diff --git a/Tests/ConfigManager/Merge-ConfigOverrides.Tests.ps1 b/Tests/ConfigManager/Merge-ConfigOverrides.Tests.ps1 new file mode 100644 index 0000000..99e41f3 --- /dev/null +++ b/Tests/ConfigManager/Merge-ConfigOverrides.Tests.ps1 @@ -0,0 +1,193 @@ +#Requires -Module Pester + +# Issue #19 -- unit coverage for the global<-node<-instance deep-merge primitive. +# +# Merge-ConfigOverrides is the engine ConfigManager uses to layer overrides onto defaults +# (Get-SEBInstanceConfig merges the instance config over the global [defaults] table, and the +# node layer over that). The contract that the higher layers rely on: +# * nested tables are merged RECURSIVELY (a deeper override key wins while its unrelated +# siblings survive) -- they are NOT replaced wholesale; +# * a scalar override replaces the default scalar; +# * a key present only in the override is added; a key present only in the default survives; +# * a TYPE MISMATCH (table-vs-scalar either way) takes the override verbatim -- no merge; +# * neither input hashtable is mutated (the result is a fresh tree). +# +# This is pure hashtable-in / hashtable-out, so the Private function is dot-sourced directly and +# exercised with in-memory tables -- no module import, no mocks. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + . "$repoRoot/Modules/ConfigManager/Private/Merge-ConfigOverrides.ps1" +} + +Describe 'Merge-ConfigOverrides' { + + Context 'scalar overrides' { + It 'replaces a scalar default with the override value' { + $default = @{ port = 8080; timeout = 120 } + $override = @{ port = 9090 } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['port'] | Should -Be 9090 + $merged['timeout'] | Should -Be 120 -Because 'an unrelated default key survives' + } + + It 'adds a key that exists only in the override' { + $default = @{ a = 1 } + $override = @{ b = 2 } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['a'] | Should -Be 1 + $merged['b'] | Should -Be 2 + } + + It 'keeps a key that exists only in the default' { + $default = @{ a = 1; b = 2 } + $override = @{ a = 99 } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged.Keys | Should -Contain 'b' + $merged['b'] | Should -Be 2 + } + } + + Context 'nested-table overrides (deep merge)' { + It 'merges a nested table key-by-key instead of replacing it wholesale' { + $default = @{ + vrage_api = @{ port = 8080; save_timeout_seconds = 120 } + compression = @{ engine = 'auto' } + } + $override = @{ + vrage_api = @{ port = 9090 } + } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + # Deeper override key wins... + $merged['vrage_api']['port'] | Should -Be 9090 + # ...while its unrelated sibling survives (this is what "deep" buys over a wholesale swap). + $merged['vrage_api']['save_timeout_seconds'] | Should -Be 120 + # ...and an entirely untouched sibling table is intact. + $merged['compression']['engine'] | Should -Be 'auto' + } + + It 'recurses through more than one level of nesting' { + $default = @{ + outer = @{ inner = @{ keep = 'yes'; change = 'old' } } + } + $override = @{ + outer = @{ inner = @{ change = 'new' } } + } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['outer']['inner']['change'] | Should -Be 'new' + $merged['outer']['inner']['keep'] | Should -Be 'yes' + } + + It 'adds a brand-new nested table that exists only in the override' { + $default = @{ existing = @{ a = 1 } } + $override = @{ added = @{ b = 2 } } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['existing']['a'] | Should -Be 1 + $merged['added']['b'] | Should -Be 2 + } + } + + Context 'type mismatches take the override verbatim' { + It 'replaces a scalar default with a table override (no merge attempted)' { + $default = @{ section = 'scalar-value' } + $override = @{ section = @{ now = 'a-table' } } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['section'] | Should -BeOfType [hashtable] + $merged['section']['now'] | Should -Be 'a-table' + } + + It 'replaces a table default with a scalar override (no merge attempted)' { + $default = @{ section = @{ a = 1 } } + $override = @{ section = 'now-a-scalar' } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['section'] | Should -Be 'now-a-scalar' + $merged['section'] | Should -Not -BeOfType [hashtable] + } + } + + Context 'array and null handling' { + It 'replaces an array default with the override array (arrays are not element-merged)' { + $default = @{ excludes = @('*.log', '*.tmp') } + $override = @{ excludes = @('*.bak') } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + # Arrays are not hashtables, so the override value wins as a unit. + $merged['excludes'] | Should -Be @('*.bak') + $merged['excludes'].Count | Should -Be 1 + } + + It 'lets a $null override value replace a non-null default' { + $default = @{ key = 'has-value' } + $override = @{ key = $null } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + # The key is present in the override, so its (null) value wins over the default. + $merged.ContainsKey('key') | Should -BeTrue + $merged['key'] | Should -BeNullOrEmpty + } + + It 'preserves a default value when the override does not mention the key (absent != null)' { + $default = @{ keep = 'value'; other = 1 } + $override = @{ other = 2 } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + $merged['keep'] | Should -Be 'value' + } + } + + Context 'empty inputs' { + It 'returns the default unchanged when the override is empty' { + $default = @{ a = 1; b = @{ c = 2 } } + $merged = Merge-ConfigOverrides -Default $default -Override @{} + + $merged['a'] | Should -Be 1 + $merged['b']['c'] | Should -Be 2 + } + + It 'returns the override values when the default is empty' { + $override = @{ a = 1 } + $merged = Merge-ConfigOverrides -Default @{} -Override $override + + $merged['a'] | Should -Be 1 + } + + It 'returns an empty hashtable when both inputs are empty' { + $merged = Merge-ConfigOverrides -Default @{} -Override @{} + $merged | Should -BeOfType [hashtable] + $merged.Count | Should -Be 0 + } + } + + Context 'non-destructive (inputs are not mutated)' { + It 'does not mutate either input hashtable' { + $default = @{ shared = @{ a = 1 }; only_default = 'd' } + $override = @{ shared = @{ b = 2 }; only_override = 'o' } + + $null = Merge-ConfigOverrides -Default $default -Override $override + + # Default must not gain the override's keys. + $default.ContainsKey('only_override') | Should -BeFalse + $default['shared'].ContainsKey('b') | Should -BeFalse + # Override must not gain the default's keys. + $override.ContainsKey('only_default') | Should -BeFalse + $override['shared'].ContainsKey('a') | Should -BeFalse + } + + It 'returns a new top-level hashtable, not one of the inputs' { + $default = @{ a = 1 } + $override = @{ b = 2 } + $merged = Merge-ConfigOverrides -Default $default -Override $override + + [object]::ReferenceEquals($merged, $default) | Should -BeFalse + [object]::ReferenceEquals($merged, $override) | Should -BeFalse + } + } +} diff --git a/Tests/ConfigManager/Test-SEBConfig.Validation.Tests.ps1 b/Tests/ConfigManager/Test-SEBConfig.Validation.Tests.ps1 new file mode 100644 index 0000000..5d45903 --- /dev/null +++ b/Tests/ConfigManager/Test-SEBConfig.Validation.Tests.ps1 @@ -0,0 +1,349 @@ +#Requires -Module Pester + +# Issue #19 -- edge-case validation coverage for Test-SEBConfig beyond the Issue #14 instance-schema +# tests in Get-SEBInstanceConfig.Tests.ps1. Those pin the instance contract; this file pins the +# GLOBAL and NODE validators and a handful of cross-cutting type/range checks: +# * missing required sections/keys at each level produce ERRORS (IsValid = $false); +# * wrong scalar TYPES (string where int expected, etc.) produce ERRORS; +# * out-of-range numeric values (ports, percentages, chain length) produce ERRORS; +# * soft issues (missing optional sections, odd-but-tolerated values) produce WARNINGS, not errors. +# +# All inputs are in-memory [hashtable]s passed via -Config (already the hashtable shape the engine +# builds), so these are pure in/out assertions -- no TOML parse, no mocks. + +BeforeAll { + $script:repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$script:repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # A minimal global config that PASSES, used as the base each negative test perturbs so the + # assertion isolates the one thing under test. + function New-ValidGlobalConfig { + @{ + storage = @{ cc_backup_root = 'C:\SEBackup\Backups'; nas_backup_path = '\\nas01\Backups' } + retention = @{ cc_full_count = 5; nas_retention_days = 30 } + schedule = @{ enabled = $true; interval_hours = 6; start_time = '02:00'; max_incremental_chain_length = 7 } + compression = @{ engine = 'auto'; level_7zip = 5 } + network = @{ max_bandwidth_mbps = 100 } + load_awareness = @{ max_cpu_percent = 80; max_memory_percent = 85; sim_speed_threshold = 0.8 } + defaults = @{ + vrage_api = @{ port = 8080; save_timeout_seconds = 120 } + vss = @{ mount_base = 'C:\SEBackup\vss_mount' } + } + } + } + + function New-ValidNodeConfig { + @{ node = @{ hostname = 'gamingpc01'; username = 'SEBackupSvc'; display_name = 'Gaming PC 01' } } + } +} + +Describe 'Test-SEBConfig -Type Global' { + + It 'reports a complete, well-typed global config as valid' { + $result = Test-SEBConfig -Type Global -Config (New-ValidGlobalConfig) + $result.IsValid | Should -BeTrue -Because "validator errors: $($result.Errors -join '; ')" + } + + Context 'missing required keys' { + It 'errors when the [storage] section is missing entirely' { + $cfg = New-ValidGlobalConfig + $cfg.Remove('storage') + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'storage' + } + + It 'errors when [storage].cc_backup_root is empty' { + $cfg = New-ValidGlobalConfig + $cfg.storage.cc_backup_root = '' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'cc_backup_root' + } + + It 'warns (not errors) when an optional section like [retention] is absent' { + $cfg = New-ValidGlobalConfig + $cfg.Remove('retention') + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeTrue -Because 'retention is optional with defaults' + ($result.Warnings -join ' ') | Should -Match 'retention' + } + + It 'warns when [storage].nas_backup_path is not set' { + $cfg = New-ValidGlobalConfig + $cfg.storage.Remove('nas_backup_path') + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeTrue + ($result.Warnings -join ' ') | Should -Match 'nas_backup_path' + } + } + + Context 'wrong types' { + It 'errors when [storage] is a scalar instead of a table' { + $cfg = New-ValidGlobalConfig + $cfg.storage = 'not-a-table' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'storage' + } + + It 'errors when [retention].cc_full_count is a string instead of an integer' { + $cfg = New-ValidGlobalConfig + $cfg.retention.cc_full_count = 'five' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'cc_full_count' + } + + It 'errors when [schedule].enabled is not a boolean' { + $cfg = New-ValidGlobalConfig + $cfg.schedule.enabled = 'yes' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'enabled' + } + + It 'errors when [schedule].interval_hours is not an integer' { + $cfg = New-ValidGlobalConfig + $cfg.schedule.interval_hours = '6h' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'interval_hours' + } + + It 'errors when [network].max_bandwidth_mbps is not an integer' { + $cfg = New-ValidGlobalConfig + $cfg.network.max_bandwidth_mbps = 'fast' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'max_bandwidth_mbps' + } + } + + Context 'out-of-range values' { + It 'errors when [defaults.vrage_api].port is above 65535' { + $cfg = New-ValidGlobalConfig + $cfg.defaults.vrage_api.port = 70000 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'port' + } + + It 'errors when [defaults.vrage_api].port is below 1' { + $cfg = New-ValidGlobalConfig + $cfg.defaults.vrage_api.port = 0 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'port' + } + + It 'errors when [defaults.vrage_api].save_timeout_seconds is not positive' { + $cfg = New-ValidGlobalConfig + $cfg.defaults.vrage_api.save_timeout_seconds = 0 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'save_timeout_seconds' + } + + It 'errors when [schedule].max_incremental_chain_length is below 1' { + $cfg = New-ValidGlobalConfig + $cfg.schedule.max_incremental_chain_length = 0 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'max_incremental_chain_length' + } + + It 'errors when [load_awareness].max_cpu_percent exceeds 100' { + $cfg = New-ValidGlobalConfig + $cfg.load_awareness.max_cpu_percent = 150 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'max_cpu_percent' + } + + It 'errors when [load_awareness].max_memory_percent is below 1' { + $cfg = New-ValidGlobalConfig + $cfg.load_awareness.max_memory_percent = 0 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'max_memory_percent' + } + + It 'warns (not errors) when [compression].level_7zip is outside 1-9' { + $cfg = New-ValidGlobalConfig + $cfg.compression.level_7zip = 12 + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeTrue -Because 'an odd compression level is tolerated with a warning' + ($result.Warnings -join ' ') | Should -Match 'level_7zip' + } + + It 'errors when [compression].engine is not one of the allowed engines' { + $cfg = New-ValidGlobalConfig + $cfg.compression.engine = 'gzip' + $result = Test-SEBConfig -Type Global -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'engine' + } + } +} + +Describe 'Test-SEBConfig -Type Node' { + + It 'reports a complete node config as valid' { + $result = Test-SEBConfig -Type Node -Config (New-ValidNodeConfig) + $result.IsValid | Should -BeTrue -Because "validator errors: $($result.Errors -join '; ')" + } + + It 'errors when the [node] section is missing' { + $result = Test-SEBConfig -Type Node -Config @{ something = 'else' } + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'node' + } + + It 'errors when [node] is a scalar instead of a table' { + $result = Test-SEBConfig -Type Node -Config @{ node = 'gamingpc01' } + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'node' + } + + It 'errors when [node].hostname is empty' { + $cfg = New-ValidNodeConfig + $cfg.node.hostname = '' + $result = Test-SEBConfig -Type Node -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'hostname' + } + + It 'errors when [node].username is empty' { + $cfg = New-ValidNodeConfig + $cfg.node.username = '' + $result = Test-SEBConfig -Type Node -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'username' + } + + It 'accepts a dotted IP address as a hostname without warning' { + $cfg = New-ValidNodeConfig + $cfg.node.hostname = '192.168.1.50' + $result = Test-SEBConfig -Type Node -Config $cfg + $result.IsValid | Should -BeTrue + ($result.Warnings -join ' ') | Should -Not -Match 'hostname' + } + + It 'warns on a hostname that looks neither like DNS nor an IP' { + $cfg = New-ValidNodeConfig + $cfg.node.hostname = 'bad host name!' + $result = Test-SEBConfig -Type Node -Config $cfg + # Still "valid" (hostname is present) but the operator is nudged. + $result.IsValid | Should -BeTrue + ($result.Warnings -join ' ') | Should -Match 'hostname' + } + + It 'warns (not errors) when [node].display_name is absent' { + $cfg = New-ValidNodeConfig + $cfg.node.Remove('display_name') + $result = Test-SEBConfig -Type Node -Config $cfg + $result.IsValid | Should -BeTrue + ($result.Warnings -join ' ') | Should -Match 'display_name' + } +} + +Describe 'Test-SEBConfig -Type Instance (edge cases)' { + + It 'errors when the [instance] section is missing' { + $cfg = @{ + world_path = 'C:\W\Saves\X' + share_name = 'SEBackup_X$' + vrage_api = @{ port = 8080; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'instance' + } + + It 'errors when [instance].name is empty' { + $cfg = @{ + instance = @{ name = ''; display_name = 'X' } + world_path = 'C:\W\Saves\X' + share_name = 'SEBackup_X$' + vrage_api = @{ port = 8080; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'name' + } + + It 'errors when world_path is missing (the required top-level key)' { + $cfg = @{ + instance = @{ name = 'X'; display_name = 'X' } + share_name = 'SEBackup_X$' + vrage_api = @{ port = 8080; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'world_path' + } + + It 'errors when share_name is missing (the required transfer key)' { + $cfg = @{ + instance = @{ name = 'X'; display_name = 'X' } + world_path = 'C:\W\Saves\X' + vrage_api = @{ port = 8080; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'share_name' + } + + It 'errors when [vrage_api].port is above the upper bound' { + $cfg = @{ + instance = @{ name = 'X'; display_name = 'X' } + world_path = 'C:\W\Saves\X' + staging_path = 'D:\stage\X' + share_name = 'SEBackup_X$' + vrage_api = @{ port = 99999; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'port' + } + + It 'errors when [vrage_api].port is below the lower bound (port = 0)' { + # Symmetric with the upper-bound case above (and with the Global port lower-bound test): + # the Instance validator rejects port < 1 just as it rejects port > 65535. + $cfg = @{ + instance = @{ name = 'X'; display_name = 'X' } + world_path = 'C:\W\Saves\X' + staging_path = 'D:\stage\X' + share_name = 'SEBackup_X$' + vrage_api = @{ port = 0; security_key = 'k' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'port' + } + + It 'errors when [backup].enabled is not a boolean' { + $cfg = @{ + instance = @{ name = 'X'; display_name = 'X' } + world_path = 'C:\W\Saves\X' + staging_path = 'D:\stage\X' + share_name = 'SEBackup_X$' + vrage_api = @{ port = 8080; security_key = 'k' } + backup = @{ enabled = 'yes' } + } + $result = Test-SEBConfig -Type Instance -Config $cfg + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'enabled' + } +} + +Describe 'Test-SEBConfig -Path file handling' { + + It 'errors cleanly (does not throw) when the file does not exist' { + $missing = Join-Path ([System.IO.Path]::GetTempPath()) ("nope_" + [guid]::NewGuid().ToString('n') + '.toml') + $result = Test-SEBConfig -Type Global -Path $missing + $result.IsValid | Should -BeFalse + ($result.Errors -join ' ') | Should -Match 'not found' + } +} diff --git a/Tests/ManifestManager/Compare-SEBManifest.Tests.ps1 b/Tests/ManifestManager/Compare-SEBManifest.Tests.ps1 new file mode 100644 index 0000000..8feeb32 --- /dev/null +++ b/Tests/ManifestManager/Compare-SEBManifest.Tests.ps1 @@ -0,0 +1,136 @@ +#Requires -Module Pester + +# Issue #19 -- unit coverage for the manifest diff that drives incremental backups. +# +# Compare-SEBManifest classifies every file in (current vs previous) into Added / Modified / +# Deleted / Unchanged by SHA256, and reports the counts the backup engine uses to decide what +# goes into the incremental archive. This is pure hashtable-in / object-out, so it is exercised +# with in-memory manifest hashtables -- no remote node, no mocks. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # New-FileEntry (the shared manifest 'files' entry factory) lives in one place so it cannot + # drift between this suite and New-SEBManifest.Tests.ps1. + . "$PSScriptRoot/_ManifestTestHelpers.ps1" + + # Wrap a files hashtable in a minimal v2 manifest shell. + function New-ManifestFromFiles { + param([hashtable]$Files) + @{ + version = 2 + type = 'full' + chain_id = 'chain-1' + chain_sequence = 0 + files = $Files + deleted_files = @() + } + } +} + +Describe 'Compare-SEBManifest' { + + It 'classifies unchanged / modified / added / deleted in one pass' { + $previous = New-ManifestFromFiles @{ + 'unchanged.cfg' = New-FileEntry -Size 10 -Sha256 'aaaa' + 'modified.sbs' = New-FileEntry -Size 20 -Sha256 'bbbb' + 'gone.tmp' = New-FileEntry -Size 30 -Sha256 'cccc' + } + $current = New-ManifestFromFiles @{ + 'unchanged.cfg' = New-FileEntry -Size 10 -Sha256 'aaaa' # same hash -> unchanged + 'modified.sbs' = New-FileEntry -Size 25 -Sha256 'dddd' # hash differs -> modified + 'new.vx2' = New-FileEntry -Size 40 -Sha256 'eeee' # only in current -> added + # 'gone.tmp' absent in current -> deleted + } + + $diff = Compare-SEBManifest -CurrentManifest $current -PreviousManifest $previous + + $diff.Unchanged | Should -Be @('unchanged.cfg') + $diff.Modified | Should -Be @('modified.sbs') + $diff.Added | Should -Be @('new.vx2') + $diff.Deleted | Should -Be @('gone.tmp') + } + + It 'reports a content change as Modified when only the hash differs' { + # Size/last_write are irrelevant to the diff -- classification is purely by SHA256. + $previous = New-ManifestFromFiles @{ 'world.sbs' = New-FileEntry -Size 100 -Sha256 'oldhash' -LastWrite '2026-01-01T00:00:00.0000000Z' } + $current = New-ManifestFromFiles @{ 'world.sbs' = New-FileEntry -Size 100 -Sha256 'newhash' -LastWrite '2026-01-01T00:00:00.0000000Z' } + + $diff = Compare-SEBManifest -CurrentManifest $current -PreviousManifest $previous + $diff.ModifiedCount | Should -Be 1 + $diff.UnchangedCount | Should -Be 0 + $diff.Modified | Should -Contain 'world.sbs' + } + + It 'treats identical manifests as fully unchanged with zero total changes' { + $files = @{ + 'a.cfg' = New-FileEntry -Size 1 -Sha256 '1111' + 'b.cfg' = New-FileEntry -Size 2 -Sha256 '2222' + } + $diff = Compare-SEBManifest -CurrentManifest (New-ManifestFromFiles $files) -PreviousManifest (New-ManifestFromFiles $files) + + $diff.UnchangedCount | Should -Be 2 + $diff.AddedCount | Should -Be 0 + $diff.ModifiedCount | Should -Be 0 + $diff.DeletedCount | Should -Be 0 + $diff.TotalChanges | Should -Be 0 -Because 'TotalChanges = added + modified + deleted only' + } + + It 'counts every current file as Added when the previous manifest is empty' { + $current = New-ManifestFromFiles @{ + 'a.cfg' = New-FileEntry -Size 1 -Sha256 '1111' + 'b.cfg' = New-FileEntry -Size 2 -Sha256 '2222' + } + $diff = Compare-SEBManifest -CurrentManifest $current -PreviousManifest (New-ManifestFromFiles @{}) + + $diff.AddedCount | Should -Be 2 + $diff.DeletedCount | Should -Be 0 + $diff.UnchangedCount | Should -Be 0 + $diff.TotalChanges | Should -Be 2 + } + + It 'counts every previous file as Deleted when the current manifest is empty' { + $previous = New-ManifestFromFiles @{ + 'a.cfg' = New-FileEntry -Size 1 -Sha256 '1111' + 'b.cfg' = New-FileEntry -Size 2 -Sha256 '2222' + } + $diff = Compare-SEBManifest -CurrentManifest (New-ManifestFromFiles @{}) -PreviousManifest $previous + + $diff.DeletedCount | Should -Be 2 + $diff.AddedCount | Should -Be 0 + $diff.UnchangedCount | Should -Be 0 + $diff.TotalChanges | Should -Be 2 + } + + It 'tolerates a manifest with no files key by treating it as empty' { + # Compare-SEBManifest defaults a missing 'files' key to @{}; a manifest without it must not throw. + $previous = @{ version = 2; type = 'full' } # no 'files' + $current = New-ManifestFromFiles @{ 'new.cfg' = New-FileEntry -Size 1 -Sha256 'ffff' } + + $diff = Compare-SEBManifest -CurrentManifest $current -PreviousManifest $previous + $diff.AddedCount | Should -Be 1 + $diff.DeletedCount | Should -Be 0 + } + + It 'returns count properties that agree with the array lengths' { + $previous = New-ManifestFromFiles @{ + 'keep.cfg' = New-FileEntry -Size 1 -Sha256 'same' + 'edit.sbs' = New-FileEntry -Size 2 -Sha256 'old' + 'drop.tmp' = New-FileEntry -Size 3 -Sha256 'bye' + } + $current = New-ManifestFromFiles @{ + 'keep.cfg' = New-FileEntry -Size 1 -Sha256 'same' + 'edit.sbs' = New-FileEntry -Size 2 -Sha256 'new' + 'add.vx2' = New-FileEntry -Size 4 -Sha256 'hi' + } + + $diff = Compare-SEBManifest -CurrentManifest $current -PreviousManifest $previous + + $diff.AddedCount | Should -Be $diff.Added.Count + $diff.ModifiedCount | Should -Be $diff.Modified.Count + $diff.DeletedCount | Should -Be $diff.Deleted.Count + $diff.UnchangedCount | Should -Be $diff.Unchanged.Count + $diff.TotalChanges | Should -Be ($diff.AddedCount + $diff.ModifiedCount + $diff.DeletedCount) + } +} diff --git a/Tests/ManifestManager/New-SEBManifest.Tests.ps1 b/Tests/ManifestManager/New-SEBManifest.Tests.ps1 new file mode 100644 index 0000000..454c0d9 --- /dev/null +++ b/Tests/ManifestManager/New-SEBManifest.Tests.ps1 @@ -0,0 +1,260 @@ +#Requires -Module Pester + +# Issue #19 -- unit coverage for the manifest-building logic in New-SEBManifest that runs on the +# C&C side (NOT the remote scan block, which is node-local and exercised by RemoteScriptBlock +# contract tests). The remote file-hash boundary is the ONLY thing mocked: Invoke-SEBRemoteCommand +# is stubbed to return a canned scan result (@{ Files; HashedCount; SkippedCount }), so no real node +# is needed. With that fixed, the deterministic C&C logic is asserted: +# * deleted-file detection (present in previous, absent in current); +# * incremental vs full chain metadata (type, chain_id continuity, chain_sequence increment, +# parent_manifest); +# * a full (no PreviousManifest) manifest gets a fresh chain_id, sequence 0, null parent. +# +# The mock returns the SAME shape the real remote block returns, so the C&C-side merge/metadata +# code under test runs exactly as in production. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # An uninitialized PSSession satisfies the [PSSession] cast on -Session without a live + # connection (the type has no public constructor). Invoke-SEBRemoteCommand is mocked, so it is + # never actually used to talk to a node. + $script:fakeSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject( + [System.Management.Automation.Runspaces.PSSession]) + + # New-FileEntry (the shared manifest 'files' entry factory) lives in one place so it cannot + # drift between this suite and Compare-SEBManifest.Tests.ps1. + . "$PSScriptRoot/_ManifestTestHelpers.ps1" +} + +Describe 'New-SEBManifest (remote scan mocked)' { + + Context 'full manifest (no previous)' { + BeforeAll { + Mock Invoke-SEBRemoteCommand -ModuleName ManifestManager { + @{ + Files = @{ + 'world.sbs' = @{ size = 100; sha256 = 'aaaa'; last_write = '2026-01-01T00:00:00.0000000Z' } + 'config.xml' = @{ size = 20; sha256 = 'bbbb'; last_write = '2026-01-01T00:00:00.0000000Z' } + } + HashedCount = 2 + SkippedCount = 0 + } + } + } + + It 'returns a v2 full manifest with a fresh chain and null parent' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -Session $script:fakeSession + + $m['version'] | Should -Be 2 + $m['type'] | Should -Be 'full' + $m['chain_sequence'] | Should -Be 0 + $m['parent_manifest'] | Should -BeNullOrEmpty + $m['chain_id'] | Should -Not -BeNullOrEmpty + # No previous -> nothing can be deleted. + @($m['deleted_files']).Count | Should -Be 0 + } + + It 'carries the scanned files through from the (mocked) remote result' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -Session $script:fakeSession + $m['files'].Keys | Should -Contain 'world.sbs' + $m['files'].Keys | Should -Contain 'config.xml' + $m['files']['world.sbs']['sha256'] | Should -Be 'aaaa' + } + + It 'generates a unique chain_id per full manifest' { + $a = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -Session $script:fakeSession + $b = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -Session $script:fakeSession + $a['chain_id'] | Should -Not -Be $b['chain_id'] + } + } + + Context 'incremental manifest (with a previous)' { + BeforeAll { + # Current scan: 'world.sbs' present (changed hash), 'new.vx2' added, 'old.tmp' GONE. + Mock Invoke-SEBRemoteCommand -ModuleName ManifestManager { + @{ + Files = @{ + 'world.sbs' = @{ size = 150; sha256 = 'newhash'; last_write = '2026-02-01T00:00:00.0000000Z' } + 'new.vx2' = @{ size = 500; sha256 = 'addhash'; last_write = '2026-02-01T00:00:00.0000000Z' } + } + HashedCount = 2 + SkippedCount = 0 + } + } + + $script:previous = @{ + version = 2 + type = 'full' + chain_id = 'CHAIN-AAA' + chain_sequence = 3 + parent_manifest = $null + _source_filename = 'FULL_20260101_020000.json' + files = @{ + 'world.sbs' = New-FileEntry -Size 100 -Sha256 'oldhash' + 'old.tmp' = New-FileEntry -Size 10 -Sha256 'tmphash' + } + } + } + + It 'marks it incremental and continues the previous chain' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $script:previous -Session $script:fakeSession + $m['type'] | Should -Be 'incremental' + $m['chain_id'] | Should -Be 'CHAIN-AAA' -Because 'an incremental stays in its parent chain' + } + + It 'increments chain_sequence by one over the previous' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $script:previous -Session $script:fakeSession + $m['chain_sequence'] | Should -Be 4 -Because 'previous was sequence 3' + } + + It 'sets parent_manifest to the previous manifest filename' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $script:previous -Session $script:fakeSession + $m['parent_manifest'] | Should -Be 'FULL_20260101_020000.json' + } + + It 'detects a deleted file (in previous, absent from the current scan)' { + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $script:previous -Session $script:fakeSession + $m['deleted_files'] | Should -Contain 'old.tmp' + # Files still present (even if changed/added) are NOT in deleted_files. + $m['deleted_files'] | Should -Not -Contain 'world.sbs' + $m['deleted_files'] | Should -Not -Contain 'new.vx2' + } + + It 'warns and nulls parent_manifest when the previous lacks _source_filename' { + $prevNoFilename = @{ + version = 2; type = 'full'; chain_id = 'CHAIN-BBB'; chain_sequence = 0 + files = @{ 'a.cfg' = New-FileEntry -Size 1 -Sha256 'x' } + } + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $prevNoFilename -Session $script:fakeSession -WarningAction SilentlyContinue + $m['parent_manifest'] | Should -BeNullOrEmpty + $m['chain_id'] | Should -Be 'CHAIN-BBB' + $m['chain_sequence'] | Should -Be 1 + } + } + + Context 'incremental with no deletions' { + BeforeAll { + Mock Invoke-SEBRemoteCommand -ModuleName ManifestManager { + @{ + Files = @{ + 'keep.cfg' = @{ size = 1; sha256 = 'same'; last_write = '2026-01-01T00:00:00.0000000Z' } + } + HashedCount = 0 + SkippedCount = 1 + } + } + } + + It 'produces an empty deleted_files list when every previous file still exists' { + $previous = @{ + version = 2; type = 'full'; chain_id = 'C'; chain_sequence = 0; _source_filename = 'p.json' + files = @{ 'keep.cfg' = New-FileEntry -Size 1 -Sha256 'same' } + } + $m = New-SEBManifest -SourcePath 'E:\Shadow\Inst' -PreviousManifest $previous -Session $script:fakeSession + @($m['deleted_files']).Count | Should -Be 0 + } + } +} + +# The headline incremental optimization -- reuse a previous SHA256 instead of recomputing it when a +# file's size AND last_write are both unchanged -- runs INSIDE the remote scan script block, on the +# node. The "remote scan mocked" tests above mock Invoke-SEBRemoteCommand wholesale and so never +# exercise that decision. The contract test (Tests/Contract/RemoteScriptBlock.Tests.ps1) forbids the +# remote block from calling any *-SEB* helper (none is imported on the node) or capturing C&C vars, +# so the decision cannot be extracted into an SEB helper the block calls -- doing so would break +# production AND fail that contract test. Instead we pin the REAL decision by lifting the actual +# script block out of New-SEBManifest.ps1 via the AST and invoking its body against a temp file set. +# This runs the production code, not a copy: flip the size/last_write '-eq' guard in the source and +# these assertions go red. +Describe 'New-SEBManifest remote scan -- hash-reuse optimization (real script block over temp files)' { + + BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + $src = Join-Path $repoRoot 'Modules/ManifestManager/Public/New-SEBManifest.ps1' + + # Lift the literal script block passed to Invoke-SEBRemoteCommand straight out of the source. + $tokens = $null; $parseErrors = $null + $fileAst = [System.Management.Automation.Language.Parser]::ParseFile($src, [ref]$tokens, [ref]$parseErrors) + $invokeCall = $fileAst.FindAll( + { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Invoke-SEBRemoteCommand' }, + $true) | Select-Object -First 1 + $sbExpr = $invokeCall.CommandElements | + Where-Object { $_ -is [System.Management.Automation.Language.ScriptBlockExpressionAst] } | + Select-Object -First 1 + # GetScriptBlock() yields a real, invokable [scriptblock] from the AST node. + $script:scanBlock = $sbExpr.ScriptBlock.GetScriptBlock() + + # A scan root with three files exercising each branch. + $script:scanRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sebhashreuse_" + [guid]::NewGuid().ToString('n')) + New-Item -ItemType Directory -Path $script:scanRoot -Force | Out-Null + + $script:reuseFile = Join-Path $script:scanRoot 'unchanged.sbs' # size+last_write match -> reuse + $script:rehashFile = Join-Path $script:scanRoot 'touched.cfg' # same size, changed mtime -> re-hash + $script:newFile = Join-Path $script:scanRoot 'brandnew.vx2' # absent in previous -> hash + Set-Content -Path $script:reuseFile -Value 'unchanged-content' -NoNewline + Set-Content -Path $script:rehashFile -Value 'samesize-content!' -NoNewline # 17 chars, matches prev size below + Set-Content -Path $script:newFile -Value 'new-content-here' -NoNewline + + $reuseInfo = [System.IO.FileInfo]::new($script:reuseFile) + $rehashInfo = [System.IO.FileInfo]::new($script:rehashFile) + + # Previous manifest 'files' map fed to the block. A sentinel sha256 on each previous entry + # lets us tell "reused the previous hash" from "freshly computed a real one" by inspection. + $script:prevFiles = @{ + # Exact size + last_write match -> the block MUST reuse this sha256 and skip Get-FileHash. + 'unchanged.sbs' = @{ + size = $reuseInfo.Length + sha256 = 'sentinel_reused_hash' + last_write = $reuseInfo.LastWriteTimeUtc.ToString('o') + } + # SAME size but a deliberately wrong last_write -> the block MUST re-hash (sentinel discarded). + 'touched.cfg' = @{ + size = $rehashInfo.Length + sha256 = 'sentinel_stale_hash' + last_write = '2000-01-01T00:00:00.0000000Z' + } + # 'brandnew.vx2' is intentionally absent from the previous map. + } + + $script:scan = & $script:scanBlock -ScanPath $script:scanRoot -Excludes @() -PrevFiles $script:prevFiles + } + + AfterAll { + if ($script:scanRoot -and (Test-Path $script:scanRoot)) { + Remove-Item -Recurse -Force $script:scanRoot -ErrorAction SilentlyContinue + } + } + + It 'reuses the previous sha256 (no re-hash) when size AND last_write are unchanged' { + $script:scan.Files['unchanged.sbs'].sha256 | Should -Be 'sentinel_reused_hash' ` + -Because 'an unchanged file must carry the cached hash through verbatim' + } + + It 'counts a reused file as Skipped, not Hashed' { + # exactly one file (unchanged.sbs) qualifies for reuse + $script:scan.SkippedCount | Should -Be 1 + } + + It 're-hashes a file whose size matches but whose last_write changed' { + $entry = $script:scan.Files['touched.cfg'] + $entry.sha256 | Should -Not -Be 'sentinel_stale_hash' -Because 'a changed mtime must invalidate the cached hash' + $entry.sha256 | Should -Match '^[0-9a-f]{64}$' -Because 'a freshly computed SHA256 is 64 lowercase hex chars' + } + + It 'always hashes a file that was absent from the previous manifest' { + $script:scan.Files.Keys | Should -Contain 'brandnew.vx2' + $script:scan.Files['brandnew.vx2'].sha256 | Should -Match '^[0-9a-f]{64}$' + } + + It 'counts both the re-hashed and the new file as Hashed' { + # touched.cfg (re-hash) + brandnew.vx2 (new) = 2 hashed; unchanged.sbs is the only skip. + $script:scan.HashedCount | Should -Be 2 + } + + It 'records every scanned file with the real on-disk size' { + $script:scan.Files['unchanged.sbs'].size | Should -Be ([System.IO.FileInfo]::new($script:reuseFile).Length) + $script:scan.Files['touched.cfg'].size | Should -Be ([System.IO.FileInfo]::new($script:rehashFile).Length) + } +} diff --git a/Tests/ManifestManager/_ManifestTestHelpers.ps1 b/Tests/ManifestManager/_ManifestTestHelpers.ps1 new file mode 100644 index 0000000..770533e --- /dev/null +++ b/Tests/ManifestManager/_ManifestTestHelpers.ps1 @@ -0,0 +1,20 @@ +# Shared test helpers for the ManifestManager Pester suites. +# +# Dot-sourced from New-SEBManifest.Tests.ps1 and Compare-SEBManifest.Tests.ps1 so the manifest +# "files" entry factory lives in ONE place and cannot drift between the two suites (it was +# previously copy-pasted verbatim into both). Defines functions in the dot-sourcing scope; call +# from a BeforeAll so they are visible to the Describe/It blocks. + +function New-FileEntry { + <# + Build a single manifest 'files' entry exactly the way New-SEBManifest records one: + a hashtable with size / sha256 / last_write keys. last_write defaults to a fixed ISO-8601 + UTC instant so size/hash are the only things a test needs to vary. + #> + param( + [long]$Size, + [string]$Sha256, + [string]$LastWrite = '2026-01-01T00:00:00.0000000Z' + ) + @{ size = $Size; sha256 = $Sha256; last_write = $LastWrite } +} diff --git a/Tests/MetricsCollector/Get-SEBDiskSpace.Tests.ps1 b/Tests/MetricsCollector/Get-SEBDiskSpace.Tests.ps1 new file mode 100644 index 0000000..aef584d --- /dev/null +++ b/Tests/MetricsCollector/Get-SEBDiskSpace.Tests.ps1 @@ -0,0 +1,128 @@ +#Requires -Module Pester + +# Issue #19 -- unit coverage for Get-SEBDiskSpace's free-space accounting and the -1 sentinel that +# flags an unconfigured/unreachable tier. +# +# The function reports free/total bytes for three storage tiers (node staging, C&C backup root, +# NAS) and uses -1 to mean "not configured / not available". The ONLY external boundary mocked is +# the remote drive query (Invoke-SEBRemoteCommand -ModuleName MetricsCollector), stubbed to return +# canned drive data so no live node is required. The C&C backup-root tier is read from a real local +# drive (a temp path), so the free/total math is checked against a freshly-read DriveInfo for that +# same drive -- a true in/out assertion with no node. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # An uninitialized PSSession satisfies the [PSSession] cast on -Session; the remote call it + # would drive is mocked, so it is never used to reach a node. + $script:fakeSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject( + [System.Management.Automation.Runspaces.PSSession]) + + # A real local path whose drive DriveInfo can read directly (the C&C backup-root tier is local). + $script:localPath = [System.IO.Path]::GetTempPath() + $script:localRoot = [System.IO.Path]::GetPathRoot($script:localPath) +} + +Describe 'Get-SEBDiskSpace' { + + Context 'C&C backup root (local drive, no node)' { + It 'returns the actual free/total bytes for the backup-root drive' { + $expected = [System.IO.DriveInfo]::new($script:localRoot) + $r = Get-SEBDiskSpace -BackupRoot $script:localPath + + # Free space is volatile between the two reads; assert it is a sane positive figure and + # within a wide tolerance of the reference read rather than exact equality. + $r.CCBackupFreeBytes | Should -BeGreaterThan 0 + $r.CCBackupTotalBytes | Should -Be $expected.TotalSize -Because 'total size is stable for a fixed drive' + [math]::Abs($r.CCBackupFreeBytes - $expected.AvailableFreeSpace) | + Should -BeLessThan 1GB -Because 'two reads moments apart should be close' + } + + It 'reports free <= total for the backup-root drive (basic accounting invariant)' { + $r = Get-SEBDiskSpace -BackupRoot $script:localPath + $r.CCBackupFreeBytes | Should -BeLessOrEqual $r.CCBackupTotalBytes + } + } + + Context 'unconfigured tiers use the -1 sentinel' { + It 'reports NAS as -1 when no NASPath is supplied' { + $r = Get-SEBDiskSpace -BackupRoot $script:localPath + $r.NASFreeBytes | Should -Be -1 + $r.NASTotalBytes | Should -Be -1 + } + + It 'reports NAS as -1 for a whitespace-only NASPath' { + $r = Get-SEBDiskSpace -BackupRoot $script:localPath -NASPath ' ' + $r.NASFreeBytes | Should -Be -1 + $r.NASTotalBytes | Should -Be -1 + } + + It 'reports node staging as -1 when no Session is supplied' { + $r = Get-SEBDiskSpace -BackupRoot $script:localPath + $r.NodeStagingFreeBytes | Should -Be -1 + $r.NodeStagingTotalBytes | Should -Be -1 + } + } + + Context 'node staging via a (mocked) remote session' { + It 'fills node staging from the canned remote drive query' { + Mock Invoke-SEBRemoteCommand -ModuleName MetricsCollector { + @{ FreeBytes = 50GB; TotalBytes = 200GB } + } + + $r = Get-SEBDiskSpace -BackupRoot $script:localPath -Session $script:fakeSession + + $r.NodeStagingFreeBytes | Should -Be 50GB + $r.NodeStagingTotalBytes | Should -Be 200GB + Should -Invoke Invoke-SEBRemoteCommand -ModuleName MetricsCollector -Times 1 -Exactly + } + + It 'leaves node staging at -1 when the remote query throws' { + Mock Invoke-SEBRemoteCommand -ModuleName MetricsCollector { throw 'node unreachable' } + + $r = Get-SEBDiskSpace -BackupRoot $script:localPath -Session $script:fakeSession -WarningAction SilentlyContinue + + $r.NodeStagingFreeBytes | Should -Be -1 + $r.NodeStagingTotalBytes | Should -Be -1 + # The local C&C tier must still be populated -- a node failure does not poison it. + $r.CCBackupTotalBytes | Should -BeGreaterThan 0 + } + + It 'coerces the remote drive figures to [long]' { + # The remote query returns NATIVE [int] figures (a fresh node runspace types small + # integers as Int32). The source casts them to [long]; this asserts that cast is + # load-bearing -- drop the [long] in Get-SEBDiskSpace.ps1 and the -BeOfType [long] + # below goes red, because the values would stay [int]. (A literal like 12345678901, + # which PowerShell already types as Int64, could NOT catch a missing cast.) + Mock Invoke-SEBRemoteCommand -ModuleName MetricsCollector { + @{ FreeBytes = [int]100; TotalBytes = [int]200 } + } + $r = Get-SEBDiskSpace -BackupRoot $script:localPath -Session $script:fakeSession + $r.NodeStagingFreeBytes | Should -BeOfType [long] + $r.NodeStagingTotalBytes | Should -BeOfType [long] + $r.NodeStagingFreeBytes | Should -Be 100 + $r.NodeStagingTotalBytes | Should -Be 200 + } + } + + Context 'NAS on a local/mapped (non-UNC) path' { + It 'reads NAS free/total from a local-style path via DriveInfo' { + # A drive-rooted (non-UNC) NASPath goes through the same local DriveInfo helper as the + # backup root, so pointing it at the temp drive yields that drive's real figures. + $r = Get-SEBDiskSpace -BackupRoot $script:localPath -NASPath $script:localPath + $r.NASFreeBytes | Should -BeGreaterThan 0 + $r.NASTotalBytes | Should -Be ([System.IO.DriveInfo]::new($script:localRoot).TotalSize) + } + } + + Context 'output shape' { + It 'always returns all six tier properties typed as [long]' { + $r = Get-SEBDiskSpace -BackupRoot $script:localPath + foreach ($p in 'NodeStagingFreeBytes','NodeStagingTotalBytes','CCBackupFreeBytes','CCBackupTotalBytes','NASFreeBytes','NASTotalBytes') { + $r.PSObject.Properties.Name | Should -Contain $p + $r.$p | Should -BeOfType [long] + } + } + } +} diff --git a/Tests/MetricsCollector/Get-SEBHealthSummary.Tests.ps1 b/Tests/MetricsCollector/Get-SEBHealthSummary.Tests.ps1 new file mode 100644 index 0000000..04ef808 --- /dev/null +++ b/Tests/MetricsCollector/Get-SEBHealthSummary.Tests.ps1 @@ -0,0 +1,230 @@ +#Requires -Module Pester + +# Issue #19 -- direct unit coverage for Get-SEBHealthSummary (the Public health-verdict aggregation) +# and, while we are at the boundary, the Add-SEBMetric -> Get-SEBMetrics round trip it reads through. +# +# Get-SEBHealthSummary derives, from an instance's metric history: last-successful-backup age, +# average duration / archive size over the last 10, a 7-day success rate, the current incremental +# chain length, and duration/size trends. The ONLY external boundary is the on-disk JSON metrics +# file under {BackupRoot}\Data\metrics\; tests point BackupRoot at a fresh temp directory and write +# the history directly (so timestamps relative to "now" are controlled), then assert the deterministic +# aggregation. Expected figures are precomputed from the documented rules. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # Write a metrics JSON file for $InstanceName under a (temp) BackupRoot, bypassing Add-SEBMetric + # so each entry's timestamp can be set explicitly relative to "now". + function New-MetricsFile { + param( + [string]$BackupRoot, + [string]$InstanceName, + [object[]]$History + ) + $dir = Join-Path -Path $BackupRoot -ChildPath 'Data' | Join-Path -ChildPath 'metrics' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $file = Join-Path $dir "${InstanceName}_metrics.json" + [pscustomobject]@{ instance = $InstanceName; history = $History } | + ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8 + return $file + } + + function New-TempRoot { + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("sebmetrics_" + [guid]::NewGuid().ToString('n')) + New-Item -ItemType Directory -Path $root -Force | Out-Null + return $root + } +} + +Describe 'Get-SEBHealthSummary' { + + Context 'no metrics yet' { + It 'returns a safe default summary when the instance has no metrics file' { + $root = New-TempRoot + try { + $s = Get-SEBHealthSummary -InstanceName 'NeverRan' -BackupRoot $root + $s.InstanceName | Should -Be 'NeverRan' + $s.LastSuccessfulBackupAge | Should -BeNullOrEmpty + $s.LastSuccessfulBackupAgeFormatted | Should -Be 'Never' + $s.SuccessRate | Should -Be 0 + $s.CurrentChainLength | Should -Be 0 + $s.DurationTrend | Should -Be 'stable' + $s.SizeTrend | Should -Be 'stable' + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + } + + Context 'a populated history' { + BeforeAll { + $script:root = New-TempRoot + $now = [datetime]::UtcNow + # Oldest -> newest. The -10d Full is outside the 7-day success window. The trailing two + # Incrementals after the -3d Full define a chain length of 2. Durations and sizes both + # rise monotonically -> growing trends. + $history = @( + @{ timestamp = $now.AddDays(-10).ToString('o'); type = 'Full'; duration_seconds = 100; archive_size_bytes = 1000; success = $true } + @{ timestamp = $now.AddDays(-3).ToString('o'); type = 'Full'; duration_seconds = 110; archive_size_bytes = 1100; success = $true } + @{ timestamp = $now.AddDays(-2).ToString('o'); type = 'Incremental'; duration_seconds = 120; archive_size_bytes = 1200; success = $false } + @{ timestamp = $now.AddDays(-1).ToString('o'); type = 'Incremental'; duration_seconds = 130; archive_size_bytes = 1300; success = $true } + ) + New-MetricsFile -BackupRoot $script:root -InstanceName 'Inst' -History $history | Out-Null + $script:summary = Get-SEBHealthSummary -InstanceName 'Inst' -BackupRoot $script:root + } + + AfterAll { + if ($script:root) { Remove-Item -Recurse -Force $script:root -ErrorAction SilentlyContinue } + } + + It 'computes the 7-day success rate over only the in-window entries' { + # In-window entries: -3d(ok), -2d(fail), -1d(ok) => 2/3 => 66.7. The -10d success is excluded. + $script:summary.SuccessRate | Should -Be 66.7 + } + + It 'counts the trailing incrementals as the current chain length' { + $script:summary.CurrentChainLength | Should -Be 2 + } + + It 'averages the recent backup durations' { + # last<=10 durations: 100,110,120,130 -> mean 115. + $script:summary.AverageBackupDuration | Should -Be 115 + } + + It 'averages the recent archive sizes' { + $script:summary.AverageArchiveSize | Should -Be 1150 + } + + It 'reports growing trends for the rising duration and size series' { + $script:summary.DurationTrend | Should -Be 'growing' + $script:summary.SizeTrend | Should -Be 'growing' + } + + It 'reports a populated, day-scale age for the last successful backup' { + # The newest success is ~1 day old. Assert the age is positive and on a day scale, plus + # that the human-readable form is well shaped. (An EXACT hour count is deliberately avoided: + # Get-SEBHealthSummary parses JSON-deserialized timestamps, which carries a host-timezone + # dependency; pinning the magnitude here would make the test pass/fail by machine locale. + # The timezone-invariant "skips a later failure" contract is asserted separately below.) + $script:summary.LastSuccessfulBackupAge | Should -Not -BeNullOrEmpty + $script:summary.LastSuccessfulBackupAge.TotalHours | Should -BeGreaterThan 0 + $script:summary.LastSuccessfulBackupAge.TotalDays | Should -BeLessThan 3 + $script:summary.LastSuccessfulBackupAgeFormatted | Should -Match '^\d+[dhm]' + } + } + + Context 'a trailing failure after a success' { + It 'rolls the failure into the success rate yet still anchors the age on the success' { + $root = New-TempRoot + try { + $now = [datetime]::UtcNow + $history = @( + @{ timestamp = $now.AddHours(-3).ToString('o'); type = 'Full'; duration_seconds = 50; archive_size_bytes = 500; success = $true } + @{ timestamp = $now.AddHours(-1).ToString('o'); type = 'Incremental'; duration_seconds = 60; archive_size_bytes = 600; success = $false } + ) + New-MetricsFile -BackupRoot $root -InstanceName 'Mixed' -History $history | Out-Null + + $s = Get-SEBHealthSummary -InstanceName 'Mixed' -BackupRoot $root + # 1 of 2 in-window succeeded -> 50% (timezone-independent: both entries sit inside the + # 7-day window regardless of host locale). + $s.SuccessRate | Should -Be 50 + # A successful entry exists, so the last-successful age is populated (not the 'Never' + # default) -- i.e. the function anchored on the success and did not blank out because + # the NEWEST entry was a failure. + $s.LastSuccessfulBackupAge | Should -Not -BeNullOrEmpty + $s.LastSuccessfulBackupAgeFormatted | Should -Not -Be 'Never' + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + } + + Context 'all backups failed' { + It 'leaves the last-successful age at the Never default but still reports a 0% rate' { + $root = New-TempRoot + try { + $now = [datetime]::UtcNow + $history = @( + @{ timestamp = $now.AddHours(-2).ToString('o'); type = 'Full'; duration_seconds = 50; archive_size_bytes = 500; success = $false } + @{ timestamp = $now.AddHours(-1).ToString('o'); type = 'Incremental'; duration_seconds = 60; archive_size_bytes = 600; success = $false } + ) + New-MetricsFile -BackupRoot $root -InstanceName 'AllFail' -History $history | Out-Null + + $s = Get-SEBHealthSummary -InstanceName 'AllFail' -BackupRoot $root + $s.SuccessRate | Should -Be 0 + $s.LastSuccessfulBackupAge | Should -BeNullOrEmpty + $s.LastSuccessfulBackupAgeFormatted | Should -Be 'Never' + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + } +} + +Describe 'Add-SEBMetric / Get-SEBMetrics round trip' { + + It 'persists an appended metric and reads it back' { + $root = New-TempRoot + try { + $metric = @{ + type = 'Full' + duration_seconds = 135.5 + archive_size_bytes = 1932735283 + files_changed = 12 + transfer_seconds = 45.2 + compression_seconds = 30.1 + vss_seconds = 8.5 + manifest_seconds = 2.1 + success = $true + } + Add-SEBMetric -InstanceName 'RoundTrip' -MetricData $metric -BackupRoot $root + + $read = Get-SEBMetrics -InstanceName 'RoundTrip' -BackupRoot $root + @($read.history).Count | Should -Be 1 + $read.history[0].type | Should -Be 'Full' + $read.history[0].archive_size_bytes | Should -Be 1932735283 + $read.history[0].success | Should -BeTrue + $read.history[0].timestamp | Should -Not -BeNullOrEmpty + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + + It 'appends in order across multiple calls' { + $root = New-TempRoot + try { + Add-SEBMetric -InstanceName 'Seq' -BackupRoot $root -MetricData @{ type = 'Full'; success = $true } + Add-SEBMetric -InstanceName 'Seq' -BackupRoot $root -MetricData @{ type = 'Incremental'; success = $true } + Add-SEBMetric -InstanceName 'Seq' -BackupRoot $root -MetricData @{ type = 'Incremental'; success = $false } + + $read = Get-SEBMetrics -InstanceName 'Seq' -BackupRoot $root + @($read.history).Count | Should -Be 3 + $read.history[0].type | Should -Be 'Full' + $read.history[2].type | Should -Be 'Incremental' + $read.history[2].success | Should -BeFalse + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + + It 'honours -Last by returning only the most recent N entries (newest kept)' { + $root = New-TempRoot + try { + 1..5 | ForEach-Object { + Add-SEBMetric -InstanceName 'Trim' -BackupRoot $root -MetricData @{ type = 'Full'; files_changed = $_; success = $true } + } + $read = Get-SEBMetrics -InstanceName 'Trim' -BackupRoot $root -Last 2 + @($read.history).Count | Should -Be 2 + # The two newest (files_changed 4 then 5) are what survive the trim. + $read.history[0].files_changed | Should -Be 4 + $read.history[1].files_changed | Should -Be 5 + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } + + It 'returns an empty history for an unknown instance without throwing' { + $root = New-TempRoot + try { + $read = Get-SEBMetrics -InstanceName 'DoesNotExist' -BackupRoot $root + $read.instance | Should -Be 'DoesNotExist' + @($read.history).Count | Should -Be 0 + } + finally { Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue } + } +} diff --git a/Tests/MetricsCollector/Get-SEBTrendIndicator.Tests.ps1 b/Tests/MetricsCollector/Get-SEBTrendIndicator.Tests.ps1 new file mode 100644 index 0000000..c74cf78 --- /dev/null +++ b/Tests/MetricsCollector/Get-SEBTrendIndicator.Tests.ps1 @@ -0,0 +1,105 @@ +#Requires -Module Pester + +# Issue #19 -- direct unit coverage for Get-SEBTrendIndicator, the Private trend-direction helper +# that drives the DurationTrend / SizeTrend fields of the health summary. It fits a simple linear +# regression over a chronologically ordered numeric series and classifies the slope, relative to a +# 5%-of-mean threshold, as 'growing' / 'shrinking' / 'stable'. +# +# This is pure math (numbers in, string out) with no I/O, so it is exercised with in-memory series +# and asserted exactly. Because the function is NOT exported, every test runs InModuleScope so the +# private function is reachable. Expected outcomes were precomputed from the regression/threshold +# definition in the source, so they fail if that math is changed. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null +} + +Describe 'Get-SEBTrendIndicator' { + + Context 'clear monotonic trends' { + It 'classifies a steadily rising series as growing' { + InModuleScope MetricsCollector { + Get-SEBTrendIndicator -Values @(100, 110, 120, 130, 140) | Should -Be 'growing' + } + } + + It 'classifies a steadily falling series as shrinking' { + InModuleScope MetricsCollector { + Get-SEBTrendIndicator -Values @(200, 180, 160, 140, 120) | Should -Be 'shrinking' + } + } + + It 'classifies a flat / noisy series as stable' { + InModuleScope MetricsCollector { + # Tiny fluctuations around 500: slope is well under 5% of the mean. + Get-SEBTrendIndicator -Values @(500, 498, 502, 501, 499) | Should -Be 'stable' + } + } + } + + Context 'too few points to judge a trend' { + It 'returns stable for a two-point series even with a large jump' { + InModuleScope MetricsCollector { + # Fewer than 3 points -> no meaningful regression -> stable, regardless of the gap. + Get-SEBTrendIndicator -Values @(1, 1000) | Should -Be 'stable' + } + } + + It 'returns stable for a single point' { + InModuleScope MetricsCollector { + Get-SEBTrendIndicator -Values @(42) | Should -Be 'stable' + } + } + } + + Context 'threshold behaviour (5% of the mean per data point)' { + It 'calls a slope just OVER 5% of the mean growing' { + InModuleScope MetricsCollector { + # slope 5.10 vs threshold 5.00 (mean 100.10) -> just crosses into growing. + Get-SEBTrendIndicator -Values @(90, 95, 100, 105, 110.5) | Should -Be 'growing' + } + } + + It 'calls a slope under 5% of the mean stable' { + InModuleScope MetricsCollector { + # slope 1.00 vs threshold 5.00 (mean 100) -> stays stable. + Get-SEBTrendIndicator -Values @(98, 99, 100, 101, 102) | Should -Be 'stable' + } + } + } + + Context 'degenerate means' { + It 'returns stable when every value is zero (mean 0, slope 0)' { + InModuleScope MetricsCollector { + Get-SEBTrendIndicator -Values @(0, 0, 0, 0) | Should -Be 'stable' + } + } + + It 'returns growing for a rising series whose mean is exactly zero' { + InModuleScope MetricsCollector { + # mean = 0 triggers the sign-of-slope branch; this series rises through zero. + Get-SEBTrendIndicator -Values @(-10, 0, 10) | Should -Be 'growing' + } + } + + It 'returns shrinking for a falling series whose mean is exactly zero' { + InModuleScope MetricsCollector { + Get-SEBTrendIndicator -Values @(10, 0, -10) | Should -Be 'shrinking' + } + } + } + + It 'always returns one of the three documented indicator strings' { + InModuleScope MetricsCollector { + foreach ($series in @( + , @(1, 2, 3, 4, 5) + , @(5, 4, 3, 2, 1) + , @(7, 7, 7) + , @(1, 1000) + )) { + Get-SEBTrendIndicator -Values $series | Should -BeIn @('growing', 'shrinking', 'stable') + } + } + } +} diff --git a/Tests/VRageAPI/Invoke-SEBVRageRequest.Signing.Tests.ps1 b/Tests/VRageAPI/Invoke-SEBVRageRequest.Signing.Tests.ps1 new file mode 100644 index 0000000..ff90796 --- /dev/null +++ b/Tests/VRageAPI/Invoke-SEBVRageRequest.Signing.Tests.ps1 @@ -0,0 +1,184 @@ +#Requires -Module Pester + +# Issue #19 -- unit coverage for the request-signing / auth-header ASSEMBLY in Invoke-SEBVRageRequest. +# New-SEBVRageAuthHeaders.Tests.ps1 already pins the HMAC math (Base64-decoded key, canonical +# message, pinned known-answer signature, URI binding). This file pins how the CALLER wires that +# signature into an HTTP request, with the network boundary (Invoke-RestMethod) mocked so no live +# Torch server is touched: +# * the endpoint is normalized to the canonical '/vrageremote/v1/...' path WITHOUT double-prefixing; +# * the signature is bound to that exact path (the Authorization the server would verify is computed +# over the SAME path the request is sent to); +# * the Authorization header is assembled as 'nonce:base64(signature)', and a Date header is sent; +# * a non-Base64 key fails gracefully (returns $null) instead of throwing. +# +# Verifying the field ORDER against a REAL Torch server is issue #29 (the Torch harness) -- NOT done +# here. The one test that would need a live server is tagged 'Integration' and skipped. + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null + + # A real, valid Base64 key so New-SEBVRageAuthHeaders (called internally) does not bail. + $script:key = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef')) + + # Independent reference signer mirroring the documented canonical scheme, so the test fails if + # the request path the signature is bound to ever diverges from the path actually requested. + function Get-ExpectedSignature { + param([string]$Key, [string]$Uri, [string]$Nonce, [string]$Date) + $keyBytes = [Convert]::FromBase64String($Key) + $message = "$Uri`r`n$Nonce`r`n$Date`r`n" + $hmac = [System.Security.Cryptography.HMACSHA1]::new($keyBytes) + try { $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($message)) } + finally { $hmac.Dispose() } + [Convert]::ToBase64String($hash) + } +} + +Describe 'Invoke-SEBVRageRequest auth-header assembly (HTTP mocked)' { + + Context 'endpoint normalization -> signed request path' { + BeforeAll { + # Capture the URL and headers handed to Invoke-RestMethod without making a real call. + Mock Invoke-RestMethod -ModuleName VRageAPI { + $script:captured = @{ Uri = $Uri; Headers = $Headers; Method = $Method } + return @{ ok = $true } + } + } + + It "prefixes a bare endpoint to '/vrageremote/v1/'" { + $null = Invoke-SEBVRageRequest -Hostname 'localhost' -Port 8080 -SecurityKey $script:key -Endpoint 'server/ping' + $script:captured.Uri | Should -Be 'http://localhost:8080/vrageremote/v1/server/ping' + } + + It 'does not double-prefix an already-prefixed endpoint' { + $null = Invoke-SEBVRageRequest -Hostname 'localhost' -Port 8080 -SecurityKey $script:key -Endpoint '/vrageremote/v1/session/save' + $script:captured.Uri | Should -Be 'http://localhost:8080/vrageremote/v1/session/save' + } + + It 'tolerates a leading slash on a bare endpoint' { + $null = Invoke-SEBVRageRequest -Hostname '10.0.0.5' -Port 9000 -SecurityKey $script:key -Endpoint '/server' + $script:captured.Uri | Should -Be 'http://10.0.0.5:9000/vrageremote/v1/server' + } + } + + Context 'Authorization header is bound to the requested path' { + It 'computes the signature over the SAME path the request targets' { + # Drive a deterministic nonce/date INTO the signer by mocking it; capture what the request + # actually sends, then independently re-derive the signature over the URL's path. + $fixedNonce = 'reqnonce42' + $fixedDate = 'Mon, 01 Jan 2024 00:00:00 GMT' + + Mock New-SEBVRageAuthHeaders -ModuleName VRageAPI { + # Honour the resource-bound contract: sign exactly the -RequestUri the caller passes. + $sig = (Get-ExpectedSignature -Key $script:key -Uri $RequestUri -Nonce $fixedNonce -Date $fixedDate) + @{ Date = $fixedDate; Authorization = "${fixedNonce}:${sig}"; Nonce = $fixedNonce } + } + Mock Invoke-RestMethod -ModuleName VRageAPI { + $script:captured = @{ Uri = $Uri; Headers = $Headers } + return @{ ok = $true } + } + + $null = Invoke-SEBVRageRequest -Hostname 'host1' -Port 8080 -SecurityKey $script:key -Endpoint 'session/save' -Method PATCH + + # The path the signature was bound to (asserted via the independent re-derivation). + $expectedPath = '/vrageremote/v1/session/save' + $expectedSig = Get-ExpectedSignature -Key $script:key -Uri $expectedPath -Nonce $fixedNonce -Date $fixedDate + + $script:captured.Uri | Should -Be "http://host1:8080$expectedPath" + $script:captured.Headers['Authorization'] | Should -Be "${fixedNonce}:${expectedSig}" + $script:captured.Headers['Date'] | Should -Be $fixedDate + } + + It "assembles the Authorization header as 'nonce:base64(signature)' and sends a Date header" { + Mock Invoke-RestMethod -ModuleName VRageAPI { + $script:captured = @{ Headers = $Headers } + return @{ ok = $true } + } + $null = Invoke-SEBVRageRequest -Hostname 'localhost' -Port 8080 -SecurityKey $script:key -Endpoint 'server' + + $script:captured.Headers.ContainsKey('Authorization') | Should -BeTrue + $script:captured.Headers.ContainsKey('Date') | Should -BeTrue + # nonce (hex):base64sig -- exactly two colon-separated parts, the second valid Base64. + $parts = $script:captured.Headers['Authorization'] -split ':', 2 + $parts.Count | Should -Be 2 + $parts[0] | Should -Not -BeNullOrEmpty + { [Convert]::FromBase64String($parts[1]) } | Should -Not -Throw + } + } + + Context 'request wiring' { + It 'passes the chosen HTTP method through to Invoke-RestMethod' { + Mock Invoke-RestMethod -ModuleName VRageAPI { + $script:captured = @{ Method = $Method } + return @{ ok = $true } + } + $null = Invoke-SEBVRageRequest -Hostname 'h' -Port 8080 -SecurityKey $script:key -Endpoint 'session/save' -Method PATCH + $script:captured.Method | Should -Be 'PATCH' + } + + It 'serializes a non-string body to JSON' { + Mock Invoke-RestMethod -ModuleName VRageAPI { + $script:captured = @{ Body = $Body } + return @{ ok = $true } + } + $null = Invoke-SEBVRageRequest -Hostname 'h' -Port 8080 -SecurityKey $script:key -Endpoint 'session' -Method POST -Body @{ Prop = 'val' } + $script:captured.Body | Should -Match '"Prop"' + ($script:captured.Body | ConvertFrom-Json).Prop | Should -Be 'val' + } + } + + Context 'graceful failure (no live server, network mocked)' { + It 'returns $null on a non-Base64 security key without throwing' { + # New-SEBVRageAuthHeaders throws on a bad key; the caller must convert that to a $null + # return (the same contract as a connection failure), not propagate the exception. + Mock Invoke-RestMethod -ModuleName VRageAPI { @{ ok = $true } } + $result = Invoke-SEBVRageRequest -Hostname 'h' -Port 8080 -SecurityKey 'not base64!!!' -Endpoint 'server' -WarningAction SilentlyContinue + $result | Should -BeNullOrEmpty + Should -Invoke Invoke-RestMethod -ModuleName VRageAPI -Times 0 -Exactly + } + + It 'returns $null and does not throw when the HTTP call fails' { + Mock Invoke-RestMethod -ModuleName VRageAPI { throw [System.Net.Http.HttpRequestException]::new('refused') } + $result = Invoke-SEBVRageRequest -Hostname 'h' -Port 8080 -SecurityKey $script:key -Endpoint 'server' -WarningAction SilentlyContinue + $result | Should -BeNullOrEmpty + } + } + + # Field ORDER against a real Torch server is issue #29; this placeholder documents the deferral + # and never runs in the offline gate. + It 'round-trips against a live Torch server (deferred to the Torch harness)' -Tag 'Integration' -Skip { + Set-ItResult -Skipped -Because 'live Torch field-order verification is issue #29 (Torch harness)' + } +} + +Describe 'New-SEBVRageAuthHeaders deterministic outputs (issue #19 additions)' { + + BeforeAll { + . "$($script:repoRoot ?? (Resolve-Path "$PSScriptRoot/../..").Path)/Modules/VRageAPI/Private/New-SEBVRageAuthHeaders.ps1" + $script:k = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef')) + } + + It 'echoes the supplied nonce and date verbatim into the result' { + $h = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server' -Nonce 'echome' -Date 'Mon, 01 Jan 2024 00:00:00 GMT' + $h.Nonce | Should -Be 'echome' + $h.Date | Should -Be 'Mon, 01 Jan 2024 00:00:00 GMT' + $h.Authorization | Should -BeLike 'echome:*' + } + + It 'produces a different signature for a different nonce (same key/uri/date)' { + $a = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server' -Nonce 'nonceA' -Date 'Mon, 01 Jan 2024 00:00:00 GMT' + $b = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server' -Nonce 'nonceB' -Date 'Mon, 01 Jan 2024 00:00:00 GMT' + ($a.Authorization -split ':', 2)[1] | Should -Not -Be (($b.Authorization -split ':', 2)[1]) + } + + It 'is deterministic: identical inputs yield an identical Authorization' { + $a = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server/ping' -Nonce 'fixed' -Date 'Mon, 01 Jan 2024 00:00:00 GMT' + $b = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server/ping' -Nonce 'fixed' -Date 'Mon, 01 Jan 2024 00:00:00 GMT' + $a.Authorization | Should -Be $b.Authorization + } + + It 'auto-generates a 32-hex-char nonce when none is supplied' { + $h = New-SEBVRageAuthHeaders -SecurityKey $script:k -RequestUri '/vrageremote/v1/server' + $h.Nonce | Should -Match '^[0-9a-f]{32}$' -Because '16 random bytes rendered as lowercase hex' + } +}