<# .SYNOPSIS BakNRet 的 Pester 测试套件(单元 + 集成 + 回归)。 .DESCRIPTION 与 tests/Run-Tests.ps1 的分工: * Run-Tests.ps1 是**零依赖**冒烟套件——机器上没装 Pester 时也能跑, 适合"插上别人的机器先确认没坏"; * 本文件是 Pester 套件,覆盖同样的单元面,并额外用**子进程**真正调用 Backup.ps1 / Restore.ps1。用子进程有两个原因: 1. 两个脚本结尾都会 `exit`,在同一个进程里用 `&` 调用会把 Pester 宿主 一起带走; 2. 子进程给出的是真正的进程退出码,正好独立验证"退出码取法"这条修复。 跑法: .\tests\Run-Pester.ps1 # 推荐:会自动找到 Pester(含 .tools 下的本地副本) Invoke-Pester -Path .\tests\BakNRet.Tests.ps1 需要 Pester 5.0+。Pester 3.4.0 不支持 `Should -Be`,会被显式拒绝并给出提示。 .EXAMPLE pwsh -File .\tests\Run-Pester.ps1 #> # 发现阶段(discovery)也会执行文件顶层代码,因此 -Skip: 用到的判据必须在这里算好: # BeforeAll 里的变量在 discovery 阶段还不存在,直接引用会永远是 $null。 $script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue) $script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue) BeforeAll { $script:ProjectRoot = Split-Path -Parent $PSScriptRoot $script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1' $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' $script:CatalogPath = Join-Path $script:ProjectRoot 'SoftwareCatalog.psd1' $script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force $script:Sandbox = Join-Path $env:TEMP ('baknret-pester-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null # 在子进程里跑 Backup.ps1 / Restore.ps1,拿到真实退出码与完整输出。 function Invoke-BaknretScript { param( [Parameter(Mandatory = $true)][string]$Script, [hashtable]$Parameters = @{} ) $arguments = @('-NoProfile', '-NonInteractive', '-File', $Script) foreach ($name in ($Parameters.Keys | Sort-Object)) { $value = $Parameters[$name] if ($value -is [bool]) { if ($value) { $arguments += "-$name" } continue } $arguments += "-$name" if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } } $lines = & pwsh @arguments 2>&1 return [pscustomobject]@{ ExitCode = $LASTEXITCODE Lines = @($lines | ForEach-Object { [string]$_ }) Output = (($lines | Out-String)) } } # 造一棵确定性的源目录树,返回需要逐字节对拍的文件(相对路径 -> SHA256)。 function New-VerifiableSourceTree { param([Parameter(Mandatory = $true)][string]$Root) New-Item -ItemType Directory -Path $Root -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $Root 'Cache') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $Root 'logs') -Force | Out-Null Set-Content -LiteralPath (Join-Path $Root 'keep.txt') -Value 'keep-me' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $Root 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $Root 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $Root '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 $Root 'blob.bin'), $blob) $hashes = @{} foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') { $hashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $Root $relative) -Algorithm SHA256).Hash } return $hashes } function Write-ListFile { param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content) [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) return $Path } } AfterAll { if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) { Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue } } # ============================================================================ Describe 'BackupList.txt 解析' { # ============================================================================ It '注释行与空行返回 $null' { ConvertFrom-BackupListLine -Line '# 这是注释' | Should -BeNullOrEmpty ConvertFrom-BackupListLine -Line ' ' | Should -BeNullOrEmpty ConvertFrom-BackupListLine -Line '' | Should -BeNullOrEmpty } It '裸路径' { $result = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' $result.Path | Should -Be 'C:\Programs\FooClolor' $result.ExcludePatterns.Count | Should -Be 0 $result.Flags.Count | Should -Be 0 } It '逗号分隔的排除表被拆成多个模式(旧实现只认分号,整串会被当成一个模式)' { $result = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\' $result.Path | Should -Be 'C:\Programs\March7thAssistant' $result.ExcludePatterns.Count | Should -Be 2 $result.ExcludePatterns[0] | Should -Be 'a\b' $result.ExcludePatterns[1] | Should -Be 'c\d\' } It '分号分隔同样支持' { $result = ConvertFrom-BackupListLine -Line 'C:\x :: a;b' $result.ExcludePatterns.Count | Should -Be 2 } It '引号只包路径时排除表正常解析' { $result = ConvertFrom-BackupListLine -Line '"C:\Programs\Foo" :: logs\' $result.Path | Should -Be 'C:\Programs\Foo' $result.ExcludePatterns[0] | Should -Be 'logs\' } It '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实踩过的坑)' { $result = ConvertFrom-BackupListLine -Line '"C:\a b\CodeSpace :: Shuery-Shuai\immortalwrt\"' $result.Path | Should -Be 'C:\a b\CodeSpace' $result.ExcludePatterns.Count | Should -Be 1 $result.ExcludePatterns[0] | Should -Be 'Shuery-Shuai\immortalwrt\' } It '@ 标记单独出现' { $result = ConvertFrom-BackupListLine -Line '.ssh @encrypt' $result.Path | Should -Be '.ssh' $result.Flags | Should -Contain 'encrypt' } It '@ 标记跟在排除表后面' { $result = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt,pathname' $result.Flags | Should -Contain 'encrypt' $result.Flags | Should -Contain 'pathname' $result.ExcludePatterns.Count | Should -Be 2 } It ':+ 追加目录(可多个、位置无关)' { # 每个 :+ 记号后面跟**一个**路径:多个目录要写多个记号。 # 段内不会按空格再切分——路径本来就可以带空格。 $result = ConvertFrom-BackupListLine -Line 'Foo :+ D:\a :+ D:\b' $result.Path | Should -Be 'Foo' $result.AddedPaths.Count | Should -Be 2 $result.AddedPaths[0] | Should -Be 'D:\a' $result.AddedPaths[1] | Should -Be 'D:\b' $result.ExcludePatterns.Count | Should -Be 0 } It ':- 排除,与 :+ 混用且顺序任意' { $result = ConvertFrom-BackupListLine -Line 'Foo :- logs\ :+ D:\a :- !*Cache' $result.Path | Should -Be 'Foo' $result.AddedPaths.Count | Should -Be 1 $result.AddedPaths[0] | Should -Be 'D:\a' $result.ExcludePatterns.Count | Should -Be 2 $result.ExcludePatterns[0] | Should -Be 'logs\' $result.ExcludePatterns[1] | Should -Be '!*Cache' } It ':: 与 :- 等价' { $a = ConvertFrom-BackupListLine -Line 'Foo :: logs\' $b = ConvertFrom-BackupListLine -Line 'Foo :- logs\' $a.ExcludePatterns | Should -Be $b.ExcludePatterns } It '盘符里的单个冒号不被误当分隔符' { $result = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo :: a\b' $result.Path | Should -Be 'C:\Programs\Foo' } It 'root= 标记会被识别出来(备份端据此告警:该功能尚未实现)' { $result = ConvertFrom-BackupListLine -Line 'Foo @root=Bar' $result.Flags | Should -Contain 'root=Bar' } } # ============================================================================ Describe '归档命名' { # ============================================================================ # 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。 It '基础命名规则:末级名_from_上级路径用加号连接' { Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs' } It '/ 与 \ 以及重复分隔符结果一致' { $a = Get-BackupBaseName -RawPath 'C:\a/b' $b = Get-BackupBaseName -RawPath 'C:\\a\\\\b' $a | Should -Be $b } It '命名与路径往返' { $base = Get-BackupBaseName -RawPath 'D:\UserData\Documents\Aria' Convert-BackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria' } It '归档名里不含非法文件名字符' { $base = Get-BackupBaseName -RawPath 'C:\ac|d?e*f' ($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 } } # ============================================================================ Describe '7z 排除参数翻译' { # ============================================================================ It '相对模式自动补上归档根目录名' { $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('logs\') $arguments | Should -Contain '-x!Foo\logs' } It '已经带根目录名时不重复前缀' { $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('Foo\logs\') $arguments | Should -Contain '-x!Foo\logs' } It '! 前缀翻译成递归组件匹配' { $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache') $arguments | Should -Contain '-xr!*Cache' } It '模式里的空格转成 ? (7z 的模式不支持空格)' { $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache') $arguments | Should -Contain '-x!User?Data\Default\Code?Cache' } It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' { $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache', 'Default\Code Cache') ($arguments -join ' ') | Should -Not -Match '"' } It '空模式被忽略' { $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('', ' ', '!') @($arguments).Count | Should -Be 0 } } # ============================================================================ Describe '命令行参数拼接' { # ============================================================================ It '无空格参数原样输出' { ConvertTo-NativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9' } It '含空格参数加引号' { ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"' } It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' { ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"' } It '内部引号被转义' { ConvertTo-NativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"' } It '空参数输出一对空引号' { ConvertTo-NativeArgumentString -ArgumentList @('') | Should -Be '""' } } # ============================================================================ Describe 'manifest 与配置' { # ============================================================================ It 'manifest 读写往返' { $path = Join-Path $script:Sandbox 'roundtrip\manifest.json' $manifest = Read-BaknretManifest -Path $path $manifest.items['demo'] = [pscustomobject]@{ archive = 'demo.7z'; action = 'backed-up'; verified = $true } Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null $again = Read-BaknretManifest -Path $path $again.items.Count | Should -Be 1 $again.items['demo'].archive | Should -Be 'demo.7z' $again.items['demo'].verified | Should -BeTrue } It 'manifest 损坏时不抛异常,而是重建空清单' { $path = Write-ListFile -Path (Join-Path $script:Sandbox 'broken.json') -Content '{ this is not json' { $script:broken = Read-BaknretManifest -Path $path } | Should -Not -Throw $script:broken.items.Count | Should -Be 0 } It '配置缺失时返回默认值' { $config = Get-BaknretConfig -Path (Join-Path $script:Sandbox 'no-such-config.psd1') $config.BackupDir | Should -Be 'Backups' $config.VerifyArchive | Should -BeTrue } It '配置嵌套段落合并且不丢默认键' { $path = Write-ListFile -Path (Join-Path $script:Sandbox 'cfg.psd1') -Content "@{ MinFreeSpaceGB = 3; Encryption = @{ Enabled = `$true } }" $config = Get-BaknretConfig -Path $path $config.MinFreeSpaceGB | Should -Be 3 $config.Encryption.Enabled | Should -BeTrue # 没写到的子键要保留默认值。Encryption 是哈希表,用 ContainsKey 查。 $config.Encryption.ContainsKey('EncryptHeaders') | Should -BeTrue } It '口令:环境变量可读取,取不到时返回 $null(绝不退化成明文)' { $saved = $env:BAKNRET_PASSWORD try { $env:BAKNRET_PASSWORD = 'sekrit' (Get-BaknretPassword) | Should -Be 'sekrit' $env:BAKNRET_PASSWORD = $null (Get-BaknretPassword) | Should -BeNullOrEmpty } finally { $env:BAKNRET_PASSWORD = $saved } } } # ============================================================================ Describe '软件名录' { # ============================================================================ BeforeAll { $script:CatalogSandbox = Join-Path $script:Sandbox 'catalog' New-Item -ItemType Directory -Path $script:CatalogSandbox -Force | Out-Null $script:CatDir = Join-Path $script:CatalogSandbox 'src' New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'legendary_2.0.4') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'LegendarySomething') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'Real App') -Force | Out-Null # 先把路径算好再拼名录内容:在 @"..."@ 里嵌 $(...) 再嵌单引号很容易把 # 收尾的引号吃掉(踩过一次:生成的 .psd1 直接解析失败,而"名录读不到" # 会让下面几条断言静默通过)。 $realApp = Join-Path $script:CatDir 'Real App' $legendarySomething = Join-Path $script:CatDir 'LegendarySomething' $legendaryTarget = Join-Path $script:CatDir 'legendary' $script:CatFile = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'SoftwareCatalog.psd1') -Content @" @{ 'my-app' = '$realApp' 'dotted' = '$realApp' 'multi' = @{ Dirs = @('$realApp', '$legendarySomething') } 'legendary' = '$legendaryTarget' } "@ # 名录一旦写坏,后面几条测试会全部"静默通过"(目录查不到 → 候选集为空, # Should -Not -Contain 自然成立)。这里先把"名录本身能读进来"钉死。 $script:CatalogEntryCount = (Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3).Count } It '名录解析:裸键、引号键、带 - 与 . 的名字' { $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 $catalog.ContainsKey('my-app') | Should -BeTrue $catalog.ContainsKey('dotted') | Should -BeTrue $catalog['my-app'].Name | Should -Be 'my-app' } It '名录解析:目录带版本后缀时按前缀补全(legendary -> legendary_2.0.4)' { $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 $catalog['legendary'].ResolvedPath | Should -Be (Join-Path $script:CatDir 'legendary_2.0.4') $catalog['legendary'].Kind | Should -Be 'Single' } It '名录解析:不会把 Legendary 误配成 LegendarySomething' { $script:CatalogEntryCount | Should -Be 4 # 先确认名录真的读进来了,避免空集静默通过 $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 $catalog['legendary'].Variants | Should -Contain (Join-Path $script:CatDir 'legendary_2.0.4') $catalog['legendary'].Variants | Should -Not -Contain (Join-Path $script:CatDir 'LegendarySomething') } It '软件名条目:默认用软件名做归档名' { $entry = ConvertFrom-BackupListLine -Line 'my-app' (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'my-app' } It '字面路径条目:仍用路径命名算法(现有清单无需改写)' { $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs' } It '@pathname 用真实路径命名,而不是软件名' { $entry = ConvertFrom-BackupListLine -Line 'my-app @pathname' $expected = Get-BackupBaseName -RawPath (Join-Path $script:CatDir 'Real App') (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be $expected } It '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { $entry = ConvertFrom-BackupListLine -Line 'no-such-thing' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved.BaseName | Should -Be 'no-such-thing' $resolved.Sources.Count | Should -Be 0 $resolved.Error | Should -Not -BeNullOrEmpty } It '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' { $missingCatalog = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Missing.psd1') -Content "@{ 'gone' = 'C:\definitely-not-here-98765' }" $entry = ConvertFrom-BackupListLine -Line 'gone' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3 $resolved.Sources.Count | Should -Be 1 $resolved.Error | Should -Not -BeNullOrEmpty } It 'Includes:分文件维护的名录会被合并' { Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Extra.psd1') -Content "@{ 'extra-app' = '$script:CatDir' }" | Out-Null $main = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = '$script:CatDir' }" $catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 $catalog.ContainsKey('main-app') | Should -BeTrue $catalog.ContainsKey('extra-app') | Should -BeTrue } It '软件名会被规范化成合法文件名' { $clean = Format-CatalogName -Name 'ac:d/e' ($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 } # ---- 回归:Resolve-BackupEntry 曾经在给 $rootName 赋值之前就引用它 ---- It '[回归] 名录里没有该名字时,不会把调用方作用域里残留的 $rootName 泄漏进返回值' { # PowerShell 是动态作用域:函数会沿着**调用方**的作用域链找变量。 # 修复前 $rootName 在 return 之后才赋值,于是这里会读到 'LEAKED'。 $rootName = 'LEAKED' $entry = ConvertFrom-BackupListLine -Line 'no-such-thing' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved.RootName | Should -Not -Be 'LEAKED' $resolved.RootName | Should -Be 'no-such-thing' } } # ============================================================================ Describe '外部命令退出码(旧实现的核心缺陷)' { # ============================================================================ It 'Invoke-ExternalCommand 能拿到真实退出码' { (Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')) | Should -Be 7 } It '成功时拿到 0' { (Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0')) | Should -Be 0 } } # ============================================================================ Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSevenZip) { # ============================================================================ BeforeAll { $script:IntegrationRoot = Join-Path $script:Sandbox 'integration' $script:IntegrationSource = Join-Path $script:IntegrationRoot 'src' $script:IntegrationItem = 'User Data' $script:IntegrationPath = Join-Path $script:IntegrationSource $script:IntegrationItem foreach ($directory in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') { New-Item -ItemType Directory -Path (Join-Path $script:IntegrationPath $directory) -Force | Out-Null } Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'keep.txt') 'keep' Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Cache\c.bin') 'c' Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Code Cache\cc.bin') 'cc' Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Extensions\e.bin') 'e' Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'component_crx_cache\x.bin') 'x' Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\IndexedDB\i.bin') 'i' } It '排除规则与空格处理在真实归档上生效' { $patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB') $excludeArguments = Get-ArchiveExcludeArgument -ItemName $script:IntegrationItem -Patterns $patterns $archive = Join-Path $script:IntegrationRoot 'excl.7z' $arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem) (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList $arguments -WorkingDirectory $script:IntegrationSource) | Should -Be 0 $verify = Join-Path $script:IntegrationRoot 'verify' New-Item -ItemType Directory -Path $verify -Force | Out-Null (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0 Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\keep.txt") | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Code Cache\cc.bin") | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Extensions\e.bin") | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\component_crx_cache\x.bin") | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\IndexedDB\i.bin") | Should -BeFalse } It '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' { $archive = Join-Path $script:IntegrationRoot 'full.7z' (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $script:IntegrationItem) -WorkingDirectory $script:IntegrationSource) | Should -Be 0 $verify = Join-Path $script:IntegrationRoot 'verify-full' New-Item -ItemType Directory -Path $verify -Force | Out-Null (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0 Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeTrue } It '7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据)' { $good = Join-Path $script:IntegrationRoot 'full.7z' (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)) | Should -Be 0 $bad = Join-Path $script:IntegrationRoot 'corrupt.7z' $bytes = [System.IO.File]::ReadAllBytes($good) for ($i = [math]::Max(0, $bytes.Length - 40); $i -lt $bytes.Length; $i++) { $bytes[$i] = 0xFF } [System.IO.File]::WriteAllBytes($bad, $bytes) (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)) | Should -Not -Be 0 } } # ============================================================================ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ BeforeAll { $script:E2ERoot = Join-Path $script:Sandbox 'e2e' $script:E2ESource = Join-Path $script:E2ERoot 'src\My Code Space' $script:E2EBackupDir = Join-Path $script:E2ERoot 'Backups' $script:E2EList = Join-Path $script:E2ERoot 'list.txt' $script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :: logs\,!*Cache`n" | Out-Null $script:BackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir Force = $true QuietTool = $true } $script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json' $script:E2EManifest = Read-BaknretManifest -Path $script:E2EManifestPath } It '备份退出码为 0(旧实现会把成功的压缩判成失败)' { $script:BackupRun.ExitCode | Should -Be 0 } It '归档已生成且 manifest 记录了条目、动作与校验结果' { $archives = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z) $archives.Count | Should -Be 1 $archives[0].Length | Should -BeGreaterThan 0 $record = $script:E2EManifest.items[$archives[0].BaseName] $record | Should -Not -BeNullOrEmpty $record.action | Should -Be 'backed-up' $record.verified | Should -BeTrue $record.exitCode | Should -Be 0 } It '备份过程写了日志文件' { $logs = @(Get-ChildItem -LiteralPath (Join-Path $script:ProjectRoot 'logs') -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue) $logs.Count | Should -BeGreaterThan 0 } It '归档里保留了应当保留的内容,且不含被排除项' { $verify = Join-Path $script:E2ERoot 'verify' New-Item -ItemType Directory -Path $verify -Force | Out-Null $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0 Test-Path -LiteralPath (Join-Path $verify 'My Code Space\keep.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'My Code Space\sub\b.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'My Code Space\logs\a.log') | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify 'My Code Space\Cache\c.bin') | Should -BeFalse } # ---- 回归:manifest.roots 曾经记录的是软件名,而不是归档里真实的顶层条目名 ---- It '[回归] manifest.roots 记录的是归档内真实的顶层条目名' { $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] $record = $script:E2EManifest.items[$archive.BaseName] # 源目录名带空格,正好同时压一下"顶层名不能被空白拆开"这条 $record.roots | Should -Contain 'My Code Space' # 和归档里的真实内容对一次账,而不是只信 manifest 自己 $listing = & $script:SevenZip l -ba -slt $archive.FullName 2>$null $topLevel = @($listing | Where-Object { $_ -like 'Path = *' } | ForEach-Object { $_.Substring(7) } | Where-Object { $_ -notmatch '\\' } | Select-Object -Unique) $topLevel | Should -Be @('My Code Space') } It 'Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变)' { $before = (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash Remove-Item -LiteralPath $script:E2ESource -Recurse -Force $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir DryRun = $true } $run.ExitCode | Should -Be 0 Test-Path -LiteralPath $script:E2ESource | Should -BeFalse (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash | Should -Be $before } It 'Restore -WhatIf 同样不写盘' { $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir WhatIf = $true } $run.ExitCode | Should -Be 0 Test-Path -LiteralPath $script:E2ESource | Should -BeFalse } It '真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来' { Test-Path -LiteralPath $script:E2ESource | Should -BeFalse # 保证不是空操作 $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir Force = $true } $run.ExitCode | Should -Be 0 foreach ($relative in $script:E2EHashes.Keys) { $restored = Join-Path $script:E2ESource $relative Test-Path -LiteralPath $restored | Should -BeTrue (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash | Should -Be $script:E2EHashes[$relative] } Test-Path -LiteralPath (Join-Path $script:E2ESource 'logs\a.log') | Should -BeFalse Test-Path -LiteralPath (Join-Path $script:E2ESource 'Cache\c.bin') | Should -BeFalse } It '真实恢复之后 manifest 才被更新(lastRestoreAt)' { $manifest = Read-BaknretManifest -Path $script:E2EManifestPath $record = @($manifest.items.Values)[0] $record.lastRestoreAt | Should -Not -BeNullOrEmpty } It '孤儿归档会被点名报告,但不影响退出码' { $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] Copy-Item -LiteralPath $archive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force $run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir QuietTool = $true } $run.ExitCode | Should -Be 0 $run.Output | Should -Match '孤儿归档' $run.Output | Should -Match 'Orphaned-Archive\.7z' } It '带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿)' { $run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:E2EList BackupDir = $script:E2EBackupDir Only = @('My Code Space') QuietTool = $true } $run.Output | Should -Not -Match '孤儿归档' } It '源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败)' { $missingList = Write-ListFile -Path (Join-Path $script:E2ERoot 'missing.txt') -Content "Z:\definitely-not-here-12345`n" $run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $missingList BackupDir = $script:E2EBackupDir QuietTool = $true } $run.ExitCode | Should -Be 0 $manifest = Read-BaknretManifest -Path $script:E2EManifestPath $manifest.items['definitely-not-here-12345_from_Z_'].action | Should -Be 'missing-source' } It '归档名重复时直接报失败(退出码 1),不静默互相覆盖' { $duplicateList = Write-ListFile -Path (Join-Path $script:E2ERoot 'dup.txt') -Content "$script:E2ESource`n$script:E2ESource`n" $run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $duplicateList BackupDir = $script:E2EBackupDir Force = $true QuietTool = $true } $run.ExitCode | Should -Be 1 } }