# run_tests.ps1 # Tests for the Windows sysinfo client (Documentation/Spec.md 7.8). # No Pester: one PASS/FAIL/SKIP line per check, then a RESULT line; exit 0 # only when nothing failed. Runs under Windows PowerShell 5.1 on Windows (all # checks; the ACL checks need an elevated session) and under pwsh on Linux # (Windows-only checks print SKIP). # # powershell -NoProfile -ExecutionPolicy Bypass -File t\windows\run_tests.ps1 # pwsh -NoProfile -File t/windows/run_tests.ps1 # # Must stay Windows PowerShell 5.1 compatible and pure ASCII. # # Version 1.0.0 20261018 Claude # Initial version (bug 211, stage W1): parser, runner, report, server.info, # ACL, library, argument quoting, placeholder and transport-dispatch checks # Stage W3: upload_http.ps1 return codes, fields and filename; installer dry run # Bug 216: dsEdit.ps1 and configure.ps1 (serializer, operations, INI, file write, relaunch, end to end) $script:Root = [IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot, '..', '..', 'windows')) $script:Fixtures = [IO.Path]::Combine($PSScriptRoot, 'fixtures') $script:Counts = @{ 'pass' = 0; 'fail' = 0; 'skip' = 0 } # the client's functions, without running it . ([IO.Path]::Combine($script:Root, 'sysinfo_client.ps1')) Import-Module ([IO.Path]::Combine($script:Root, 'lib', 'SysinfoSensor.psm1')) -Force function Assert-Check { param([string]$Name, [bool]$Condition, [string]$Detail = '') if ($Condition) { $script:Counts['pass']++ [Console]::Out.WriteLine("PASS $Name") } else { $script:Counts['fail']++ $line = "FAIL $Name" if ($Detail -ne '') { $line += " ($Detail)" } [Console]::Out.WriteLine($line) } } function Skip-Check { param([string]$Name, [string]$Reason) $script:Counts['skip']++ [Console]::Out.WriteLine("SKIP $Name ($Reason)") } # member lookup that is safe under strict mode; $null when any step is absent function Get-Member2 { param($Object, [string[]]$Path) $cursor = $Object foreach ($step in $Path) { if ($null -eq $cursor) { return $null } if ($cursor -is [System.Collections.IDictionary]) { if (-not $cursor.Contains($step)) { return $null } $cursor = $cursor[$step] } else { $property = $cursor.PSObject.Properties[$step] if ($null -eq $property) { return $null } $cursor = $property.Value } } return , $cursor } ####################################################### # Parser (Spec 2.4) ####################################################### $T = "`t" $report = New-SysinfoMap $text = @( "system${T}kernel${T}6.1", "garbage", "a${T}b${T}c${T}d${T}e${T}six", "x${T}${T}y${T}v", "system${T}kernel${T}deeper${T}v", "system${T}scalar", "pci${T}0${T}name${T}card", "system${T}kernel${T}7", "spaced ${T} key ${T} val", "", " ", "crlf${T}key${T}value`r", "Case${T}k${T}upper", "case${T}k${T}lower" ) -join "`n" $parsed = Add-SysinfoFacts $report $text Assert-Check 'parser: 2+ fields stored' ((Get-Member2 $report @('a', 'b', 'c', 'd', 'e')) -eq 'six') Assert-Check 'parser: one field rejected, first rejected kept' ($parsed['FirstRejected'] -eq 'garbage') Assert-Check 'parser: empty segment rejected' ($null -eq (Get-Member2 $report @('x'))) Assert-Check 'parser: rejected count (1 field, empty segment, through scalar, map->scalar)' ($parsed['Rejected'] -eq 4) "got $($parsed['Rejected'])" Assert-Check 'parser: map not replaced by scalar' ($report['system'] -is [System.Collections.IDictionary]) Assert-Check 'parser: key 0 valid' ((Get-Member2 $report @('pci', '0', 'name')) -eq 'card') Assert-Check 'parser: later scalar replaces earlier' ((Get-Member2 $report @('system', 'kernel')) -eq '7') Assert-Check 'parser: spaces around tabs trimmed' ((Get-Member2 $report @('spaced', 'key')) -eq 'val') Assert-Check 'parser: trailing CR dropped' ((Get-Member2 $report @('crlf', 'key')) -eq 'value') Assert-Check 'parser: keys case-sensitive' (((Get-Member2 $report @('Case', 'k')) -ceq 'upper') -and ((Get-Member2 $report @('case', 'k')) -ceq 'lower')) Assert-Check 'parser: values are strings' ((Get-Member2 $report @('system', 'kernel')) -is [string]) $empty = Add-SysinfoFacts (New-SysinfoMap) '' Assert-Check 'parser: empty input' ($empty['Rejected'] -eq 0) $sevenMap = New-SysinfoMap [void](Add-SysinfoFacts $sevenMap "a${T}b${T}c${T}d${T}e${T}f${T}seven") Assert-Check 'parser: more than 6 fields accepted as Unix' ((Get-Member2 $sevenMap @('a', 'b', 'c', 'd', 'e', 'f')) -eq 'seven') ####################################################### # server.info (Spec 3.3) ####################################################### $info = Read-SysinfoServerInfo ([IO.Path]::Combine($script:Fixtures, 'server.info')) Assert-Check 'server.info: first-colon split, key case-insensitive' ($info['clientName'] -eq 'Acme: Inc') "got '$($info['clientName'])'" Assert-Check 'server.info: key and value trimmed' ($info['serialNumber'] -eq 'SN-SERVERINFO') Assert-Check 'server.info: empty value does not override' ((-not $info.Contains('hostname')) -and (-not $info.Contains('UUID'))) Assert-Check 'server.info: unknown key ignored' (@($info.Keys).Count -eq 3) "keys: $(@($info.Keys) -join ',')" $tags = $info['tags'] Assert-Check 'server.info: tags comma split, empties dropped' (($tags -is [System.Collections.IList]) -and ($tags.Count -eq 3) -and ($tags[2] -eq 'c')) ####################################################### # Library (rules U and P) ####################################################### Assert-Check 'lib: UUID normalised' ((ConvertTo-NormalisedUUID '4C4C4544-0042-3510-8052-B4C04F4E4B32') -ceq '4c4c4544004235108052b4c04f4e4b32') Assert-Check 'lib: UUID wrong length -> empty' ((ConvertTo-NormalisedUUID '1234') -eq '') Assert-Check 'lib: UUID placeholders' ((Test-PlaceholderUUID '00000000-0000-0000-0000-000000000000') -and (Test-PlaceholderUUID 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') -and (Test-PlaceholderUUID '03000200-0400-0500-0006-000700080009') -and (Test-PlaceholderUUID 'Not Settable') -and (Test-PlaceholderUUID '')) Assert-Check 'lib: Supermicro MAC UUID ok' (-not (Test-PlaceholderUUID '00000000-0000-0000-0000-0cc47a123456')) Assert-Check 'lib: UUID status' (((Get-SysinfoUUIDStatus 'Not Present') -eq 'missing') -and ((Get-SysinfoUUIDStatus '0000') -eq 'placeholder') -and ((Get-SysinfoUUIDStatus '4c4c4544-0042-3510-8052-b4c04f4e4b32') -eq 'ok')) Assert-Check 'lib: serial placeholders' ((Test-PlaceholderSerial ' to be filled by o.e.m. ') -and (Test-PlaceholderSerial '0000') -and (Test-PlaceholderSerial '...') -and (Test-PlaceholderSerial 'N/A') -and (Test-PlaceholderSerial '')) Assert-Check 'lib: real serial ok' (-not (Test-PlaceholderSerial 'CZ12345678')) Assert-Check 'lib: serial status' (((Get-SysinfoSerialStatus '') -eq 'missing') -and ((Get-SysinfoSerialStatus 'Default string') -eq 'placeholder') -and ((Get-SysinfoSerialStatus 'ABC123') -eq 'ok')) Assert-Check 'lib: field sanitising' ((ConvertTo-SysinfoField "a`tb`r`nc") -eq 'a b c') ####################################################### # Argument quoting, placeholders, JSON helpers ####################################################### Assert-Check 'quote: trailing backslash doubled' ((ConvertTo-SysinfoArgument 'C:\a b\') -ceq '"C:\a b\\"') Assert-Check 'quote: embedded quote escaped' ((ConvertTo-SysinfoArgument 'x"y') -ceq '"x\"y"') Assert-Check 'quote: backslashes before quote doubled' ((ConvertTo-SysinfoArgument 'a\\"b') -ceq '"a\\\\\"b"') Assert-Check 'quote: inner backslashes kept' ((ConvertTo-SysinfoArgument 'C:\x\y') -ceq '"C:\x\y"') $placeholderConfig = New-SysinfoMap $placeholderConfig['hostname'] = 'myhost' $placeholderConfig['clientName'] = 'Acme' $placeholderConfig['serialNumber'] = 'SN1' $expanded = Expand-SysinfoPlaceholders 'h=$hostname c=$config{''clientName''} s=$configuration{''serialNumber''} d=$reportDate' $placeholderConfig '2026-10-18 01:02:03' Assert-Check 'placeholders: literal substitution' ($expanded -ceq 'h=myhost c=Acme s=SN1 d=2026-10-18 01:02:03') "got '$expanded'" $jsonConfig = ConvertTo-SysinfoMap ('{"tags":["one"],"n":{"m":{"o":{"p":{"q":"deep"}}}}}' | ConvertFrom-Json) Assert-Check 'config map: one-element list stays a list' ($jsonConfig['tags'] -is [System.Collections.IList]) $roundTrip = ConvertTo-SysinfoJson $jsonConfig Assert-Check 'json: one-element list written as array' ($roundTrip -match '"tags"\s*:\s*\[') Assert-Check 'json: depth kept' ($roundTrip -match '"q"\s*:\s*"deep"') ####################################################### # Transport dispatch (Spec 3.5) ####################################################### $global:SysinfoTestCalls = New-Object System.Collections.ArrayList $global:SysinfoTestParams = $null $global:SysinfoTestReport = $null $transports = ConvertTo-SysinfoMap ('{' + '"10":{"name":"ok","sendScript":"t_ok","subject":"report $hostname $reportDate","nested":{"k":"$hostname"}},' + '"2":{"name":"fail","sendScript":"t_fail"},' + '"3":{"name":"missing","sendScript":"t_missing"},' + '"4":{"name":"nofunc","sendScript":"t_nofunc"},' + '"5":{"name":"throw","sendScript":"t_throw.ps1"},' + '"6":{"name":"evil","sendScript":"..\\t_ok"},' + '"7":{"name":"nosend"}' + '}' | ConvertFrom-Json) $globals = [ordered]@{ 'upload_type' = 'sysinfo'; 'report date' = '2026-10-18 01:02:03'; 'host name' = 'myhost'; 'fileType' = 'json' } $transportDirs = @([IO.Path]::Combine($script:Fixtures, 'transports')) $sent = Send-SysinfoResults $globals $transports '{"x":1}' $transportDirs $placeholderConfig $true $calls = @($global:SysinfoTestCalls) -join ',' Assert-Check 'transport: success returned' ($sent -eq $true) Assert-Check 'transport: numeric order, first 1 wins, no leaked function' ($calls -eq 't_fail,t_nofunc loaded,t_throw,t_ok') "calls: $calls" Assert-Check 'transport: globals merged' (($null -ne $global:SysinfoTestParams) -and ($global:SysinfoTestParams['host name'] -eq 'myhost') -and ($global:SysinfoTestParams['fileType'] -eq 'json') -and ($global:SysinfoTestParams['name'] -eq 'ok')) Assert-Check 'transport: placeholders substituted' ($global:SysinfoTestParams['subject'] -eq 'report myhost 2026-10-18 01:02:03') Assert-Check 'transport: report passed as string' ($global:SysinfoTestReport -ceq '{"x":1}') $global:SysinfoTestCalls.Clear() $failOnly = ConvertTo-SysinfoMap ('{"0":{"sendScript":"t_fail"}}' | ConvertFrom-Json) Assert-Check 'transport: all fail returns false' ((Send-SysinfoResults $globals $failOnly '{}' $transportDirs $placeholderConfig $true) -eq $false) ####################################################### # Transport upload_http.ps1 (no network) ####################################################### # loaded in its own scope so its Send-SysinfoReport cannot leak into the dispatch checks $upload = & { param($Path) . $Path $results = @{ 'codes' = New-Object System.Collections.ArrayList } $params = [ordered]@{ 'URL' = 'http://127.0.0.1:9/' 'report date' = '2026-10-18 01:02:03' 'client name' = 'Acme Corp' 'host name' = 'host.example' 'serial number' = 'SN 1' 'upload_type' = 'sysinfo' 'fileType' = 'json' } $keys = [ordered]@{ 'key for report' = 'report' 'key for date' = 'report_date' 'key for hostname' = 'hostname' 'key for client' = 'client' 'key for serial number' = 'serialnumber' } # each call is missing the next key, so none reaches the network foreach ($key in @($keys.Keys)) { [void]$results['codes'].Add((Send-SysinfoReport $params '{}')) $params[$key] = $keys[$key] } $plan = Get-UploadHttpFields $params $results['report key'] = $plan['ReportKey'] $results['fields'] = (@($plan['Fields']) | ForEach-Object { $_[0] }) -join ',' $results['filename'] = Get-UploadHttpFileName $params $params.Remove('fileType') $results['filename no type'] = Get-UploadHttpFileName $params $params['URL'] = 'not-a-url' $results['bad url'] = Send-SysinfoReport $params '{}' $results['no url threw'] = $false try { [void](Send-SysinfoReport @{} '{}') } catch { $results['no url threw'] = $true } $results } ([IO.Path]::Combine($script:Root, 'transports', 'upload_http.ps1')) $codes = @($upload['codes']) -join ',' Assert-Check 'upload_http: return codes 5-9 for missing keys, in Perl order' ($codes -eq '5,6,7,8,9') "got $codes" Assert-Check 'upload_http: POST field names and order' (($upload['report key'] -eq 'report') -and ($upload['fields'] -eq 'report_date,hostname,client,serialnumber,upload_type,filename')) "got $($upload['report key']); $($upload['fields'])" Assert-Check 'upload_http: filename format, whitespace runs to one underscore' ($upload['filename'] -ceq '2026-10-18_01:02:03_Acme_Corp_host.example_SN_1.sysinfo.json') "got $($upload['filename'])" Assert-Check 'upload_http: filename without fileType' ($upload['filename no type'] -ceq '2026-10-18_01:02:03_Acme_Corp_host.example_SN_1.sysinfo') "got $($upload['filename no type'])" Assert-Check 'upload_http: unusable URL returns 4' ($upload['bad url'] -eq 4) "got $($upload['bad url'])" Assert-Check 'upload_http: missing URL throws' ($upload['no url threw'] -eq $true) Assert-Check 'upload_http: Send-SysinfoReport not leaked' (-not (Test-Path -LiteralPath 'Function:\Send-SysinfoReport')) ####################################################### # Installer dry run (Windows only; changes nothing) ####################################################### if (-not $script:OnWindows) { Skip-Check 'install: -DryRun ends RESULT: ok' 'not Windows' } else { $installOut = & (Get-Process -Id $PID).Path -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ([IO.Path]::Combine($script:Root, 'install.ps1')) -DryRun -Quiet -ClientName 'dry run test' | Out-String Assert-Check 'install: -DryRun ends RESULT: ok' ($installOut -match 'RESULT: ok \S+ dry run') $installOut.Trim() } ####################################################### # ACL check (Windows, elevated) ####################################################### function Set-InstallerStyleAcl { param([string]$Path, [bool]$IsDirectory) if ($IsDirectory) { $acl = New-Object System.Security.AccessControl.DirectorySecurity $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' } else { $acl = New-Object System.Security.AccessControl.FileSecurity $inherit = [System.Security.AccessControl.InheritanceFlags]::None } $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544')) $grants = @( @('S-1-5-18', 'FullControl'), @('S-1-5-32-544', 'FullControl'), @('S-1-5-32-545', 'ReadAndExecute') ) foreach ($grant in $grants) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule ( (New-Object System.Security.Principal.SecurityIdentifier $grant[0]), [System.Security.AccessControl.FileSystemRights]$grant[1], $inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow) $acl.AddAccessRule($rule) } Set-Acl -LiteralPath $Path -AclObject $acl } if (-not $script:OnWindows) { Skip-Check 'acl: installer-style ACL accepted' 'not Windows' Skip-Check 'acl: Users write ACE rejected' 'not Windows' Skip-Check 'acl: untrusted owner rejected' 'not Windows' } elseif (-not (Test-SysinfoElevated)) { Skip-Check 'acl: installer-style ACL accepted' 'not elevated' Skip-Check 'acl: Users write ACE rejected' 'not elevated' Skip-Check 'acl: untrusted owner rejected' 'not elevated' } else { $aclDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'sysinfo_acl_' + [guid]::NewGuid().ToString('N')) [void](New-Item -ItemType Directory -Path $aclDir) $aclFile = [IO.Path]::Combine($aclDir, 'acl_test.ps1') Write-SysinfoFile $aclFile "exit 0`r`n" try { Set-InstallerStyleAcl $aclFile $false Set-InstallerStyleAcl $aclDir $true $problem = Test-SysinfoAcl @($aclFile, $aclDir) Assert-Check 'acl: installer-style ACL accepted' ($problem -eq '') $problem $acl = Get-Acl -LiteralPath $aclFile $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule ( (New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-545'), [System.Security.AccessControl.FileSystemRights]::Modify, 'Allow'))) Set-Acl -LiteralPath $aclFile -AclObject $acl $problem = Test-SysinfoAcl @($aclFile, $aclDir) Assert-Check 'acl: Users write ACE rejected' ($problem -like '*S-1-5-32-545*') $problem $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User if ($script:TrustedSids -contains $me.Value) { Skip-Check 'acl: untrusted owner rejected' 'running as a trusted identity' } else { Set-InstallerStyleAcl $aclFile $false $acl = Get-Acl -LiteralPath $aclFile $acl.SetOwner($me) Set-Acl -LiteralPath $aclFile -AclObject $acl $problem = Test-SysinfoAcl @($aclFile, $aclDir) Assert-Check 'acl: untrusted owner rejected' ($problem -like '*owned by untrusted*') $problem } } finally { Remove-Item -LiteralPath $aclDir -Recurse -Force -ErrorAction SilentlyContinue } } ####################################################### # Sensors (Windows only): each exits 0, every line has 2-6 fields ####################################################### if (-not $script:OnWindows) { Skip-Check 'sensors: run on Windows only' 'not Windows' } else { $sensorDir = [IO.Path]::Combine($script:Root, 'sensors') $facts = @{} $noEnv = @{ 'SYSINFO_CONFIGURED_UUID' = $null; 'SYSINFO_CONFIGURED_SERIAL' = $null; 'SYSINFO_SERVERINFO_UUID' = $null; 'SYSINFO_SERVERINFO_SERIAL' = $null } foreach ($name in 'windows_identity', 'windows_system', 'windows_storage', 'windows_network', 'windows_software') { $run = Invoke-SysinfoSensorProcess ([IO.Path]::Combine($sensorDir, "$name.ps1")) $sensorDir 120 $noEnv Assert-Check "sensor ${name}: exits 0" (($run['ExitCode'] -eq 0) -and (-not $run['TimedOut'])) "exit $($run['ExitCode']): $($run['Stderr'])" $lines = @($run['Stdout'] -split "`n" | Where-Object { $_ -ne '' }) $bad = @($lines | Where-Object { $n = @($_ -split "`t").Count; $n -lt 2 -or $n -gt 6 }) Assert-Check "sensor ${name}: prints facts, all lines 2-6 fields" (($lines.Count -gt 0) -and ($bad.Count -eq 0)) "lines $($lines.Count), bad $($bad.Count)" Assert-Check "sensor ${name}: no CR or BOM in output" (($run['Stdout'] -notmatch "`r") -and (-not ($run['Stdout'].Length -gt 0 -and $run['Stdout'][0] -eq [char]0xFEFF))) $facts[$name] = $lines } $has = { param($sensor, $pattern) @($facts[$sensor] | Where-Object { $_ -match $pattern }).Count -gt 0 } Assert-Check 'sensor windows_identity: effective uuid and serial rules' ((& $has 'windows_identity' '^identity\tsystem_uuid_status\t(ok|placeholder|missing)$') -and (& $has 'windows_identity' '^identity\tvirtualization\t\S+$')) Assert-Check 'sensor windows_system: last_boot is a UTC epoch' (& $has 'windows_system' '^system\tlast_boot\t\d{9,11}$') Assert-Check 'sensor windows_system: os_name and kernel' ((& $has 'windows_system' '^operatingsystem\tos_name\tWindows$') -and (& $has 'windows_system' '^operatingsystem\tkernel\t\d+\.\d+\.\d+')) Assert-Check 'sensor windows_storage: sizes are integer KiB' (& $has 'windows_storage' '^diskinfo\t[A-Z]:\tsize\t\d+$') Assert-Check 'sensor windows_network: MAC format and netmask' ((& $has 'windows_network' '^network\t.+\tmac\t([0-9a-f]{2}:){5}[0-9a-f]{2}$') -and (& $has 'windows_network' '^network\t.+\tnetmask\t\d+\.\d+\.\d+\.\d+$')) Assert-Check 'sensor windows_software: version and description lines' ((& $has 'windows_software' '^software\t.+\tversion\t') -and (-not (& $has 'windows_software' '^software\t\t'))) } ####################################################### # Client run in -Test: runner statuses and report ####################################################### $runDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'sysinfo_run_' + [guid]::NewGuid().ToString('N')) [void](New-Item -ItemType Directory -Path $runDir) $runConfig = [IO.Path]::Combine($runDir, 'sysinfo_client.json') $configText = [IO.File]::ReadAllText([IO.Path]::Combine($script:Fixtures, 'sysinfo_client.test.json')) Write-SysinfoFile $runConfig ($configText.Replace('@FIXTURES@', $script:Fixtures.Replace('\', '\\'))) $savedTemp = $env:TEMP $env:TEMP = $runDir try { $hostPath = Get-SysinfoHostPath $clientOut = & $hostPath -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ([IO.Path]::Combine($script:Root, 'sysinfo_client.ps1')) ` -Test -ConfigFile $runConfig -ServerInfoFile ([IO.Path]::Combine($script:Fixtures, 'server_report.info')) 2>&1 $clientExit = $LASTEXITCODE } finally { $env:TEMP = $savedTemp } $outPath = [IO.Path]::Combine($runDir, 'sysinfo.testing.json') Assert-Check 'client: -Test exits 0' ($clientExit -eq 0) "exit $clientExit; $(@($clientOut) -join ' | ')" Assert-Check 'client: test output written' (Test-Path -LiteralPath $outPath -PathType Leaf) $j = $null $raw = '' if (Test-Path -LiteralPath $outPath -PathType Leaf) { $bytes = [IO.File]::ReadAllBytes($outPath) Assert-Check 'report: no BOM' (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) $raw = (New-Object System.Text.UTF8Encoding $false).GetString($bytes) try { $j = $raw | ConvertFrom-Json } catch { $j = $null } } Assert-Check 'report: valid JSON' ($null -ne $j) if ($null -ne $j) { $sensors = Get-Member2 $j @('sysinfo', 'sensors') $status = { param($name) Get-Member2 $sensors @($name, 'status') } $message = { param($name) Get-Member2 $sensors @($name, 'message') } # runner Assert-Check 'runner: exit 0 -> ok' ((& $status 't01_ok') -eq 'ok') Assert-Check 'runner: exit 1 -> wrong_os, message' (((& $status 't02_wrong_os') -eq 'wrong_os') -and ((& $message 't02_wrong_os') -eq 'fixture wrong os')) Assert-Check 'runner: exit 2 -> not_applicable' ((& $status 't03_not_applicable') -eq 'not_applicable') Assert-Check 'runner: exit 3 -> not_due, no message' (((& $status 't04_not_due') -eq 'not_due') -and ($null -eq (& $message 't04_not_due'))) Assert-Check 'runner: exit 4 -> error, exit_code' (((& $status 't05_error') -eq 'error') -and ((Get-Member2 $sensors @('t05_error', 'exit_code')) -eq 4)) Assert-Check 'runner: first non-empty STDERR line trimmed' ((& $message 't05_error') -eq 'first line') "got '$(& $message 't05_error')'" Assert-Check 'runner: STDOUT ignored on non-zero exit' ($null -eq (Get-Member2 $j @('fixture', 'from_error'))) Assert-Check 'runner: garbage -> ok with rejected_lines' (((& $status 't06_garbage') -eq 'ok') -and ((Get-Member2 $sensors @('t06_garbage', 'rejected_lines')) -eq 2) -and ((& $message 't06_garbage') -eq 'garbage-no-tab')) Assert-Check 'runner: garbage good line kept' ((Get-Member2 $j @('fixture_garbage', 'good')) -eq 'yes') Assert-Check 'runner: sleeper -> timeout' ((& $status 't07_sleeper') -eq 'timeout') $sleepSeconds = Get-Member2 $sensors @('t07_sleeper', 'seconds') Assert-Check 'runner: timeout enforced near sensorTimeout' (($null -ne $sleepSeconds) -and ([double]$sleepSeconds -lt 15)) "seconds $sleepSeconds" Assert-Check 'runner: uncaught error in Invoke-SysinfoSensor -> error' (((& $status 't08_crash') -eq 'error') -and ((& $message 't08_crash') -like 'failed: boom*')) Assert-Check 'runner: bad file name not run' (($null -eq (Get-Member2 $sensors @('bad-name'))) -and ($null -eq (Get-Member2 $j @('fixture', 'badname')))) Assert-Check 'runner: first name wins across moduleDirs' ($null -eq (Get-Member2 $j @('fixture', 'second'))) Assert-Check 'runner: later directory still scanned' ((Get-Member2 $j @('fixture', 'second_dir')) -eq 'ran') Assert-Check 'runner: seconds numeric' ((Get-Member2 $sensors @('t01_ok', 'seconds')) -is [ValueType]) # sensor output through the library Assert-Check 'lib: hostile value kept on one line' ((Get-Member2 $j @('fixture', 'hostile')) -eq 'a b c') Assert-Check 'lib: 5-segment path' ((Get-Member2 $j @('fixture', 'a', 'b', 'c', 'd')) -eq 'deep') Assert-Check 'lib: empty value not written' ($null -eq (Get-Member2 $j @('fixture', 'empty'))) Assert-Check 'lib: UTF-8 round trip' ((Get-Member2 $j @('fixture', 'unicode')) -ceq ([string][char]0x00E9)) Assert-Check 'lib: value 0 written as string' ((Get-Member2 $j @('fixture', 'zero')) -ceq '0') $argDir = [string](Get-Member2 $j @('fixture', 'argdir')) $expectDir = [IO.Path]::Combine($script:Fixtures, 'sensors') + [IO.Path]::DirectorySeparatorChar Assert-Check 'runner: module directory passed with trailing separator' ($argDir -eq $expectDir) "got '$argDir'" Assert-Check 'runner: SYSINFO_CONFIGURED_SERIAL is the config value' ((Get-Member2 $j @('fixture', 'env_configured_serial')) -eq 'CFG-SERIAL') Assert-Check 'runner: SYSINFO_SERVERINFO_SERIAL from server.info' ((Get-Member2 $j @('fixture', 'env_serverinfo_serial')) -eq 'SN-REPORT') Assert-Check 'runner: SYSINFO_SERVERINFO_UUID unset when absent' ((Get-Member2 $j @('fixture', 'env_serverinfo_uuid_set')) -eq 'False') # report Assert-Check 'report: one-element tags still an array' (($raw -match '"tags"\s*:\s*\[') -and ((Get-Member2 $j @('system', 'tags')) -is [array])) Assert-Check 'report: nested depth intact' ((Get-Member2 $j @('system', 'extra', 'a', 'b', 'c', 'd')) -eq 'deep') foreach ($keyPath in @(@('report', 'date'), @('report', 'client'), @('report', 'version'), @('system', 'hostname'), @('system', 'serial'), @('system', 'UUID'))) { $value = Get-Member2 $j $keyPath Assert-Check "report: $($keyPath -join '.') present and a string" ($value -is [string]) "got '$value'" } Assert-Check 'report: date format' ((Get-Member2 $j @('report', 'date')) -match '^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$') Assert-Check 'report: version is the data version' ((Get-Member2 $j @('report', 'version')) -eq 'v3.8.0') Assert-Check 'report: server.info owner overrides clientName' ((Get-Member2 $j @('report', 'client')) -eq 'Acme Report') Assert-Check 'report: server.info serial feeds system.serial' ((Get-Member2 $j @('system', 'serial')) -eq 'SN-REPORT') Assert-Check 'report: config UUID kept' ((Get-Member2 $j @('system', 'UUID')) -eq '4c4c4544-0000-1111-2222-333344445555') $hostname = [string](Get-Member2 $j @('system', 'hostname')) Assert-Check 'report: default hostname lower-cased' (($hostname -ne '') -and ($hostname -ceq $hostname.ToLowerInvariant())) Assert-Check 'report: other config keys copied into system' (((Get-Member2 $j @('system', 'location')) -eq 'Rack 1') -and ((Get-Member2 $j @('system', 'TESTING')) -eq 0)) $leaked = @('moduleDirs', 'transports', 'logging', 'sensorTimeout', 'outputType') | Where-Object { $null -ne (Get-Member2 $j @('system', $_)) } Assert-Check 'report: excluded config keys not in system' (@($leaked).Count -eq 0) "leaked: $(@($leaked) -join ',')" Assert-Check 'report: software.sysinfo-client-windows.version' ((Get-Member2 $j @('software', 'sysinfo-client-windows', 'version')) -eq $script:CLIENT_VERSION) Assert-Check 'report: sysinfo versions' (((Get-Member2 $j @('sysinfo', 'client_version')) -eq $script:CLIENT_VERSION) -and ((Get-Member2 $j @('sysinfo', 'data_version')) -eq 'v3.8.0')) Assert-Check 'report: sysinfo.config_file and server_info_file' (($null -ne (Get-Member2 $j @('sysinfo', 'config_file'))) -and ((Get-Member2 $j @('sysinfo', 'server_info_file')) -like '*server_report.info')) } Remove-Item -LiteralPath $runDir -Recurse -Force -ErrorAction SilentlyContinue # unsent.json is removed after a successful send $unsentDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'sysinfo_unsent_' + [Guid]::NewGuid().ToString('N')) [void](New-Item -ItemType Directory -Path $unsentDir) $script:ConfDir = $unsentDir $unsentFile = [IO.Path]::Combine($unsentDir, 'unsent.json') Assert-Check 'unsent: path is beside the config' ((Get-SysinfoUnsentPath) -eq $unsentFile) [IO.File]::WriteAllText($unsentFile, '{}') Remove-SysinfoUnsent Assert-Check 'unsent: stale file removed after success' (-not (Test-Path -LiteralPath $unsentFile)) Remove-SysinfoUnsent Assert-Check 'unsent: nothing to remove is not an error' $true Remove-Item -LiteralPath $unsentDir -Recurse -Force -ErrorAction SilentlyContinue ####################################################### # Updater (sysinfo_update.ps1): pure functions everywhere, end to end on Windows ####################################################### . ([IO.Path]::Combine($script:Root, 'sysinfo_update.ps1')) $hex = 'ab' * 32 Assert-Check 'update: checksum, sha256sum format' ((Read-ChecksumText "$hex sysinfo-client-windows.zip`n") -eq $hex) Assert-Check 'update: checksum, bare and lower-cased' ((Read-ChecksumText $hex.ToUpperInvariant()) -ceq $hex) Assert-Check 'update: checksum, none in text' ((Read-ChecksumText '404 not found') -eq '') Assert-Check 'update: checksum, 63 and 65 digits rejected' (((Read-ChecksumText ('a' * 63)) -eq '') -and ((Read-ChecksumText ('a' * 65)) -eq '')) $updDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'sysinfo_upd_' + [Guid]::NewGuid().ToString('N')) [void](New-Item -ItemType Directory -Path $updDir) $verFile = [IO.Path]::Combine($updDir, 'v.ps1') [IO.File]::WriteAllText($verFile, "# comment `$script:CLIENT_VERSION = '7.7.7'`n`$script:CLIENT_VERSION = '1.12.3'`n") Assert-Check 'update: release version read as text (line start only)' ((Get-ReleaseVersion $verFile) -eq '1.12.3') [IO.File]::WriteAllText($verFile, "nothing here`n") Assert-Check 'update: release version missing is empty' ((Get-ReleaseVersion $verFile) -eq '') Assert-Check 'update: real client version readable' ((Get-ReleaseVersion ([IO.Path]::Combine($script:Root, 'sysinfo_client.ps1'))) -eq $script:CLIENT_VERSION) $recFile = [IO.Path]::Combine($updDir, 'update_installed.sha256') Assert-Check 'update: no record file is no checksum (not matched)' ((Read-InstalledChecksum $recFile) -eq '') Write-InstalledChecksum $recFile $hex.ToUpperInvariant() Assert-Check 'update: recorded checksum round trips, lower case' ((Read-InstalledChecksum $recFile) -ceq $hex) Write-InstalledChecksum $recFile '' Assert-Check 'update: empty checksum is not recorded over the old one' ((Read-InstalledChecksum $recFile) -ceq $hex) [IO.File]::WriteAllText($recFile, 'garbage') Assert-Check 'update: unreadable record is no checksum' ((Read-InstalledChecksum $recFile) -eq '') Assert-Check 'update: compare newer, equal, older' (((Compare-ReleaseVersion '1.0.0' '1.0.1') -gt 0) -and ((Compare-ReleaseVersion '1.0.0' '1.0.0') -eq 0) -and ((Compare-ReleaseVersion '2.0.0' '1.9.9') -lt 0)) Assert-Check 'update: compare is numeric, 1.10.0 newer than 1.9.0' ((Compare-ReleaseVersion '1.9.0' '1.10.0') -gt 0) $cfg = ConvertTo-SysinfoMap ('{"update":{"url":"http://a/z.zip","sha256_url":"http://a/z.sha"}}' | ConvertFrom-Json) $settings = Get-UpdateSettings $cfg '' '' Assert-Check 'update: settings from config' (($settings['url'] -eq 'http://a/z.zip') -and ($settings['sha256_url'] -eq 'http://a/z.sha')) $settings = Get-UpdateSettings $cfg 'http://b/y.zip' '' Assert-Check 'update: -Url overrides config, checksum kept' (($settings['url'] -eq 'http://b/y.zip') -and ($settings['sha256_url'] -eq 'http://a/z.sha')) $settings = Get-UpdateSettings (New-SysinfoMap) '' '' Assert-Check 'update: no built-in URLs' (($settings['url'] -eq '') -and ($settings['sha256_url'] -eq '')) [IO.File]::WriteAllText($verFile, 'abc') Assert-Check 'update: local file read as bytes' ([Text.Encoding]::ASCII.GetString((Get-UpdateBytes $verFile)) -eq 'abc') $missing = '' try { [void](Get-UpdateBytes ([IO.Path]::Combine($updDir, 'nope.zip'))) } catch { $missing = $_.Exception.Message } Assert-Check 'update: missing file reports NOTFOUND' ($missing -eq 'NOTFOUND') $missing # zips: a release, one without an installer, one that tries to escape Add-Type -AssemblyName System.IO.Compression.FileSystem function New-TestZip { param([string]$Path, $Entries) $archive = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create) try { foreach ($name in $Entries.Keys) { $entry = $archive.CreateEntry($name) $stream = $entry.Open() $bytes = [Text.Encoding]::ASCII.GetBytes([string]$Entries[$name]) $stream.Write($bytes, 0, $bytes.Length) $stream.Dispose() } } finally { $archive.Dispose() } } function New-ReleaseZip { param([string]$Path, [string]$Version) New-TestZip $Path ([ordered]@{ 'sysinfo_client_windows/sysinfo_client.ps1' = "`$script:CLIENT_VERSION = '$Version'`n" 'sysinfo_client_windows/install.ps1' = "[Console]::Out.WriteLine('RESULT: ok stub')`nexit 0`n" }) } $goodZip = [IO.Path]::Combine($updDir, 'good.zip') New-ReleaseZip $goodZip '9.9.9' $releaseDir = Expand-ReleaseZip $goodZip ([IO.Path]::Combine($updDir, 'x_good')) Assert-Check 'update: unpack finds the release directory' ((Split-Path -Leaf $releaseDir) -eq 'sysinfo_client_windows') $threw = '' $noInstallZip = [IO.Path]::Combine($updDir, 'noinst.zip') New-TestZip $noInstallZip ([ordered]@{ 'r/sysinfo_client.ps1' = 'x' }) try { [void](Expand-ReleaseZip $noInstallZip ([IO.Path]::Combine($updDir, 'x_noinst'))) } catch { $threw = $_.Exception.Message } Assert-Check 'update: zip without install.ps1 refused' ($threw -like '*no sysinfo_client.ps1 and install.ps1*') $threw $threw = '' $slipZip = [IO.Path]::Combine($updDir, 'slip.zip') New-TestZip $slipZip ([ordered]@{ '../evil.txt' = 'x' }) try { [void](Expand-ReleaseZip $slipZip ([IO.Path]::Combine($updDir, 'x_slip'))) } catch { $threw = $_.Exception.Message } Assert-Check 'update: entry escaping the directory refused, nothing written' (($threw -like '*outside*') -and (-not (Test-Path -LiteralPath ([IO.Path]::Combine($updDir, 'evil.txt'))))) $threw if (-not $script:OnWindows) { Skip-Check 'update: end to end (-Check, checksum, exit codes)' 'not Windows' Skip-Check 'install: -DryRun with -UpdateUrl shows the update key' 'not Windows' } else { $psExe = (Get-Process -Id $PID).Path $updater = [IO.Path]::Combine($script:Root, 'sysinfo_update.ps1') function Invoke-TestUpdater { param([string]$Url, [string]$ShaUrl, [string[]]$Extra = @()) $cfgPath = [IO.Path]::Combine($updDir, 'cfg.json') $updateKeys = '"url":"' + $Url.Replace('\', '\\') + '","sha256_url":"' + $ShaUrl.Replace('\', '\\') + '"' [IO.File]::WriteAllText($cfgPath, '{"clientName":"t","update":{' + $updateKeys + '}}') $text = & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $updater -Config $cfgPath -Check -Quiet @Extra 2>&1 | Out-String return @{ 'code' = $LASTEXITCODE; 'text' = $text.Trim() } } $r = Invoke-TestUpdater $goodZip '' Assert-Check 'update e2e: newer release reported, exit 0' (($r['code'] -eq 0) -and ($r['text'] -match 'RESULT: ok 9\.9\.9 update available')) "$($r['code']) $($r['text'])" $sameZip = [IO.Path]::Combine($updDir, 'same.zip') New-ReleaseZip $sameZip $script:CLIENT_VERSION $r = Invoke-TestUpdater $sameZip '' Assert-Check 'update e2e: same version is nochange, exit 1' (($r['code'] -eq 1) -and ($r['text'] -match 'RESULT: nochange')) "$($r['code']) $($r['text'])" $goodSum = [IO.Path]::Combine($updDir, 'good.sha256') [IO.File]::WriteAllText($goodSum, ((Get-FileHash -LiteralPath $goodZip -Algorithm SHA256).Hash.ToLowerInvariant() + " good.zip`n")) $r = Invoke-TestUpdater $goodZip $goodSum Assert-Check 'update e2e: matching checksum accepted' (($r['code'] -eq 0) -and ($r['text'] -match 'RESULT: ok')) "$($r['code']) $($r['text'])" $badSum = [IO.Path]::Combine($updDir, 'bad.sha256') [IO.File]::WriteAllText($badSum, (('0' * 64) + " good.zip`n")) $r = Invoke-TestUpdater $goodZip $badSum Assert-Check 'update e2e: -Check with a differing published checksum reports an update without downloading, exit 0' (($r['code'] -eq 0) -and ($r['text'] -match 'RESULT: ok .*published checksum differs')) "$($r['code']) $($r['text'])" $r = Invoke-TestUpdater $goodZip ([IO.Path]::Combine($updDir, 'absent.sha256')) Assert-Check 'update e2e: absent checksum file is a warning' (($r['code'] -eq 0) -and ($r['text'] -match 'RESULT: ok')) "$($r['code']) $($r['text'])" $r = Invoke-TestUpdater $goodZip ([IO.Path]::Combine($updDir, 'absent.sha256')) @('-RequireChecksum') Assert-Check 'update e2e: -RequireChecksum makes absence fatal' (($r['code'] -eq 2) -and ($r['text'] -match 'RESULT: fail')) "$($r['code']) $($r['text'])" $r = Invoke-TestUpdater '' '' Assert-Check 'update e2e: no URL names update.url, exit 2' (($r['code'] -eq 2) -and ($r['text'] -match 'update\.url')) "$($r['code']) $($r['text'])" $installOut = & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ([IO.Path]::Combine($script:Root, 'install.ps1')) -DryRun -ClientName 'dry run test' -UpdateUrl 'http://h/z.zip' | Out-String Assert-Check 'install: -DryRun with -UpdateUrl shows the update key' (($installOut -match '"update"') -and ($installOut -match 'http://h/z\.zip')) $installOut.Trim() } Remove-Item -LiteralPath $updDir -Recurse -Force -ErrorAction SilentlyContinue ####################################################### # dsEdit.ps1 / configure.ps1 (bug 216): pure functions in a child scope, end to end in a child PowerShell ####################################################### $dsDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'sysinfo_dsedit_' + [Guid]::NewGuid().ToString('N')) [void][IO.Directory]::CreateDirectory($dsDir) $dsEditPath = [IO.Path]::Combine($script:Root, 'dsEdit.ps1') & { . $dsEditPath # its parameters become variables of this child scope only $script:Ed = @{ 'Format' = 'json'; 'Delimiter' = ''; 'Root' = $null; 'Header' = @(); 'Newline' = "`n"; 'Text' = ''; 'Lost' = 0; 'New' = $false; 'Path' = '' } $src = '{"clientName":"x","tags":["a","b"],"sensorTimeout":120,"ratio":1.5,"on":true,"off":false,"nothing":null,"note":"quote \" back \\ tab \t nl \n","empty":[],"e2":{},"l1":{"l2":{"l3":{"l4":{"l5":{"l6":{"l7":{"l8":{"deep":"yes"}}}}}}}}}' $root = Read-EditJson $src $text = ConvertTo-EditJson $root Assert-Check 'dsEdit: JSON round trip is stable' ((ConvertTo-EditJson (Read-EditJson $text)) -ceq $text) Assert-Check 'dsEdit: JSON keeps eight levels of depth' ($text.Contains('"deep": "yes"')) Assert-Check 'dsEdit: JSON does not escape < > or quotes needlessly' ($text.Contains('"x"')) Assert-Check 'dsEdit: JSON escapes quote, backslash, tab and newline' ($text.Contains('quote \" back \\ tab \t nl \n')) Assert-Check 'dsEdit: JSON types survive (number, float, booleans, null, empty list and map)' (($text.Contains('"sensorTimeout": 120')) -and ($text.Contains('"ratio": 1.5')) -and ($text.Contains('"on": true')) -and ($text.Contains('"off": false')) -and ($text.Contains('"nothing": null')) -and ($text.Contains('"empty": []')) -and ($text.Contains('"e2": {}'))) Assert-Check 'dsEdit: JSON output has three-space indent' ($text.Contains("`n `"tags`": [`n `"a`"")) $threw = $false; try { [void](Read-EditJson '{"a":') } catch { $threw = $true } Assert-Check 'dsEdit: invalid JSON is refused' $threw $threw = $false; try { [void](Read-EditJson '[1,2]') } catch { $threw = $true } Assert-Check 'dsEdit: a JSON array at the top level is refused' $threw Assert-Check 'dsEdit: empty text is an empty map' ((Get-EditKind (Read-EditJson ' ')) -eq 'map') Assert-Check 'dsEdit: guess number, bool, text, leading zero stays text' (((Convert-EditGuess '42') -is [int]) -and ((Convert-EditGuess 'true') -eq $true) -and ((Convert-EditGuess 'hello') -ceq 'hello') -and ((Convert-EditGuess '007') -ceq '007') -and ((Convert-EditGuess '1.25') -eq 1.25)) Assert-Check 'dsEdit: typed input refuses a non-number' (-not (Convert-EditInput 'abc' 'number')['Ok']) $script:Ed['Root'] = Read-EditJson '{"clientName":"x","tags":["a"],"sensorTimeout":120,"update":{"url":"u"}}' [void](Invoke-EditSet $script:Ed['Root'] 'sensorTimeout=60') [void](Invoke-EditSet $script:Ed['Root'] 'clientName=123') [void](Invoke-EditSet $script:Ed['Root'] 'update.sha256_url=http://h/a=b') [void](Invoke-EditSet $script:Ed['Root'] 'new.sub.key=true') Assert-Check 'dsEdit: -Set keeps the existing type (number stays number, text stays text)' (($script:Ed['Root']['sensorTimeout'] -is [int]) -and ($script:Ed['Root']['sensorTimeout'] -eq 60) -and ($script:Ed['Root']['clientName'] -ceq '123')) Assert-Check 'dsEdit: -Set splits at the first = only, creates maps, guesses new types' (($script:Ed['Root']['update']['sha256_url'] -ceq 'http://h/a=b') -and ($script:Ed['Root']['new']['sub']['key'] -eq $true)) $threw = $false; try { [void](Invoke-EditSet $script:Ed['Root'] 'sensorTimeout=many') } catch { $threw = $true } Assert-Check 'dsEdit: -Set refuses text for a number' $threw $threw = $false; try { [void](Invoke-EditSet $script:Ed['Root'] 'tags=x') } catch { $threw = $true } Assert-Check 'dsEdit: -Set refuses a list or map target' $threw [void](Invoke-EditAdd $script:Ed['Root'] 'tags=b') $again = Invoke-EditAdd $script:Ed['Root'] 'tags=b' [void](Invoke-EditAdd $script:Ed['Root'] 'fresh=1') Assert-Check 'dsEdit: -Add appends, skips duplicates, creates a list' ((@($script:Ed['Root']['tags']).Count -eq 2) -and ($again -match 'already') -and ((Get-EditKind $script:Ed['Root']['fresh']) -eq 'list')) [void](Invoke-EditRemove $script:Ed['Root'] 'tags=a') [void](Invoke-EditRemove $script:Ed['Root'] 'update.url') $gone = Invoke-EditRemove $script:Ed['Root'] 'no.such' Assert-Check 'dsEdit: -Remove takes a list value or a key, and a missing key is not an error' ((@($script:Ed['Root']['tags']) -join ',') -eq 'b' -and (-not $script:Ed['Root']['update'].Contains('url')) -and ($gone -match 'not present')) Assert-Check 'dsEdit: known-key check passes a good config' ((Get-EditWarnings (Read-EditJson '{"clientName":"x","tags":["a"],"sensorTimeout":5,"transports":{"h":{}}}') 'sysinfo_client.json').Count -eq 0) $bad = Get-EditWarnings (Read-EditJson '{"clientName":5,"tags":"a","transports":{"h":"x"}}') 'sysinfo_client.json' Assert-Check 'dsEdit: known-key check reports wrong types' ($bad.Count -eq 3) "$($bad -join '; ')" Assert-Check 'dsEdit: known-key check leaves other files alone' ((Get-EditWarnings (Read-EditJson '{"tags":"a"}') 'other.json').Count -eq 0) $ini = Read-EditIni "# header`r`n`r`nOwner: bob`r`nTags: a,b`r`n# lost`r`nbad line`r`n[sec]`r`nk: v: w`r`n" ':' Assert-Check 'dsEdit: INI reads colon files with CRLF and sections' (($ini['Root']['Owner'] -ceq 'bob') -and ($ini['Root']['sec']['k'] -ceq 'v: w') -and ($ini['Lost'] -eq 2) -and ($ini['Header'].Count -eq 1)) $iniText = ConvertTo-EditIni $ini['Root'] $ini['Header'] ':' Assert-Check 'dsEdit: INI writes header, keys, then sections' ($iniText -ceq "# header`n`nOwner: bob`nTags: a,b`n`n[sec]`nk: v: w`n") $eqText = ConvertTo-EditIni (Read-EditIni "a=1`n[s]`nb = 2`n" '=')['Root'] @() '=' Assert-Check 'dsEdit: INI equals delimiter' ($eqText -ceq "a=1`n`n[s]`nb=2`n") $k = Get-EditFormat 'C:\ProgramData\camp\server.info' '' '' Assert-Check 'dsEdit: format from extension; server.info is INI with a colon' (($k['Format'] -eq 'ini') -and ($k['Delimiter'] -eq ':') -and ((Get-EditFormat 'a.json' '' '')['Format'] -eq 'json') -and ((Get-EditFormat 'a.conf' '' '')['Delimiter'] -eq '=')) $threw = $false; try { [void](Get-EditFormat 'a.yaml' '' '') } catch { $threw = $_.Exception.Message -match 'YAML' } Assert-Check 'dsEdit: YAML is refused with a message' $threw $threw = $false; try { [void](Get-EditFormat 'a.txt' '' '') } catch { $threw = $true } Assert-Check 'dsEdit: unknown extension needs -Format' $threw # file writing: backup, BOM-free UTF-8, CRLF kept $f = [IO.Path]::Combine($dsDir, 'w.json') [IO.File]::WriteAllText($f, "{`r`n `"a`": 1`r`n}`r`n", (New-Object System.Text.UTF8Encoding $true)) Open-EditFile $f @{ 'Format' = 'json'; 'Delimiter' = '' } Assert-Check 'dsEdit: a file with a BOM and CRLF loads, newline style remembered' (($script:Ed['Root']['a'] -eq 1) -and ($script:Ed['Newline'] -eq "`r`n")) [void](Invoke-EditSet $script:Ed['Root'] 'b=two') [void](Save-EditSession $false) $bytes = [IO.File]::ReadAllBytes($f) Assert-Check 'dsEdit: write is UTF-8 without a BOM, CRLF kept, no temp file left' (($bytes[0] -eq 123) -and ([Text.Encoding]::UTF8.GetString($bytes).Contains("`r`n")) -and (-not (Test-Path -LiteralPath ($f + '.new')))) Assert-Check 'dsEdit: the previous contents are in .bak' ((Test-Path -LiteralPath ($f + '.bak')) -and (-not [IO.File]::ReadAllText($f + '.bak').Contains('two'))) # the elevated relaunch carries its arguments in one -EncodedCommand $Set = @("a=b'c", 'd=e f') $relaunch = Get-EditRelaunchArguments 'C:\Program Files\camp\dsEdit.ps1' 'C:\ProgramData\camp\server.info' $decoded = '' if ($relaunch -match '-EncodedCommand (\S+)$') { $decoded = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1])) } Assert-Check 'dsEdit: relaunch command quotes paths and values and adds -Pause -NoElevate' (($decoded.Contains("& 'C:\Program Files\camp\dsEdit.ps1' -NoElevate -Pause -Path 'C:\ProgramData\camp\server.info'")) -and ($decoded.Contains("-Set @('a=b''c','d=e f')"))) } # end to end in a child PowerShell (one operation per run, as a -File call allows) $psExe = (Get-Process -Id $PID).Path $e2e = [IO.Path]::Combine($dsDir, 'cfg.json') [IO.File]::WriteAllText($e2e, '{"clientName":"t","tags":["a"],"sensorTimeout":120,"transports":{}}') function Invoke-TestDsEdit { param([string[]]$Arguments, [string]$StdIn = '') $out = '' if ($StdIn -ne '') { $out = $StdIn | & $psExe -NoProfile -ExecutionPolicy Bypass -File $dsEditPath @Arguments 2>&1 | Out-String } else { $out = & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $dsEditPath @Arguments 2>&1 | Out-String } return @{ 'code' = $LASTEXITCODE; 'text' = $out.Trim() } } $r = Invoke-TestDsEdit @($e2e, '-Set', 'sensorTimeout=30') Assert-Check 'dsEdit e2e: -Set writes the file, exit 0' (($r['code'] -eq 0) -and ([IO.File]::ReadAllText($e2e).Contains('"sensorTimeout": 30'))) "$($r['code']) $($r['text'])" $r = Invoke-TestDsEdit @('-Path', $e2e, '-Add', 'tags=b') Assert-Check 'dsEdit e2e: -Path and -Add' (($r['code'] -eq 0) -and ([IO.File]::ReadAllText($e2e).Contains('"b"'))) "$($r['code']) $($r['text'])" $before = [IO.File]::ReadAllText($e2e) $r = Invoke-TestDsEdit @($e2e, '-Set', 'clientName=') $r = Invoke-TestDsEdit @($e2e, '-Set', 'tags=zzz') Assert-Check 'dsEdit e2e: a refused change exits 2 and leaves the file alone' (($r['code'] -eq 2) -and ([IO.File]::ReadAllText($e2e).Contains('"clientName": ""')) -and ([IO.File]::ReadAllText($e2e).Contains('"b"'))) "$($r['code']) $($r['text'])" $r = Invoke-TestDsEdit @($e2e, '-Show', '-Remove', 'sensorTimeout') Assert-Check 'dsEdit e2e: -Show prints the result' (($r['code'] -eq 0) -and ($r['text'] -notmatch 'sensorTimeout"') ) "$($r['code']) $($r['text'])" [IO.File]::WriteAllText($e2e, '{"clientName": ') $r = Invoke-TestDsEdit @($e2e, '-Show') Assert-Check 'dsEdit e2e: a broken file is reported, exit 2, not overwritten' (($r['code'] -eq 2) -and ($r['text'] -match 'not valid JSON') -and ([IO.File]::ReadAllText($e2e) -eq '{"clientName": ')) "$($r['code']) $($r['text'])" $r = Invoke-TestDsEdit @((Join-Path $dsDir 'x.yaml'), '-Show') Assert-Check 'dsEdit e2e: YAML is refused, exit 2' (($r['code'] -eq 2) -and ($r['text'] -match 'YAML')) "$($r['code']) $($r['text'])" $nav = [IO.Path]::Combine($dsDir, 'nav.json') [IO.File]::WriteAllText($nav, '{"clientName":"t","tags":["a"]}') # edit clientName; open tags; add a string z (Enter takes the default type); go up; save and quit $script_in = "1`nrenamed`n2`na`n`nz`nu`ns`n" $r = Invoke-TestDsEdit @($nav) $script_in $navText = [IO.File]::ReadAllText($nav) Assert-Check 'dsEdit e2e: the navigator edits a value, adds a list item and saves' (($r['code'] -eq 0) -and $navText.Contains('"clientName": "renamed"') -and $navText.Contains('"z"')) "$($r['code']) $($r['text']) $navText" $r = Invoke-TestDsEdit @($nav) "1`nxx`nq`ny`n" Assert-Check 'dsEdit e2e: quitting discards changes after the question' (($r['code'] -eq 0) -and ([IO.File]::ReadAllText($nav) -eq $navText)) "$($r['code']) $($r['text'])" $cfg = [IO.Path]::Combine($script:Root, 'configure.ps1') $out = & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $cfg -Path $nav -Show 2>&1 | Out-String Assert-Check 'dsEdit e2e: configure.ps1 passes parameters through to dsEdit.ps1' (($LASTEXITCODE -eq 0) -and $out.Contains('"clientName": "renamed"')) $out.Trim() Remove-Item -LiteralPath $dsDir -Recurse -Force -ErrorAction SilentlyContinue ####################################################### $failed = $script:Counts['fail'] $verdict = 'ok' if ($failed -gt 0) { $verdict = 'fail' } [Console]::Out.WriteLine("RESULT: $verdict $($script:Counts['pass']) passed, $failed failed, $($script:Counts['skip']) skipped") if ($failed -gt 0) { exit 1 } exit 0