Files
lmxopcua/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1
T
Joseph Doherty 2cb8a2c317
v2-ci / unit-tests (push) Has been cancelled
fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
An upgrade that swaps the install folder dropped the explicit ScriptWorker isolation ACEs on script-worker\, the
install folder and a copied-back pki\ (vd03 2026-11-13). Re-applying needed the operator to repeat the arguments.

- Install-ScriptWorkerIsolation.ps1 records its effective settings (InstallRoot, HostServiceAccount, PeCacheDir,
  HostServiceName, ConfigureHostService) beside DenyPaths under ...\Services\OtOpcUaScriptWorker\Parameters (new plan
  step SettingsRecord) and locks that key to SYSTEM + Administrators (protected DACL; no secrets recorded).
- -Reapply: re-apply from the record, no arguments; prints a summary line (changed/unchanged).
- -Verify: check only, [ok]/[MISSING] per step, exit 1 when anything is missing. -Uninstall also works from the record.
  A node isolated by a pre-#901 installer (DenyPaths only) must run the full-argument install once; both modes say so.
- Invoke-OtOpcUaInstallSwap.ps1: preflight -> stop -> move live/candidate -> carry appsettings/pki/logs ->
  -Reapply -> -Verify -> start; a failed -Verify leaves the service stopped. Also the rollback (-NoCarry).
- Tests: dry-run harness 101/101 (record + argument rules; record-key SDDL mutation turns 2 red). The Windows test adds
  swap -> Verify fails (exactly the 3 lost ACEs) -> Reapply (3 changed) -> Verify ok -> Reapply 12/12 unchanged, PE-cache
  and firewall negative controls, the swap script, and the pre-#901 refusal; 52/52 on windev under 5.1 and 7, with a
  scratch local host account and with SYSTEM. Evidence: scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify.
- Docs: ServiceHosting isolation section; vd03 pack (swap step, rollback, post-deploy gate, one-time full-argument run),
  the 2026-11-13 vd03 record, and the QA pack (QA swap preceded the isolation install; ACLs checked intact over SMB on
  vq03/vq04 2026-11-13; next-deploy procedure).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 02:06:10 -05:00

260 lines
22 KiB
PowerShell

<#
.SYNOPSIS
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter
-DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged ->
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> uninstall (from the record,
without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
Run it once under EACH host:
pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
.DESCRIPTION
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM',
or with -LocalHostAccount a scratch local user (random password, never shown) that is deleted at the end.
It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when
that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in
`finally`, including on failure.
#>
[CmdletBinding()]
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount,
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct')
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Write-Host 'SKIP: Windows only'; exit 0 }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 }
$installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1'
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force
$ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 }
foreach ($n in $WorkerServiceName, $HostServiceName) {
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 }
}
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Write-Host "refusing: local user $LocalHostAccountName already exists"; exit 2 }
$script:hostAccount = 'NT AUTHORITY\SYSTEM'
$script:failures = 0; $script:passes = 0
function Assert([bool]$Condition, [string]$Message) {
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
}
function Invoke-Installer([string[]]$Extra) {
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount $script:hostAccount `
-PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
}
function Invoke-Mode([string]$Mode) {
# lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector).
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
# Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is
# collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width.
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
$code = $LASTEXITCODE
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" })
[pscustomobject]@{ Exit = $code; Output = $out; Flat = ($out -replace '[\s|]+', ' '); Steps = $steps; Missing = $missing }
}
function Show([string]$Label, $R) {
# The transcript the issue asks for: one line per run.
$what = if ($R.Missing.Count) { "MISSING: $($R.Missing -join ' | ')" } else { ($R.Steps | Group-Object Outcome | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' }
Write-Host (" {0,-34} exit {1}; {2}" -f $Label, $R.Exit, $what)
}
function Get-Explicit([string]$Path, [string]$Sid) {
$id = [System.Security.Principal.SecurityIdentifier]::new($Sid)
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id })
}
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$root = Join-Path $scratch 'install'
$cache = Join-Path $scratch 'pe-cache'
# lmxopcua#890: the deployment folder + keys + secrets. pki\ inside the install root, as on vd03 (lmxopcua#901: the swap re-creates it).
$deny = @($scratch, (Join-Path $root 'pki'), (Join-Path $scratch 'data'))
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
$hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName"
try {
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
Set-Content -LiteralPath $workerExe -Value 'stub'
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
if ($LocalHostAccount) {
$pw = [Security.SecureString]::new()
foreach ($ch in ([Convert]::ToBase64String((1..24 | ForEach-Object { [byte](Get-Random -Maximum 256) })) + 'aA1!').ToCharArray()) { $pw.AppendChar($ch) }
New-LocalUser -Name $LocalHostAccountName -Password $pw -AccountNeverExpires -Description 'lmxopcua#901 isolation test (scratch)' | Out-Null
$script:hostAccount = ".\$LocalHostAccountName"
}
Set-Content -LiteralPath (Join-Path $root 'appsettings.json') -Value '{}'
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount"
Write-Host 'Dry run'
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and
-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing'
Write-Host 'Apply'
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
Assert ($a.Steps.Count -eq 12 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 12 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
$pe = @(Get-Explicit (Join-Path $cache 'pe') $workerSid)
Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe'
Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root'
Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected'
$rootDeny = @(Get-Explicit $root $workerSid)
Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\'
foreach ($d in $deny) {
$dd = @(Get-Explicit $d $workerSid)
Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d"
}
Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key'
$hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid })
Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)'
$fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe'
$env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
Assert (($env1 -contains 'Keep__Me=1') -and (@($env1 | Where-Object { $_ -like 'Scripting__Worker__Identity=*' }).Count -eq 1) -and
(@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others'
Write-Host 'Re-apply (idempotent)'
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
Assert ($b.Steps.Count -eq 12 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 12 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))"
$changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ','
Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)"
Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied'
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
Write-Host 'Settings record (lmxopcua#901)'
$recVals = Get-ItemProperty -Path $recordKey
Assert ($recVals.InstallRoot -eq $root -and $recVals.HostServiceAccount -eq $script:hostAccount -and $recVals.PeCacheDir -eq $cache -and
$recVals.HostServiceName -eq $HostServiceName -and $recVals.ConfigureHostService -eq 1) 'install root, host account, PE cache, host service and the flag are recorded'
Assert (Test-OtOpcUaRecordKeyAcl -Key $recordKey) "record key DACL is protected SYSTEM + Administrators ($((Get-Acl -Path $recordKey).GetSecurityDescriptorSddlForm('Access')))"
$settings = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName
Assert ($null -ne $settings -and (@($settings.DenyPaths) -join '|') -eq ($deny -join '|') -and $settings.ConfigureHostService) 'Get-OtOpcUaIsolationSettings reads it back'
Write-Host 'Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)'
$v0 = Invoke-Mode 'Verify'; Show 'Verify (installed)' $v0
Assert ($v0.Exit -eq 0 -and $v0.Output -match 'VERIFY OK') "verify passes on a fresh install ($($v0.Output))"
# The swap as the deploy packs did it: build the candidate beside the live folder, move live aside, move candidate in,
# copy pki\ and appsettings back (robocopy without ACLs, as a new folder).
$cand = Join-Path $scratch 'install.new'
New-Item -ItemType Directory -Path (Join-Path $cand 'script-worker') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $cand 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v2'
$prev1 = Join-Path $scratch 'install.bak.1'
Move-Item -LiteralPath $root -Destination $prev1
Move-Item -LiteralPath $cand -Destination $root
& robocopy (Join-Path $prev1 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
Copy-Item -LiteralPath (Join-Path $prev1 'appsettings.json') -Destination $root
$v1 = Invoke-Mode 'Verify'; Show 'Verify (after the swap)' $v1
Assert ($v1.Exit -eq 1 -and $v1.Output -match 'VERIFY FAILED') "verify exits 1 after the swap ($($v1.Exit))"
$expectMissing = @("WorkerFolderAcl $root\script-worker", "InstallRootDeny $root", "SensitiveDirDeny $root\pki")
Assert ((@($v1.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "exactly the three folder ACEs the swap drops are MISSING ($($v1.Missing -join ' | '))"
$r1 = Invoke-Mode 'Reapply'; Show 'Reapply (no arguments)' $r1
Assert ($r1.Exit -eq 0 -and $r1.Output -match 'settings from the record') "reapply exits 0 and says it used the record ($($r1.Output))"
$r1Changed = @($r1.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
Assert ($r1.Steps.Count -eq 12 -and (@($r1Changed | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "reapply changes exactly the missing three ($($r1Changed -join ' | '))"
$v2 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v2
Assert ($v2.Exit -eq 0 -and $v2.Output -match 'VERIFY OK') "verify passes after the reapply ($($v2.Exit))"
$r2 = Invoke-Mode 'Reapply'; Show 'Reapply again' $r2
Assert ($r2.Exit -eq 0 -and $r2.Steps.Count -eq 12 -and @($r2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second reapply: 12 steps, all unchanged ($(($r2.Steps | ForEach-Object Outcome) -join ','))"
Assert ($r2.Output -match 'summary: 12 step\(s\), 0 changed, 12 unchanged') 'the summary line says 0 changed'
Write-Host 'Negative control: Verify catches each kind of loss'
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # Users on the PE cache root
Remove-NetFirewallRule -Name $ruleName
$v3 = Invoke-Mode 'Verify'; Show 'Verify (cache ACL + rule broken)' $v3
Assert ($v3.Exit -eq 1 -and ($v3.Missing -join '|') -match 'PeCacheDir' -and ($v3.Missing -join '|') -match 'FirewallBlockOutbound') "verify flags the PE cache ACL and the missing rule ($($v3.Missing -join ' | '))"
$r3 = Invoke-Mode 'Reapply'; $v4 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v4
Assert ($r3.Exit -eq 0 -and $v4.Exit -eq 0) 'reapply repairs them'
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)'
$swapScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'Invoke-OtOpcUaInstallSwap.ps1'
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
$cand2 = Join-Path $scratch 'install.new2'
New-Item -ItemType Directory -Path (Join-Path $cand2 'script-worker') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $cand2 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v3'
$prev2 = Join-Path $scratch 'install.bak.2'
$sw = & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand2 -PreviousInstall $prev2 `
-Carry 'appsettings*.json,pki' -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String
$swExit = $LASTEXITCODE
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap', $swExit, $(if ($sw -match 'VERIFY OK') { 'VERIFY OK after its Reapply' } else { 'no VERIFY OK' }))
Assert ($swExit -eq 0 -and $sw -match 'VERIFY OK' -and $sw -match 'Swap complete') "swap script exits 0 with the gate passed ($sw)"
Assert ((Get-Content -LiteralPath (Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v3' -and (Test-Path (Join-Path $root 'pki')) -and
(Test-Path (Join-Path $root 'appsettings.json'))) 'the candidate is live and pki\ + appsettings were carried'
Assert (@(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1 -and
@(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'the three folder ACEs are back on the swapped-in folder'
Write-Host 'Swap refuses a pre-#901 record (DenyPaths only) before changing anything'
$saved = (Get-ItemProperty -Path $recordKey).InstallRoot
Remove-ItemProperty -Path $recordKey -Name InstallRoot
$cand3 = Join-Path $scratch 'install.new3'
New-Item -ItemType Directory -Path (Join-Path $cand3 'script-worker') -Force | Out-Null
$sw2 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand3 `
-PreviousInstall (Join-Path $scratch 'install.bak.3') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$sw2Exit = $LASTEXITCODE
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap (pre-#901)', $sw2Exit, $(if (($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901') { 'refused: settings not recorded' } else { 'NOT refused' }))
Assert ($sw2Exit -ne 0 -and ($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901' -and (Test-Path $cand3) -and -not (Test-Path (Join-Path $scratch 'install.bak.3'))) "refused, nothing moved ($sw2)"
$r4 = Invoke-Mode 'Reapply'
Show 'Reapply (pre-#901 record)' $r4
Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))"
Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
$u1 = Invoke-Installer @('-Uninstall')
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed'
Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and
@($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either'
Assert (-not (Test-Path $recordKey)) 'the record is gone'
Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache'
$env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))"
Start-Sleep -Milliseconds 500
$u2 = Invoke-Installer @('-Uninstall')
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
}
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
finally {
# Best-effort cleanup that does not depend on the code under test.
Remove-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
foreach ($n in $WorkerServiceName, $HostServiceName) {
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null }
}
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Remove-LocalUser -Name $LocalHostAccountName }
if (Test-Path -LiteralPath $scratch) {
& icacls $scratch /reset /T /C /Q 2>&1 | Out-Null
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
}
}
Write-Host ''
Write-Host "$($script:passes) passed, $($script:failures) failed"
if ($script:failures -gt 0) { exit 1 }