Files
BakNRet/tests/Run-E2E.ps1
T
Shuery 2937eb6652 chore: 记录改造前基线
改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。

包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。

.gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
2026-09-26 21:46:55 +08:00

674 lines
32 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约:
1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局);
2. 软件名录条目:一个软件一个归档,包内顶层是各 Slot(`<Slot>\<内容>`);
文件 Slot 在包内是一个**名为 Slot 的文件**;
3. `:+` / Include 把宿主机目录放到指定的归档内位置;
4. Slot 前缀的排除模式(`:- AlphaData\plain`)只作用于对应 Slot;
5. 名录 Slot 自己的 Exclude(未写条目级 `:-` 时)同样生效;
6. `::` 覆盖单 Slot 条目的真实路径;
7. 行首 `+` / `-` 方向:备份端跳过 `-`、恢复端跳过 `+`,
且 `-` 条目的归档名仍然算"有主",不会被孤儿审计误报;
8. manifest 记录 `roots` 与 `layouts`(每条归档项是 dir 还是 file);
9. 重构前旧布局归档的恢复(manifest 无 layouts 时按 `<末级名>` 回退);
10. @pathname 用名录里的真实路径命名,DryRun 不写盘,失败路径留记录。
全程只在临时目录里操作,不会碰到真实 Backups\。
.EXAMPLE
pwsh -File .\tests\Run-E2E.ps1
#>
[CmdletBinding()]
param(
[string]$WorkRoot,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$projectRoot = Split-Path -Parent $PSScriptRoot
$backupScript = Join-Path $projectRoot 'Backup.ps1'
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
Reset-TestResult
if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Write-Host ""
Write-Host '== 端到端:备份 -> 布局/排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
Write-Host " 工作目录:$WorkRoot"
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
function New-E2EConfig {
<# .SYNOPSIS 写一份只指向临时目录的配置,避免污染仓库日志。 #>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$LogDir,
[string]$SoftwareCatalog,
[int]$CatalogMaxDepth = 5
)
$lines = @(
'@{'
" LogDir = '$LogDir'"
" ToolOutput = 'quiet'"
' CompressionLevel = 1'
' MinFreeSpaceGB = 0'
)
if ($SoftwareCatalog) { $lines += " SoftwareCatalog = '$SoftwareCatalog'" }
$lines += " CatalogMaxDepth = $CatalogMaxDepth"
$lines += '}'
[System.IO.File]::WriteAllText($Path, ($lines -join "`r`n"), [System.Text.UTF8Encoding]::new($false))
}
function Get-NewestLog {
<# .SYNOPSIS 取日志目录里最新的 backup-*.log / restore-*.log。 #>
param([Parameter(Mandatory = $true)][string]$LogDir, [Parameter(Mandatory = $true)][string]$Prefix)
return Get-ChildItem -LiteralPath $LogDir -File -Filter "$Prefix-*.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime | Select-Object -Last 1
}
function Get-ArchiveNames {
param([Parameter(Mandatory = $true)][string]$Dir)
return @(Get-ChildItem -LiteralPath $Dir -File -Filter *.7z -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty BaseName)
}
function Get-OrphanNames {
<# .SYNOPSIS 从备份日志里解析出"孤儿归档"那一段点名的归档名。 #>
param([Parameter(Mandatory = $true)][string]$LogPath)
$names = @()
$inSection = $false
foreach ($line in @(Get-Content -LiteralPath $LogPath -Encoding UTF8)) {
if ($line -match '孤儿归档') { $inSection = $true; continue }
if (-not $inSection) { continue }
if ($line -match '-\s+([^\s()]+?)(') {
$names += $Matches[1]
} else {
$inSection = $false
}
}
return @($names)
}
function Get-Sha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}
# ============================================================================
# 1. 字面路径条目:备份 -> 排除 -> 删源 -> 恢复 -> 逐字节对拍
# ============================================================================
$sourceParent = Join-Path $WorkRoot 'src'
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
$backupDir = Join-Path $WorkRoot 'Backups'
$listPath = Join-Path $WorkRoot 'list.txt'
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
$verifyDir = Join-Path $WorkRoot 'verify'
$logDir1 = Join-Path $WorkRoot 'logs1'
$cfg1 = Join-Path $WorkRoot 'cfg1.psd1'
New-E2EConfig -Path $cfg1 -LogDir $logDir1
foreach ($dir in 'logs', 'sub', 'Cache') {
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
# 一个确定性随机二进制文件,用于逐字节对拍
$blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :- logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
$expectedHashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$expectedHashes[$relative] = Get-Sha256 (Join-Path $source $relative)
}
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
Assert-Equal 0 $backupExitCode
}
$archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
Test-Case '归档已生成' {
Assert-Equal 1 $archives.Count
Assert-True ($archives[0].Length -gt 0) '归档不应为空'
}
$manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' {
Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count
$record = $manifest.items[$archives[0].BaseName]
Assert-True ($null -ne $record) 'manifest 里应有对应条目'
Assert-Equal 'backed-up' $record.action
Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source
Assert-Equal 5 $record.sourceFiles '源里 5 个文件(排除只影响打包,不影响统计)'
Assert-Equal 1 $record.roots.Count
Assert-Equal 'My Code Space' $record.roots[0]
Assert-Equal 1 $record.layouts.Count
Assert-Equal 'My Code Space' $record.layouts[0].name
Assert-Equal 'dir' $record.layouts[0].kind
}
Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' {
$logs = @(Get-ChildItem -LiteralPath $logDir1 -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
}
if ($sevenZip) {
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
}
Test-Case '字面路径条目保留历史布局(包内顶层是源目录名)' {
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
}
Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除'
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
}
Remove-Item -LiteralPath $source -Recurse -Force
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
Assert-FileMissing $source
}
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force
$restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' {
Assert-Equal 0 $restoreExitCode
}
Test-Case '恢复出的文件与源逐字节一致' {
foreach ($relative in $expectedHashes.Keys) {
$restored = Join-Path $source $relative
Assert-FileExists $restored
Assert-Equal $expectedHashes[$relative] (Get-Sha256 $restored) "对拍 $relative"
}
}
Test-Case '被排除的内容没有被恢复出来' {
Assert-FileMissing (Join-Path $source 'logs\a.log')
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
}
# ============================================================================
# 2. 保护规则:有警告时不拿不完整的归档覆盖完整归档
# ============================================================================
$lockSource = Join-Path $sourceParent 'Locked Case'
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
$lockList = Join-Path $WorkRoot 'lock.txt'
$logDir2 = Join-Path $WorkRoot 'logs2'
$cfg2 = Join-Path $WorkRoot 'cfg2.psd1'
New-E2EConfig -Path $cfg2 -LogDir $logDir2
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
Assert-Equal 0 $cleanExitCode
Assert-Equal 'backed-up' $cleanRecord.action
Assert-Equal $false $cleanRecord.warnings
}
# 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告
Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8
$lockedPath = Join-Path $lockSource 'locked.bin'
Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
Assert-Equal 1 $warnExitCode
Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致'
Assert-Equal 'failed' $afterRecord.action
Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的'
Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告'
}
# 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
Assert-Equal 0 $acceptExitCode
Assert-Equal 'backed-up' $acceptedRecord.action
Assert-Equal $true $acceptedRecord.warnings
}
} finally {
$lockStream.Close()
$lockStream.Dispose()
}
# ============================================================================
# 3. 软件名录:Slot 布局(目录 Slot / 文件 Slot / Include / Slot 前缀排除)
# ============================================================================
$catRoot = Join-Path $WorkRoot 'catalog'
$catAppRoot = Join-Path $catRoot 'apps'
$dirA = Join-Path $catAppRoot 'A'
$dirA2 = Join-Path $catAppRoot 'A2'
$settingsFile = Join-Path $catAppRoot 'settings.json'
$incDir = Join-Path $catAppRoot 'inc'
$catBackupDir = Join-Path $catRoot 'Backups'
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
$catList = Join-Path $catRoot 'list.txt'
$catConfig = Join-Path $catRoot 'config.psd1'
$catLogDir = Join-Path $catRoot 'logs'
$catExtract = Join-Path $catRoot 'verify'
foreach ($dir in (Join-Path $dirA 'sub'), (Join-Path $dirA 'plain'), (Join-Path $dirA 'skip'), (Join-Path $dirA2 'skip'), $incDir) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $dirA 'keep.txt') -Value 'A-keep' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'sub\keep2.txt') -Value 'A-sub' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'plain\p.bin') -Value 'A-plain' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'skip\s.bin') -Value 'A-skip' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA2 'keep.txt') -Value 'A2-keep' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA2 'skip\s.bin') -Value 'A2-skip' -Encoding UTF8
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $incDir 'i.txt') -Value 'included' -Encoding UTF8
[System.IO.File]::WriteAllText($catFile, @"
@{
'my-app' = @{
AlphaData = @{ Path = '$dirA' }
BetaFile = @{ Path = '$settingsFile' }
}
'cat-excl' = @{
Data = @{ Path = '$dirA2'; Exclude = '!*skip' }
}
}
"@, [System.Text.UTF8Encoding]::new($false))
# 条目级排除用 Slot 前缀点名(AlphaData\plain)+ 任意层级(!*skip);:+ 把 inc 放到归档内 Modules\
[System.IO.File]::WriteAllText($catList, "my-app :- AlphaData\plain,!*skip :+ Modules:$incDir`ncat-excl`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $catConfig -LogDir $catLogDir -SoftwareCatalog $catFile
$catHashes = @{
(Join-Path $dirA 'keep.txt') = Get-Sha256 (Join-Path $dirA 'keep.txt')
(Join-Path $dirA 'sub\keep2.txt') = Get-Sha256 (Join-Path $dirA 'sub\keep2.txt')
$settingsFile = Get-Sha256 $settingsFile
(Join-Path $incDir 'i.txt') = Get-Sha256 (Join-Path $incDir 'i.txt')
}
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$catExitCode = $LASTEXITCODE
Test-Case '名录条目:一个软件一个归档,归档名 = 软件名;独立条目各自成包' {
Assert-Equal 0 $catExitCode
Assert-FileExists (Join-Path $catBackupDir 'my-app.7z')
Assert-FileExists (Join-Path $catBackupDir 'cat-excl.7z')
}
Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' {
$record = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app']
Assert-True ($null -ne $record)
$roots = @($record.roots | Sort-Object)
Assert-Equal 3 $roots.Count
Assert-Equal 'AlphaData' $roots[0]
Assert-Equal 'BetaFile' $roots[1]
Assert-Equal 'Modules' $roots[2]
$layoutMap = @{}
foreach ($layout in $record.layouts) { $layoutMap[$layout.name] = $layout.kind }
Assert-Equal 'dir' $layoutMap['AlphaData']
Assert-Equal 'file' $layoutMap['BetaFile']
Assert-Equal 'dir' $layoutMap['Modules']
}
New-Item -ItemType Directory -Path $catExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$catExtract", (Join-Path $catBackupDir 'my-app.7z'))
}
Test-Case '归档内顶层是 Slot 名:<Slot>\<内容>' {
Assert-FileExists (Join-Path $catExtract 'AlphaData\keep.txt') 'AlphaData 必须是包内的一层目录'
Assert-FileExists (Join-Path $catExtract 'AlphaData\sub\keep2.txt')
Assert-FileMissing (Join-Path $catExtract 'A\keep.txt') '包内不该出现宿主机上的目录名'
Assert-FileMissing (Join-Path $catExtract 'my-app') '包内不该多出一层软件名'
}
Test-Case '文件 Slot 在包内是一个名为 Slot 的文件(没有扩展名)' {
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'BetaFile') -PathType Leaf) 'BetaFile 应是文件'
Assert-FileMissing (Join-Path $catExtract 'BetaFile.json')
Assert-FileMissing (Join-Path $catExtract 'settings.json') '文件 Slot 不保留原文件名'
}
Test-Case ':+ / Include 把宿主机目录放到指定的归档内位置' {
Assert-FileExists (Join-Path $catExtract 'Modules\i.txt')
}
Test-Case 'Slot 前缀的排除模式只作用在对应 Slot 上(AlphaData\plain)' {
Assert-FileMissing (Join-Path $catExtract 'AlphaData\plain\p.bin')
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'AlphaData\keep.txt')) '未被点名的文件必须留着'
}
Test-Case '任意层级模式 (!*skip) 广播到每个归档项' {
Assert-FileMissing (Join-Path $catExtract 'AlphaData\skip\s.bin')
}
Test-Case '名录 Slot 自己的 Exclude 在没有条目级 :- 时同样生效' {
$exclExtract = Join-Path $catRoot 'verify-excl'
New-Item -ItemType Directory -Path $exclExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$exclExtract", (Join-Path $catBackupDir 'cat-excl.7z'))
}
Assert-FileExists (Join-Path $exclExtract 'Data\keep.txt')
Assert-FileMissing (Join-Path $exclExtract 'Data\skip\s.bin') '名录 Slot 的 Exclude 应把 skip 挡在包外'
}
Remove-Item -LiteralPath $dirA -Recurse -Force
Remove-Item -LiteralPath $settingsFile -Force
Remove-Item -LiteralPath $incDir -Recurse -Force
Test-Case '删源后按 Slot 恢复:目录 / 文件 / 追加项各自回到自己的 Path' {
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
Assert-Equal 0 $LASTEXITCODE
foreach ($path in $catHashes.Keys) {
Assert-FileExists $path
Assert-Equal $catHashes[$path] (Get-Sha256 $path) "对拍 $path"
}
}
Test-Case '恢复不会把被排除的内容带回来' {
Assert-FileMissing (Join-Path $dirA 'plain\p.bin')
Assert-FileMissing (Join-Path $dirA 'skip\s.bin')
Assert-FileMissing (Join-Path $catAppRoot 'BetaFile') '文件 Slot 的归档内名字不该落到宿主机上'
}
Test-Case '@pathname 覆盖:用名录里的真实路径跑命名算法(独立备份目录,避免污染共享 manifest)' {
$pathList = Join-Path $catRoot 'list-pathname.txt'
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
[System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($false))
# settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
$expectedBase = Get-BackupBaseName -RawPath $dirA
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
}
# ============================================================================
# 4. :: 覆盖单 Slot 条目的真实路径
# ============================================================================
$ovrRoot = Join-Path $WorkRoot 'override'
$ovrTarget = Join-Path $ovrRoot 'target'
$ovrBackupDir = Join-Path $ovrRoot 'Backups'
$ovrCatalog = Join-Path $ovrRoot 'catalog.psd1'
$ovrList = Join-Path $ovrRoot 'list.txt'
$ovrConfig = Join-Path $ovrRoot 'config.psd1'
$ovrExtract = Join-Path $ovrRoot 'verify'
New-Item -ItemType Directory -Path $ovrTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Value 'override-target' -Encoding UTF8
[System.IO.File]::WriteAllText($ovrCatalog, "@{`n 'ovr-app' = @{ DefaultData = @{ Path = '$ovrRoot\missing-src' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($ovrList, "ovr-app :: $ovrTarget`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $ovrConfig -LogDir (Join-Path $ovrRoot 'logs') -SoftwareCatalog $ovrCatalog
& $backupScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force -QuietTool
$ovrExitCode = $LASTEXITCODE
$ovrArchives = @(Get-ChildItem -LiteralPath $ovrBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
Test-Case ':: 覆盖:备份包内容来自被覆盖的路径,且归档项名仍是 Slot 名' {
Assert-Equal 0 $ovrExitCode
Assert-Equal 1 $ovrArchives.Count
New-Item -ItemType Directory -Path $ovrExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$ovrExtract", $ovrArchives[0].FullName)
}
Assert-FileExists (Join-Path $ovrExtract 'DefaultData\t.txt')
Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrExtract 'DefaultData\t.txt') -Raw).Trim()
}
Test-Case ':: 覆盖:删掉被覆盖的源后仍能恢复回该路径' {
Remove-Item -LiteralPath $ovrTarget -Recurse -Force
& $restoreScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $ovrTarget 't.txt')
Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Raw).Trim()
}
# ============================================================================
# 5. 方向标记:备份跳 `-`、恢复跳 `+`;`-` 的归档名仍算有主(孤儿审计)
# ============================================================================
$dirRoot = Join-Path $WorkRoot 'direction'
$appA = Join-Path $dirRoot 'A'
$appB = Join-Path $dirRoot 'B'
$dirBackupDir = Join-Path $dirRoot 'Backups'
$dirCatalog = Join-Path $dirRoot 'catalog.psd1'
$dirList1 = Join-Path $dirRoot 'list1.txt'
$dirList2 = Join-Path $dirRoot 'list2.txt'
$dirConfig = Join-Path $dirRoot 'config.psd1'
$dirLogDir = Join-Path $dirRoot 'logs'
New-Item -ItemType Directory -Path $appA -Force | Out-Null
New-Item -ItemType Directory -Path $appB -Force | Out-Null
Set-Content -LiteralPath (Join-Path $appA 'a.txt') -Value 'dir-a' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $appB 'b.txt') -Value 'dir-b' -Encoding UTF8
[System.IO.File]::WriteAllText($dirCatalog, "@{`n 'app-a' = @{ DefaultData = @{ Path = '$appA' } }`n 'app-b' = @{ DefaultData = @{ Path = '$appB' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $dirConfig -LogDir $dirLogDir -SoftwareCatalog $dirCatalog
[System.IO.File]::WriteAllText($dirList1, "+ app-a`napp-b`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($dirList2, "+ app-a`n- app-b`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $dirList1 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
Test-Case '行首 + = 仅备份:仍然会被打包' {
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $dirBackupDir 'app-a.7z')
Assert-FileExists (Join-Path $dirBackupDir 'app-b.7z')
}
# 造一个真孤儿,验证审计仍然会点名它
Copy-Item -LiteralPath (Join-Path $dirBackupDir 'app-a.7z') -Destination (Join-Path $dirBackupDir 'zzz-orphan.7z')
Start-Sleep -Milliseconds 1100 # 日志按秒命名,避免两次运行撞进同一个文件名
& $backupScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
$dirExitCode = $LASTEXITCODE
$dirLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'backup'
Test-Case '行首 - = 仅备份端跳过(日志点名),不产生归档' {
Assert-Equal 0 $dirExitCode
$content = Get-Content -LiteralPath $dirLog.FullName -Raw -Encoding UTF8
Assert-True ($content -like '*跳过(行首 -,仅恢复)*') '日志里应说明为什么跳过'
}
Test-Case '孤儿审计:`-` 条目的归档名算有主,真孤儿才被点名' {
$orphans = Get-OrphanNames -LogPath $dirLog.FullName
Assert-True ($orphans -contains 'zzz-orphan.7z') '真孤儿必须被点名'
Assert-False ($orphans -contains 'app-b.7z') '`-` 条目的归档不能被误报成孤儿'
}
Test-Case '恢复端跳过行首 + 的条目、照常恢复 - 的条目' {
Remove-Item -LiteralPath $appA -Recurse -Force
Remove-Item -LiteralPath $appB -Recurse -Force
# -Verbose 打开 DEBUG 日志,才能从日志里读到"为什么跳过"(默认只打 INFO)
& $restoreScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -Verbose
Assert-Equal 0 $LASTEXITCODE
Assert-FileMissing $appA '行首 + 的条目不该被恢复'
Assert-FileExists (Join-Path $appB 'b.txt')
Assert-Equal 'dir-b' (Get-Content -LiteralPath (Join-Path $appB 'b.txt') -Raw).Trim()
$restoreLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'restore'
$restoreContent = Get-Content -LiteralPath $restoreLog.FullName -Raw -Encoding UTF8
Assert-True ($restoreContent -like '*跳过(行首 +,仅备份)*') '日志里应说明为什么跳过'
}
# ============================================================================
# 6. 旧布局归档的恢复(manifest 没有 layouts 时按 <末级名> 回退)
# ============================================================================
$legacyRoot = Join-Path $WorkRoot 'legacy'
$legacyLive = Join-Path $legacyRoot 'live\settings.json'
$legacyBackupDir = Join-Path $legacyRoot 'Backups'
$legacyCatalog = Join-Path $legacyRoot 'catalog.psd1'
$legacyList = Join-Path $legacyRoot 'list.txt'
$legacyConfig = Join-Path $legacyRoot 'config.psd1'
$legacyLogDir = Join-Path $legacyRoot 'logs'
$legacyScratch = Join-Path $legacyRoot 'scratch'
New-Item -ItemType Directory -Path (Split-Path -Parent $legacyLive) -Force | Out-Null
New-Item -ItemType Directory -Path $legacyBackupDir -Force | Out-Null
New-Item -ItemType Directory -Path $legacyScratch -Force | Out-Null
Set-Content -LiteralPath $legacyLive -Value '{"version":"old-layout"}' -Encoding UTF8
# 手工造一份重构前布局的归档:包内顶层直接是源文件的名字
$legacySourceFile = Join-Path $legacyScratch 'settings.json'
Copy-Item -LiteralPath $legacyLive -Destination $legacySourceFile
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', (Join-Path $legacyBackupDir 'legacy-file.7z'), 'settings.json') `
-WorkingDirectory $legacyScratch
}
[System.IO.File]::WriteAllText($legacyCatalog, "@{`n 'legacy-file' = @{ LegacyData = @{ Path = '$legacyLive' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog
$legacyManifest = Read-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json')
$legacyManifest.items['legacy-file'] = [ordered]@{
baseName = 'legacy-file'
source = 'legacy-file'
archive = 'legacy-file.7z'
action = 'backed-up'
encrypted = $false
}
Write-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null
# 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容
Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8
if ($sevenZip) {
& $restoreScript -BackupListPath $legacyList -BackupDir $legacyBackupDir -ConfigPath $legacyConfig -Force
$legacyExitCode = $LASTEXITCODE
Test-Case '旧布局归档:按 <末级名> 回退,把文件还原回原位' {
Assert-Equal 0 $legacyExitCode
Assert-Equal '{"version":"old-layout"}' (Get-Content -LiteralPath $legacyLive -Raw).Trim()
$legacyLog = Get-NewestLog -LogDir $legacyLogDir -Prefix 'restore'
$legacyContent = Get-Content -LiteralPath $legacyLog.FullName -Raw -Encoding UTF8
Assert-True ($legacyContent -like '*按旧布局回退*') '回退时必须给出明确告警'
}
}
# ============================================================================
# 7. DryRun 不写盘
# ============================================================================
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -DryRun
$dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-Equal 0 $dryRestoreExitCode
Assert-FileMissing $source
}
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
Assert-Equal 0 $dryBackupExitCode
if (Test-Path -LiteralPath $dryBackupDir) {
$written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue)
Assert-Equal 0 $written.Count '试运行不应写入任何文件'
}
}
# ============================================================================
# 8. 失败路径
# ============================================================================
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
$dupList = Join-Path $catRoot 'dup.txt'
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $dupList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 1 $LASTEXITCODE '重复条目应让退出码为 1'
}
Test-Case '字面路径不受名录影响,仍走路径命名' {
$literalList = Join-Path $catRoot 'list-literal.txt'
$literalDir = Join-Path $catRoot 'Backups-literal'
[System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $literalDir ((Get-BackupBaseName -RawPath $dirA2) + '.7z'))
}
$missingList = Join-Path $WorkRoot 'missing.txt'
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
Assert-Equal 0 $missingExitCode
$after = Read-BaknretManifest -Path $manifestPath
$record = $after.items['definitely-not-here-12345_from_Z_']
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
Assert-Equal 'missing-source' $record.action
}
# ============================================================================
if ($KeepWorkRoot) {
Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow
} else {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
$failed = Write-TestSummary -Title '端到端验收'
if ($failed -gt 0) { exit 1 }
exit 0