Browse Source

test(lab): add the kit that deploys and runs the operating-system matrix

Tests\Lab\Acceptance gets the scripts of Decision 24, which extends the
acceptance of the live tests from one lab to several operating-system builds:

- Deploy-OsMatrixLab.ps1, Add-OsMatrixMachine.ps1, Complete-OsMatrixLab.ps1
  and Repair-OsMatrixBoot.ps1 build NtfsSecurityOsMatrixLab with AutomatedLab
  (Server 2019, 2022 and 2025 file servers, Windows 11 clients) and repair a
  base image whose host-side bcdboot left an empty EFI system partition.
- Test-MatrixReadiness.ps1 and Test-MatrixCleanup.ps1 gate each run and prove
  that it left nothing behind (fixture accounts, shares, folders, group
  members, orphaned SIDs, profiles); -Mode Repair removes what is left.
- Run-MatrixSequence.ps1 runs the live controller for every cell of the matrix
  and stops after an infrastructure failure.
- Run-MatrixLocalSuite.ps1 and Invoke-LocalSuite.ps1 run the module's own
  Pester suite on each machine in both editions, elevated and as a basic user,
  as scheduled tasks: a process started from a remoting session gets every
  privilege enabled, which eight of the tests do not expect.
- Export-MatrixResults.ps1 collates the cells, the skipped tests and the
  package hashes into the results table.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: AI Assistant <ai@example.com>
pull/119/head
Raimund Andree 2 days ago
parent
commit
192a55570f
  1. 123
      Tests/Lab/Acceptance/Add-OsMatrixMachine.ps1
  2. 91
      Tests/Lab/Acceptance/Complete-OsMatrixLab.ps1
  3. 96
      Tests/Lab/Acceptance/Deploy-OsMatrixLab.ps1
  4. 101
      Tests/Lab/Acceptance/Export-MatrixResults.ps1
  5. 66
      Tests/Lab/Acceptance/Invoke-LocalSuite.ps1
  6. 103
      Tests/Lab/Acceptance/Repair-OsMatrixBoot.ps1
  7. 236
      Tests/Lab/Acceptance/Run-MatrixLocalSuite.ps1
  8. 104
      Tests/Lab/Acceptance/Run-MatrixSequence.ps1
  9. 112
      Tests/Lab/Acceptance/Test-MatrixCleanup.ps1
  10. 79
      Tests/Lab/Acceptance/Test-MatrixReadiness.ps1

123
Tests/Lab/Acceptance/Add-OsMatrixMachine.ps1

@ -0,0 +1,123 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $LogPath,
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $Name,
[Parameter(Mandatory)] [string] $OperatingSystem,
[Parameter(Mandatory)] [string] $IpAddress,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[ValidateRange(2, 16)] [int] $MemoryGB = 4,
[ValidateRange(1, 8)] [int] $Processors = 2,
[ValidateRange(5, 240)] [int] $StartTimeoutMinutes = 40,
[string] $BackupRoot = 'C:\ProgramData\AutomatedLab\Backups'
)
# Adds one machine to the already deployed matrix lab (Decision 24) and creates only that machine. AutomatedLab 5.61 has no supported way to
# extend a deployed lab: Add-LabMachineDefinition refuses while a lab is imported or exported, and Install-Lab creates every machine of the
# lab again. This script copies the lab metadata first (the copy is readable by administrators only, because the files hold the lab
# credentials), reloads the definition with Import-LabDefinition (never Import-Lab), adds the machine, exports the definition, and then runs
# the same steps Install-Lab runs for a single machine: base image, hosts entries, virtual machine, start. The other machines are neither
# created, started, nor changed. Windows PowerShell 5.1 on the host; run it elevated.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' }
($stamp -f [DateTime]::UtcNow) + " START add-os-matrix-machine lab=$LabName name=$Name os='$OperatingSystem' ip=$IpAddress" | Set-Content -LiteralPath $LogPath
$lockPath = $null
try {
Import-Module -Name AutomatedLab -ErrorAction Stop
if ((Get-Lab -List) -notcontains $LabName) { throw "The lab '$LabName' does not exist." }
if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw "A virtual machine named '$Name' exists already." }
$hostsText = Get-Content -LiteralPath (Join-Path -Path $env:SystemRoot -ChildPath 'System32\drivers\etc\hosts') -Raw
if ($hostsText -match ('(?im)^\s*[^#\s]+\s+{0}(\.|\s|$)' -f [regex]::Escape($Name)) -or $hostsText -match ('(?im)^\s*{0}\s' -f [regex]::Escape($IpAddress))) {
throw "The hosts file mentions '$Name' or $IpAddress already."
}
$labFolder = Join-Path -Path (Get-LabConfigurationItem -Name LabAppDataRoot) -ChildPath "Labs\$LabName"
$backup = Join-Path -Path $BackupRoot -ChildPath ('{0}-{1:yyyyMMdd-HHmmss}' -f $LabName, [DateTime]::UtcNow)
$null = New-Item -ItemType Directory -Path $backup -Force
$null = & icacls.exe $backup /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F'
if ($LASTEXITCODE -ne 0) { throw "icacls failed on the backup folder (exit code $LASTEXITCODE)." }
Copy-Item -LiteralPath $labFolder -Destination $backup -Recurse
Write-Step "lab metadata copied to $backup"
Import-LabDefinition -Name $LabName
$definition = Get-LabDefinition
$before = @(Get-LabMachineDefinition | ForEach-Object -Process { $_.Name })
$domainName = $definition.Domains[0].Name
$rootDc = Get-LabMachineDefinition | Where-Object -FilterScript { 'RootDC' -in $_.Roles.Name } | Select-Object -First 1
$dcAddress = ($rootDc.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString
$dcPrefix = ($dcAddress -split '\.')[0..2] -join '.'
$newPrefix = ($IpAddress -split '\.')[0..2] -join '.'
if ($dcPrefix -ne $newPrefix) { throw "$IpAddress isn't in the /24 of the domain controller ($dcAddress)." }
Write-Step ("definition loaded: domain {0}; machines {1}; installation account {2}" -f $domainName, ($before -join ','), $definition.DefaultInstallationCredential.UserName)
$parameters = @{
Name = $Name; DomainName = $domainName; OperatingSystem = $OperatingSystem; Memory = ($MemoryGB * 1GB)
Processors = $Processors; Network = $LabName; IpAddress = $IpAddress
}
if ($OperatingSystem -like 'Windows 11*') {
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' }
}
Add-LabMachineDefinition @parameters
Export-LabDefinition -Force -ExportDefaultUnattendedXml
Import-Lab -Name $LabName -NoValidation -NoDisplay
$after = @(Get-LabVM -IncludeLinux | ForEach-Object -Process { $_.Name })
$difference = @(Compare-Object -ReferenceObject ($before + $Name) -DifferenceObject $after)
if ($difference.Count -gt 0) { throw "The exported lab doesn't hold exactly the old machines plus $Name. Restore the metadata from $backup." }
Write-Step 'definition extended and exported'
$lockPath = Get-LabConfigurationItem -Name DiskDeploymentInProgressPath
if (Test-Path -LiteralPath $lockPath) { throw "Another lab disk deployment seems to be in progress ($lockPath)." }
$null = New-Item -Path $lockPath -ItemType File -Value $LabName
New-LabBaseImages
Write-Step 'base images ready'
$machine = Get-LabVM -ComputerName $Name
$address = ($machine.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString
$null = Add-HostEntry -HostName $machine.Name -IpAddress $address -Section $LabName
$null = Add-HostEntry -HostName $machine.FQDN -IpAddress $address -Section $LabName
New-LabVM -Name $Name
Set-LabDefinition -Machines (Get-Lab).Machines
Export-LabDefinition -Force -ExportDefaultUnattendedXml -Silent
Write-Step 'virtual machine created and definition exported'
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
$lockPath = $null
Start-LabVM -ComputerName $Name -ProgressIndicator 30 -TimeoutInMinutes $StartTimeoutMinutes -Wait
Write-Step 'machine started and reachable with the lab credentials'
$userName = (Get-Lab).DefaultInstallationCredential.UserName
Invoke-LabCommand -ActivityName 'Setting PasswordNeverExpires for local deployment accounts' -ComputerName $Name -NoDisplay -Variable (Get-Variable -Name userName) -ScriptBlock {
Get-CimInstance -Query "Select * from Win32_UserAccount where name = '$userName' and localaccount='true'" | Set-CimInstance -Property @{ PasswordExpires = $false }
}
$evidence = Invoke-LabCommand -ComputerName $Name -ActivityName 'Readiness of the new member' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $domainName -ScriptBlock {
param ($Domain)
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
[pscustomobject]@{
Build = '{0}.{1}' -f $current.CurrentBuildNumber, $current.UBR
Product = $current.ProductName
Domain = (Get-CimInstance -ClassName Win32_ComputerSystem).Domain
SecureChannel = [bool] (Test-ComputerSecureChannel)
Verify = (@(& nltest.exe "/sc_verify:$Domain" 2>&1) -join ' | ')
}
}
Write-Step ('new member: build {0} ({1}); domain {2}; secure channel {3}; nltest: {4}' -f $evidence.Build, $evidence.Product, $evidence.Domain, $evidence.SecureChannel, $evidence.Verify)
if (-not $evidence.SecureChannel) { throw "The secure channel of $Name is broken." }
Write-Step 'add-os-matrix-machine-DONE'
exit 0
}
catch {
Write-Step ('add-os-matrix-machine-FAILED: {0}' -f $_)
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath
exit 1
}
finally {
if ($lockPath) { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue }
}

91
Tests/Lab/Acceptance/Complete-OsMatrixLab.ps1

@ -0,0 +1,91 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $LogPath,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string[]] $Member = @('OSDC1', 'OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'),
[string[]] $LocalCredentialMember = @(),
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1',
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi'
)
# Finishes machines of the matrix lab after a deployment that stopped in AutomatedLab's file server step (a job that never completed
# although its remote side was idle) or after Add-OsMatrixMachine.ps1: detaches the installation ISO from the file servers, installs
# PowerShell 7 from the MSI of the host, and copies Pester 5.7.1 into the module folders of both editions. It uses no AutomatedLab job
# (no -AsJob), only synchronous remoting. A member in -LocalCredentialMember is reached with the local installation account through a
# session, for a machine whose secure channel to the domain controller fails. Windows PowerShell 5.1 on the host; run it elevated.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# -File passes an array as one string, so a list may arrive as 'A,B'.
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
$LocalCredentialMember = @($LocalCredentialMember | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath }
$installBlock = {
param ($Msi)
$msiPath = Join-Path -Path 'C:\Windows\Temp' -ChildPath $Msi
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f $msiPath), '/quiet', '/norestart', 'ADD_PATH=1', '/l*v', 'C:\Windows\Temp\pwsh-install.log') -Wait -PassThru
$pwsh = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe'
[pscustomobject]@{ ExitCode = $process.ExitCode; Pwsh = $(if (Test-Path -LiteralPath $pwsh) { (Get-Item -LiteralPath $pwsh).VersionInfo.ProductVersion } else { 'missing' }) }
}
$createBlock = { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force }
$checkBlock = { param ($Path) '{0}: {1}' -f $env:COMPUTERNAME, (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath '5.7.1\Pester.psd1')) }
($stamp -f [DateTime]::UtcNow) + " START complete-os-matrix-lab lab=$LabName members=$($Member -join ',') localCredential=$($LocalCredentialMember -join ',')" | Set-Content -LiteralPath $LogPath
try {
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } }
Import-Module -Name AutomatedLab -ErrorAction Stop
Import-Lab -Name $LabName -NoValidation -NoDisplay
$fileServers = @($Member | Where-Object -FilterScript { $_ -like 'OSFile*' -and $_ -notin $LocalCredentialMember })
if ($fileServers) {
Dismount-LabIsoImage -ComputerName $fileServers -SupressOutput
Write-Step "installation ISO detached from $($fileServers -join ',')"
}
$msiName = Split-Path -Path $PowerShell7Msi -Leaf
$domainMembers = @($Member | Where-Object -FilterScript { $_ -notin $LocalCredentialMember })
foreach ($name in $Member) {
if ($name -in $LocalCredentialMember) {
$session = New-LabPSSession -ComputerName $name -UseLocalCredential
try {
Copy-Item -LiteralPath $PowerShell7Msi -Destination 'C:\Windows\Temp\' -ToSession $session -Force
$outcome = Invoke-Command -Session $session -ScriptBlock $installBlock -ArgumentList $msiName
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') {
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester'
Invoke-Command -Session $session -ScriptBlock $createBlock -ArgumentList $destination
Copy-Item -LiteralPath $PesterModulePath -Destination $destination -ToSession $session -Recurse -Force
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (Invoke-Command -Session $session -ScriptBlock $checkBlock -ArgumentList $destination))
}
}
finally {
Remove-PSSession -Session $session -ErrorAction SilentlyContinue
}
}
else {
Copy-LabFileItem -Path $PowerShell7Msi -ComputerName $name -DestinationFolderPath 'C:\Windows\Temp'
$outcome = Invoke-LabCommand -ComputerName $name -ActivityName "Install PowerShell 7 on $name" -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $msiName -ScriptBlock $installBlock
}
Write-Step ("PowerShell 7 on {0}: msiexec exit code {1}; pwsh {2}" -f $name, $outcome.ExitCode, $outcome.Pwsh)
if ($outcome.ExitCode -notin 0, 3010 -or $outcome.Pwsh -eq 'missing') { throw "PowerShell 7 isn't installed on $name (exit code $($outcome.ExitCode))." }
}
if ($domainMembers) {
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') {
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester'
Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Create the Pester module directory' -NoDisplay -ErrorAction Stop -ArgumentList $destination -ScriptBlock $createBlock
Copy-LabFileItem -Path $PesterModulePath -ComputerName $domainMembers -DestinationFolderPath $destination -Recurse
$found = Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Check Pester' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $destination -ScriptBlock $checkBlock
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (@($found) -join '; '))
if (@($found | Where-Object -FilterScript { $_ -notmatch ': True$' }).Count -gt 0) { throw "Pester 5.7.1 isn't in $destination on every member." }
}
}
Write-Step 'complete-os-matrix-lab-DONE'
exit 0
}
catch {
Write-Step ("complete-os-matrix-lab-FAILED: {0}" -f $_)
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath
exit 1
}

96
Tests/Lab/Acceptance/Deploy-OsMatrixLab.ps1

@ -0,0 +1,96 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $LogPath,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string] $DomainName = 'osmatrix.net',
[string] $VmPath = 'V:\AutomatedLab-VMs',
[string] $AddressSpace = '192.168.12.0/24',
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1',
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi'
)
# Deploys an isolated AutomatedLab lab for the NTFSSecurity operating-system matrix (Decision 24): one domain controller, three file
# servers (Server 2019, 2022, 2025), and a Windows 11 client, in a domain and on a switch of their own. It touches none of the
# existing labs, machines, switches, or domains, never calls Remove-Lab, and refuses to run when the lab or a machine name exists.
# The installation password is generated here, kept in memory, and stored only where AutomatedLab stores it for every lab.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' }
($stamp -f [DateTime]::UtcNow) + " START deploy-os-matrix-lab lab=$LabName" | Set-Content -LiteralPath $LogPath
try {
Import-Module -Name AutomatedLab -ErrorAction Stop
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } }
$machines = @(
@{ Name = 'OSDC1'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('RootDC'); Memory = 4GB; Address = '192.168.12.10' }
@{ Name = 'OSFile25'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.25' }
@{ Name = 'OSFile22'; Os = 'Windows Server 2022 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.22' }
@{ Name = 'OSFile19'; Os = 'Windows Server 2019 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.19' }
@{ Name = 'OSWin11'; Os = 'Windows 11 Pro'; Roles = @(); Memory = 4GB; Address = '192.168.12.11' }
)
# Collision checks from AutomatedLab metadata and from Hyper-V; the existing labs are only read.
$existingNames = New-Object System.Collections.Generic.List[string]
$labs = @(Get-Lab -List)
if ($labs -contains $LabName) { throw "The lab '$LabName' exists already. Refusing to redefine it." }
foreach ($existing in $labs) {
Import-Lab -Name $existing -NoValidation -NoDisplay -ErrorAction Stop
foreach ($vm in Get-LabVM -IncludeLinux) { $existingNames.Add($vm.Name) }
}
foreach ($vm in Get-VM) { $existingNames.Add($vm.Name) }
$collisions = @($machines.Name | Where-Object { $_ -in $existingNames })
if ($collisions) { throw "Machine name collision: $($collisions -join ', ')" }
if (Get-VMSwitch -Name $LabName -ErrorAction SilentlyContinue) { throw "A virtual switch named '$LabName' exists already." }
$usedAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { $_.IPAddress })
if ($usedAddresses | Where-Object { $_ -like '192.168.12.*' }) { throw 'The address space 192.168.12.0/24 is in use on the host.' }
Write-Step ('preflight ok; existing labs: {0}; existing machine names: {1}' -f ($labs -join ', '), $existingNames.Count)
$characters = ([char[]](48..57) + [char[]](65..90) + [char[]](97..122) + '!', '#', '%', '+', '-', '=')
$password = -join (1..24 | ForEach-Object { $characters | Get-Random })
$password = 'Aa1!' + $password
New-LabDefinition -Name $LabName -DefaultVirtualizationEngine HyperV -VmPath $VmPath
Add-LabVirtualNetworkDefinition -Name $LabName -AddressSpace $AddressSpace
Add-LabDomainDefinition -Name $DomainName -AdminUser 'install' -AdminPassword $password
Set-LabInstallationCredential -Username 'install' -Password $password
foreach ($definition in $machines) {
$parameters = @{
Name = $definition.Name; DomainName = $DomainName; OperatingSystem = $definition.Os; Memory = $definition.Memory
Processors = 2; Network = $LabName; IpAddress = $definition.Address
}
if ($definition.Roles.Count -gt 0) { $parameters.Roles = $definition.Roles }
if ($definition.Os -like 'Windows 11*') {
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' }
}
Add-LabMachineDefinition @parameters
}
Write-Step 'lab defined; installing network switches and base images'
Install-Lab -NetworkSwitches -BaseImages
Write-Step 'network switches and base images done'
Install-Lab
Write-Step 'machines, domain, and roles done'
$labMachines = Get-LabVM
Install-LabSoftwarePackage -ComputerName $labMachines -Path $PowerShell7Msi -CommandLine '/quiet /norestart ADD_PATH=1' -Timeout 30
Write-Step 'PowerShell 7 installed'
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') {
$destination = Join-Path $modulesRoot 'Pester'
Invoke-LabCommand -ComputerName $labMachines -ActivityName 'Create the Pester module directory' -ScriptBlock { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } -ArgumentList $destination -NoDisplay
Copy-LabFileItem -Path $PesterModulePath -ComputerName $labMachines -DestinationFolderPath $destination -Recurse
}
Write-Step 'Pester 5.7.1 copied'
Show-LabDeploymentSummary -Summary
Write-Step "deploy-os-matrix-lab-DONE"
exit 0
}
catch {
Write-Step ("deploy-os-matrix-lab-FAILED: {0}" -f $_)
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath
exit 1
}

101
Tests/Lab/Acceptance/Export-MatrixResults.ps1

@ -0,0 +1,101 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $OutputPrefix,
[string] $MatrixRoot,
[string] $Label,
[string[]] $LocalSuiteFolder = @(),
[string] $ReferenceMachine = 'LOCAL'
)
# Turns the raw results of the operating-system matrix (Decision 24) into the tables of the acceptance record, in Windows PowerShell 5.1.
# -MatrixRoot and -Label name the sequences of Run-MatrixSequence.ps1 (folders <Label>-<file server>): per cell, edition, and role the
# counts, the skipped tests, and the operating systems of the readiness log. -LocalSuiteFolder lists the result folders of
# Run-MatrixLocalSuite.ps1 (folders <label>-<machine> with one JSON file per run): per machine, mode, and edition the counts, and the
# difference of the skipped tests to the reference machine (a skipped test that only one side skips is a difference). Every input file is
# listed with its SHA-256 in <OutputPrefix>-hashes.csv. Nothing is changed in the lab.
$ErrorActionPreference = 'Stop'
# -File passes an array as one string, so a list may arrive as 'A,B'.
$LocalSuiteFolder = @($LocalSuiteFolder | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
$hashRows = New-Object -TypeName 'System.Collections.Generic.List[object]'
function Add-Hash { param ([string] $Path) $hashRows.Add([pscustomobject]@{ File = $Path; Sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash; Bytes = (Get-Item -LiteralPath $Path).Length }) }
function Get-Multiset { param ([string[]] $Name) $set = @{}; foreach ($item in @($Name | Where-Object -FilterScript { $_ })) { $set[$item] = 1 + [int] $set[$item] }; $set }
function Get-Excess {
param ([hashtable] $Left, [hashtable] $Right)
foreach ($key in ($Left.Keys | Sort-Object)) { $extra = [int] $Left[$key] - [int] $Right[$key]; if ($extra -gt 0) { '{0} (x{1})' -f $key, $extra } }
}
if ($MatrixRoot) {
$cellRows = New-Object -TypeName 'System.Collections.Generic.List[object]'
$skipRows = New-Object -TypeName 'System.Collections.Generic.List[object]'
foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter "$Label-*" | Sort-Object -Property Name)) {
$server = $folder.Name.Substring($Label.Length + 1)
$readiness = Join-Path -Path $folder.FullName -ChildPath 'readiness.log'
$operatingSystems = @{}
foreach ($match in (Select-String -LiteralPath $readiness -Pattern '^(\S+)\s+wsman=ok .* os=(.+?) type=(\d)')) {
$groups = $match.Matches[0].Groups
$operatingSystems[$groups[1].Value] = '{0} ({1})' -f ($groups[2].Value -replace '^Microsoft ', ''), $(if ($groups[3].Value -eq '1') { 'client' } else { 'server' })
}
$clientName = @($operatingSystems.Keys | Where-Object -FilterScript { $operatingSystems[$_] -like '*client*' })[0]
$counts = Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-counts.csv")
foreach ($row in $counts) {
$cellRows.Add([pscustomobject]@{
FileServer = $server; FileServerOs = $operatingSystems[$server]; Client = $clientName; ClientOs = $operatingSystems[$clientName]
Edition = $row.Edition; Role = $row.Role; Account = $row.Account; ExitCode = $row.ExitCode; Passed = $row.Passed; Failed = $row.Failed; Skipped = $row.Skipped
})
}
foreach ($test in (Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-tests.csv") | Where-Object -FilterScript { $_.Result -ne 'Passed' })) {
$skipRows.Add([pscustomobject]@{ FileServer = $server; Edition = $test.Edition; Role = $test.Role; Result = $test.Result; Test = $test.Test; Message = $test.Message })
}
foreach ($name in "$Label-$server-counts.csv", "$Label-$server-tests.csv", "$Label-$server-failures.csv", 'readiness.log', 'validation.log', 'run.log', 'fixture-sids.json', 'cleanup-1-snapshot.log', 'cleanup-2-remove.log', 'cleanup-3-verify.log') {
$path = Join-Path -Path $folder.FullName -ChildPath $name
if (Test-Path -LiteralPath $path) { Add-Hash -Path $path }
}
foreach ($summary in (Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue)) { Add-Hash -Path $summary.FullName }
}
$cellRows | Export-Csv -LiteralPath "$OutputPrefix-cells.csv" -NoTypeInformation
$skipRows | Export-Csv -LiteralPath "$OutputPrefix-cells-skipped.csv" -NoTypeInformation
'{0} role rows and {1} tests that did not pass, in {2} cell(s)' -f $cellRows.Count, $skipRows.Count, @($cellRows | Select-Object -ExpandProperty FileServer -Unique).Count
}
if ($LocalSuiteFolder) {
$runs = New-Object -TypeName 'System.Collections.Generic.List[object]'
foreach ($folder in $LocalSuiteFolder) {
$machine = ((Split-Path -Path $folder -Leaf) -split '-', 2)[1]
foreach ($json in (Get-ChildItem -LiteralPath $folder -Filter '*.json' | Sort-Object -Property Name)) {
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json
Add-Hash -Path $json.FullName
$log = [IO.Path]::ChangeExtension($json.FullName, '.log')
if (Test-Path -LiteralPath $log) { Add-Hash -Path $log }
$runs.Add([pscustomobject]@{
Machine = $machine; Os = $summary.Os; Mode = $(if ($summary.Elevated) { 'Elevated' } else { 'Basic' }); Edition = $summary.Edition; PowerShell = $summary.PowerShell
Result = $summary.Result; Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds
SkippedTests = @($summary.SkippedTests | Where-Object -FilterScript { $_ }); FailedTests = @($summary.FailedTests | Where-Object -FilterScript { $_ })
})
}
}
$runs | Select-Object -Property Machine, Os, Mode, Edition, PowerShell, Result, Passed, Failed, Skipped, Total, Seconds | Sort-Object -Property Machine, Mode, Edition |
Export-Csv -LiteralPath "$OutputPrefix-localsuite.csv" -NoTypeInformation
$differences = foreach ($run in ($runs | Where-Object -FilterScript { $_.Machine -ne $ReferenceMachine })) {
$reference = $runs | Where-Object -FilterScript { $_.Machine -eq $ReferenceMachine -and $_.Mode -eq $run.Mode -and $_.Edition -eq $run.Edition } | Select-Object -First 1
if (-not $reference) { continue }
$mine = Get-Multiset -Name $run.SkippedTests
$theirs = Get-Multiset -Name $reference.SkippedTests
[pscustomobject]@{
Machine = $run.Machine; Mode = $run.Mode; Edition = $run.Edition; Skipped = $run.Skipped; ReferenceSkipped = $reference.Skipped
OnlyOnMachine = (@(Get-Excess -Left $mine -Right $theirs) -join ' | '); OnlyOnReference = (@(Get-Excess -Left $theirs -Right $mine) -join ' | ')
Failed = $run.Failed; FailedTests = ($run.FailedTests -join ' | ')
}
}
$differences | Sort-Object -Property Machine, Mode, Edition | Export-Csv -LiteralPath "$OutputPrefix-localsuite-skipdiff.csv" -NoTypeInformation
'{0} local-suite run(s) of {1} machine(s)' -f $runs.Count, @($runs | Select-Object -ExpandProperty Machine -Unique).Count
}
$hashRows | Export-Csv -LiteralPath "$OutputPrefix-hashes.csv" -NoTypeInformation
'{0} input file(s) hashed' -f $hashRows.Count

66
Tests/Lab/Acceptance/Invoke-LocalSuite.ps1

@ -0,0 +1,66 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label,
[Parameter(Mandatory)] [string] $Root,
[Parameter(Mandatory)] [string] $OutDir,
[string] $PesterVersion = '5.7.1',
[string] $PesterModulePath
)
# Runs the Pester files of <Root>\Tests in this process, Windows PowerShell 5.1 or PowerShell 7, against the module in
# <Root>\NTFSSecurity\bin\Release, like .github\scripts\Invoke-Tests.ps1 does for the repository. It writes the log, the NUnit result, a JSON
# summary, and an exit code file to <OutDir>. Run it in a new process for every edition. The tests keep to their own sandbox folders
# below $env:TEMP.
$ErrorActionPreference = 'Stop'
$null = New-Item -ItemType Directory -Path $OutDir -Force
$log = Join-Path -Path $OutDir -ChildPath "$Label.log"
$script:exitCode = 1
if ($PSVersionTable.PSEdition -eq 'Desktop') {
# A Windows PowerShell process started by PowerShell 7 would inherit the module path of PowerShell 7.
$env:PSModulePath = @(
(Join-Path -Path ([Environment]::GetFolderPath('MyDocuments')) -ChildPath 'WindowsPowerShell\Modules'),
(Join-Path -Path $env:ProgramFiles -ChildPath 'WindowsPowerShell\Modules'),
(Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\Modules')
) -join ';'
}
& {
$principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList ([Security.Principal.WindowsIdentity]::GetCurrent())
$elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
'[{0:yyyy-MM-dd HH:mm:ss}Z] START {1} edition={2} {3} elevated={4} os={5} build={6}.{7} user={8}' -f [DateTime]::UtcNow, $Label, $PSVersionTable.PSEdition,
$PSVersionTable.PSVersion, $elevated, $current.ProductName, $current.CurrentBuildNumber, $current.UBR, [Security.Principal.WindowsIdentity]::GetCurrent().Name
try {
$watch = [Diagnostics.Stopwatch]::StartNew()
if ($PesterModulePath) { Import-Module -Name (Join-Path -Path $PesterModulePath -ChildPath 'Pester.psd1') -Force -ErrorAction Stop }
else { Import-Module -Name Pester -RequiredVersion $PesterVersion -Force -ErrorAction Stop }
$configuration = New-PesterConfiguration
$configuration.Run.Path = @(Join-Path -Path $Root -ChildPath 'Tests')
$configuration.Run.PassThru = $true
$configuration.Output.Verbosity = 'Normal'
# The NUnit file is written below, after the summary: its writer asks WMI for the environment, which a restricted token may not do.
$configuration.TestResult.Enabled = $false
$result = Invoke-Pester -Configuration $configuration
'RESULT result={0} passed={1} failed={2} skipped={3} notrun={4} total={5} failedContainers={6} seconds={7:N0}' -f $result.Result, $result.PassedCount,
$result.FailedCount, $result.SkippedCount, $result.NotRunCount, $result.TotalCount, $result.FailedContainersCount, $watch.Elapsed.TotalSeconds
foreach ($test in $result.Failed) { 'FAILED: {0}: {1}' -f $test.ExpandedPath, ("$(@($test.ErrorRecord)[0])" -replace '\s+', ' ') }
foreach ($test in $result.Skipped) { 'SKIPPED: {0}' -f $test.ExpandedPath }
[pscustomobject]@{
Label = $Label; Edition = $PSVersionTable.PSEdition; PowerShell = $PSVersionTable.PSVersion.ToString(); Elevated = $elevated
Os = '{0} {1}.{2}' -f $current.ProductName, $current.CurrentBuildNumber, $current.UBR; Result = [string] $result.Result
Passed = $result.PassedCount; Failed = $result.FailedCount; Skipped = $result.SkippedCount; NotRun = $result.NotRunCount; Total = $result.TotalCount
FailedContainers = $result.FailedContainersCount; Seconds = [int] $watch.Elapsed.TotalSeconds
FailedTests = @($result.Failed | ForEach-Object -Process { $_.ExpandedPath }); SkippedTests = @($result.Skipped | ForEach-Object -Process { $_.ExpandedPath })
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.json") -Encoding UTF8
try { Export-NUnitReport -Result $result -Path (Join-Path -Path $OutDir -ChildPath "$Label.xml") }
catch { 'NUnit report not written: {0}' -f $_.Exception.Message }
if ($result.Result -eq 'Passed') { $script:exitCode = 0 }
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-DONE' -f [DateTime]::UtcNow, $Label
}
catch {
'ERROR: {0}' -f $_
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-FAILED' -f [DateTime]::UtcNow, $Label
}
} *>&1 | Out-File -FilePath $log -Encoding utf8 -Width 400
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.exit") -Value $script:exitCode
exit $script:exitCode

103
Tests/Lab/Acceptance/Repair-OsMatrixBoot.ps1

@ -0,0 +1,103 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $LogPath,
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $VmName,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[switch] $Start
)
# Repairs the boot files of one virtual machine of the matrix lab. AutomatedLab builds a base image with the bcdboot of the host and
# ignores its exit code; when the host's bcdboot can't process the boot files of an older image (it fails with exit code 193 on Windows
# Server 2019 and on Windows 11 22H2), the EFI system partition stays empty and the generation 2 virtual machine fails with Hyper-V event
# 18603. This script turns the machine off, mounts the machine's own differencing disk (never the shared base image), runs the bcdboot of
# the image itself, adds the removable-media path EFI\Boot\bootx64.efi that a new virtual machine boots from, checks the files, and
# dismounts the disk. It refuses a machine that isn't connected to the switch of the lab. Windows PowerShell 5.1 on the host, elevated.
$ErrorActionPreference = 'Stop'
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' }
($stamp -f [DateTime]::UtcNow) + " START repair-os-matrix-boot vm=$VmName lab=$LabName" | Set-Content -LiteralPath $LogPath
$diskPath = $null
$letters = @()
try {
$vm = Get-VM -Name $VmName
if ($vm.Generation -ne 2) { throw "$VmName isn't a generation 2 machine." }
$switches = @(Get-VMNetworkAdapter -VMName $VmName | ForEach-Object -Process { $_.SwitchName })
if ($switches -ne $LabName) { throw "$VmName isn't connected only to the switch '$LabName' (switches: $($switches -join ', ')). Refusing." }
if ($vm.State -ne 'Off') {
Stop-VM -Name $VmName -TurnOff -Force
Write-Step "$VmName turned off"
}
$drive = Get-VMHardDiskDrive -VMName $VmName | Select-Object -First 1
$diskPath = $drive.Path
$vhd = Get-VHD -Path $diskPath
if ($vhd.VhdType -ne 'Differencing') { throw "$diskPath isn't a differencing disk; refusing to change a base image." }
Write-Step "disk $diskPath (parent $($vhd.ParentPath))"
$image = Mount-VHD -Path $diskPath -Passthru
$disk = $image | Get-Disk
$partitions = @(Get-Partition -DiskNumber $disk.Number)
$esp = $partitions | Where-Object -FilterScript { $_.GptType -eq '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' } | Select-Object -First 1
$system = $partitions | Where-Object -FilterScript { $_.Type -eq 'Basic' } | Sort-Object -Property Size -Descending | Select-Object -First 1
if (-not $esp -or -not $system) { throw 'The disk has no EFI system partition or no Windows partition.' }
foreach ($partition in $esp, $system) {
# The host may have assigned a letter to the Windows partition on mount already.
if (-not (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter) {
Add-PartitionAccessPath -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber -AssignDriveLetter
}
$letters += (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter
}
$espLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $esp.PartitionNumber).DriveLetter
$systemLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $system.PartitionNumber).DriveLetter
$windows = '{0}:\Windows' -f $systemLetter
$bcdboot = Join-Path -Path $windows -ChildPath 'System32\bcdboot.exe'
if (-not (Test-Path -LiteralPath $bcdboot)) { throw "$bcdboot is missing." }
$before = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count
Write-Step ("system partition {0}: {1}; ESP {2}: holds {3} files; image build {4}" -f $systemLetter, $windows, $espLetter, $before, (Get-Item -LiteralPath $bcdboot).VersionInfo.ProductVersion)
$output = & $bcdboot $windows /s ('{0}:' -f $espLetter) /f UEFI 2>&1 | Out-String
Write-Step ("bcdboot of the image: exit code {0}: {1}" -f $LASTEXITCODE, ($output -replace '\s+', ' ').Trim())
if ($LASTEXITCODE -ne 0) { throw "bcdboot of the image failed with exit code $LASTEXITCODE." }
$bootManager = '{0}:\EFI\Microsoft\Boot\bootmgfw.efi' -f $espLetter
if (-not (Test-Path -LiteralPath $bootManager)) { throw "$bootManager is missing after bcdboot." }
$removable = '{0}:\EFI\Boot' -f $espLetter
$null = New-Item -ItemType Directory -Path $removable -Force
Copy-Item -LiteralPath $bootManager -Destination (Join-Path -Path $removable -ChildPath 'bootx64.efi') -Force
$after = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count
$hasBcd = Test-Path -LiteralPath ('{0}:\EFI\Microsoft\Boot\BCD' -f $espLetter)
Write-Step ("ESP now holds {0} files; BCD present: {1}; bootx64.efi present: {2}" -f $after, $hasBcd, (Test-Path -LiteralPath (Join-Path -Path $removable -ChildPath 'bootx64.efi')))
if ($after -lt 20 -or -not $hasBcd) { throw "The EFI system partition still looks empty ($after files, BCD $hasBcd)." }
}
catch {
Write-Step ('repair-os-matrix-boot-FAILED: {0}' -f $_)
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath
$failed = $true
}
finally {
if ($diskPath) {
try {
foreach ($partition in @(Get-Partition -DiskNumber (Get-VHD -Path $diskPath).DiskNumber -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.DriveLetter })) {
Remove-PartitionAccessPath -DiskNumber $partition.DiskNumber -PartitionNumber $partition.PartitionNumber -AccessPath ('{0}:\' -f $partition.DriveLetter) -ErrorAction SilentlyContinue
}
}
catch { Write-Step ('access path cleanup: {0}' -f $_) }
Dismount-VHD -Path $diskPath -ErrorAction SilentlyContinue
Write-Step 'disk dismounted'
}
}
if ($failed) { exit 1 }
if ($Start) {
Start-VM -Name $VmName
Write-Step "$VmName started"
}
Write-Step 'repair-os-matrix-boot-DONE'
exit 0

236
Tests/Lab/Acceptance/Run-MatrixLocalSuite.ps1

@ -0,0 +1,236 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label,
[Parameter(Mandatory)] [string] $Machine,
[Parameter(Mandatory)] [string] $ModulePath,
[Parameter(Mandatory)] [string] $OutputRoot,
[string] $Edition = 'Desktop,Core',
[string] $Mode = 'Elevated',
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string] $LocalCredentialMachine = '',
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1',
[string] $RepositoryRoot,
[ValidateRange(5, 480)] [int] $TimeoutMinutes = 90
)
# The module's own Pester suite on the machines of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V
# host. The live controller proves the behavior against a domain and remote servers; this proves the module and its tests run on each
# operating system and edition. It stages the behavior test files of the repository and the module under test (the same bits for every
# machine), copies them to the machine, runs Invoke-LocalSuite.ps1 in a new Windows PowerShell and a new PowerShell 7 process one after
# the other, and copies the log, the NUnit result, and the JSON summary back. -Mode Basic runs each edition with the token of a basic
# user, the way .github\scripts\Invoke-TestsAsBasicUser.ps1 does (the class of that script is extracted, not copied): the tests that
# need a missing privilege skip in the elevated mode and run in this one. The machine name LOCAL runs the same stage on this host, as
# the reference. A machine that can't use the domain account (a Windows 11 build whose secure channel to the domain controller fails) is
# listed in -LocalCredentialMachine and reached with the local installation account. Nothing secret is written.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File.
if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent }
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
$targets = @($Machine -split ',' | Where-Object -FilterScript { $_ })
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ })
$modes = @($Mode -split ',' | Where-Object -FilterScript { $_ })
if ($modes | Where-Object -FilterScript { $_ -notin 'Elevated', 'Basic' }) { throw "-Mode takes Elevated, Basic, or both, separated by a comma." }
$localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ })
$behaviorFiles = 'Access', 'Audit', 'DriveRoot', 'FileHash', 'Inheritance', 'ItemCmdlets', 'Links', 'ObjectApis', 'OutputTypes', 'Owner', 'PathErrors',
'PermissionScopes', 'PipelineControl', 'Privileges', 'Remove-Item2', 'SecurityDescriptor', 'SecurityDescriptorSets', 'TestHelpers'
$null = New-Item -ItemType Directory -Path $OutputRoot -Force
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite.log"
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog }
$stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-$Label"
if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force }
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'Tests') -Force
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Force
foreach ($name in $behaviorFiles) {
$file = if ($name -eq 'TestHelpers') { 'TestHelpers.Tests.ps1' } else { "$name.Tests.ps1" }
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath "Tests\$file") -Destination (Join-Path -Path $stage -ChildPath 'Tests')
}
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath 'Tests\TestHelpers.psm1') -Destination (Join-Path -Path $stage -ChildPath 'Tests')
Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Recurse
Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-LocalSuite.ps1') -Destination $stage
if ('Basic' -in $modes) {
$wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw
$class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value
if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' }
$helperHead = @'
[CmdletBinding()]
param (
[Parameter(Mandatory)] [string] $Executable,
[Parameter(Mandatory)] [string] $Root,
[Parameter(Mandatory)] [string] $Label,
[Parameter(Mandatory)] [string] $OutDir,
[string] $PesterModulePath
)
# Generated by Run-MatrixLocalSuite.ps1: starts Invoke-LocalSuite.ps1 with the token of a basic user (SAFER level Normal User) through the
# class of .github\scripts\Invoke-TestsAsBasicUser.ps1, waits for it, and writes its exit code.
$ErrorActionPreference = 'Stop'
'@
$helperTail = @'
$runnerArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Label {1} -Root "{2}" -OutDir "{3}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1'), $Label, $Root, $OutDir
if ($PesterModulePath) { $runnerArguments += ' -PesterModulePath "{0}"' -f $PesterModulePath }
$console = Join-Path -Path $OutDir -ChildPath ('{0}.console.txt' -f $Label)
$commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $runnerArguments, $console
$exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $Root)
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath ('{0}.basic.exit' -f $Label)) -Value $exitCode
exit $exitCode
'@
$helperText = $helperHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $helperTail
Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-BasicUserProcess.ps1') -Value $helperText -Encoding UTF8
}
$dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash
$testHashes = Get-ChildItem -LiteralPath (Join-Path -Path $stage -ChildPath 'Tests') -File | Sort-Object -Property Name | ForEach-Object -Process { '{0}={1}' -f $_.Name, (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.Substring(0, 12) }
($stamp -f [DateTime]::UtcNow) + " START localsuite-$Label machines=$($targets -join ',') editions=$($editions -join ',') dll=$dllHash" | Set-Content -LiteralPath $sequenceLog
Write-Sequence ('staged test files: {0}' -f ($testHashes -join ' '))
$summaryRows = New-Object -TypeName 'System.Collections.Generic.List[object]'
if ($targets | Where-Object -FilterScript { $_ -ne 'LOCAL' }) {
Import-Module -Name AutomatedLab -ErrorAction Stop
Import-Lab -Name $LabName -NoValidation -NoDisplay
}
foreach ($name in $targets) {
$cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$name"
$null = New-Item -ItemType Directory -Path $cellFolder -Force
Write-Sequence "machine $name START"
$session = $null
$runCredential = $null
try {
if ($name -eq 'LOCAL') {
$root = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-run-$Label"
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
Copy-Item -LiteralPath $stage -Destination $root -Recurse
}
else {
$sessionParameters = @{ ComputerName = $name }
if ($name -in $localCredential) { $sessionParameters.UseLocalCredential = $true }
$session = New-LabPSSession @sessionParameters
# The account for the scheduled tasks: the lab account of the machine, or its local installation account. AutomatedLab keeps the
# installation password in clear text in the lab file; here it stays in memory.
$machineDefinition = Get-LabVM -ComputerName $name
$runCredential = if ($name -in $localCredential) {
New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $name, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force)
}
else {
$machineDefinition.GetCredential((Get-Lab))
}
$root = 'C:\NtfsMatrixLocal\' + $Label
Invoke-Command -Session $session -ArgumentList $root -ScriptBlock {
param ($Path)
if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force }
$null = New-Item -ItemType Directory -Path $Path -Force
}
Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force
Write-Sequence "machine $name stage copied to $root"
}
foreach ($modeName in $modes) {
foreach ($editionName in $editions) {
$runLabel = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant()
$arguments = @{ Root = $root; Edition = $editionName; RunLabel = $runLabel; Pester = $(if ($name -eq 'LOCAL') { $PesterModulePath } else { '' }); Mode = $modeName }
$start = {
param ($Root, $Edition, $RunLabel, $Pester, $ModeName, $Credential)
$exe = if ($Edition -eq 'Desktop') { Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' } else { Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' }
$out = Join-Path -Path $Root -ChildPath 'Results'
$null = New-Item -ItemType Directory -Path $out -Force
if ($ModeName -eq 'Basic') {
# The basic-user token writes the results, so the account of the run needs Modify on the folder.
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f [Security.Principal.WindowsIdentity]::GetCurrent().Name)
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Start-BasicUserProcess.ps1')),
'-Executable', ('"{0}"' -f $exe), '-Root', ('"{0}"' -f $Root), '-Label', $RunLabel, '-OutDir', ('"{0}"' -f $out))
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) }
$exe = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
}
else {
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1')),
'-Label', $RunLabel, '-Root', ('"{0}"' -f $Root), '-OutDir', ('"{0}"' -f $out))
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) }
}
if ($Credential) {
# A process started from a remoting session inherits a token with every privilege enabled and no credentials of its own,
# which the tests don't expect (eight of them fail). A scheduled task with a batch logon at the highest run level gets
# the token of an elevated interactive session: privileges present but disabled, and the credentials of the account.
$taskName = 'NtfsMatrixLocal-' + $RunLabel
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute $exe -Argument ($list -join ' ')
$null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel Highest -User $Credential.UserName -Password $Credential.GetNetworkCredential().Password
Start-ScheduledTask -TaskName $taskName
$taskName
}
else {
(Start-Process -FilePath $exe -ArgumentList $list -PassThru -WindowStyle Hidden).Id
}
}
$isRunning = {
param ($Handle)
if ($Handle -is [string]) { $task = Get-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue; [bool] ($task -and $task.State -eq 'Running') }
else { [bool] (Get-Process -Id $Handle -ErrorAction SilentlyContinue) }
}
$stop = {
param ($Handle)
if ($Handle -is [string]) { Stop-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue } else { Stop-Process -Id $Handle -Force -ErrorAction SilentlyContinue }
}
$finish = {
param ($Handle)
if ($Handle -is [string]) {
$result = (Get-ScheduledTaskInfo -TaskName $Handle -ErrorAction SilentlyContinue).LastTaskResult
Unregister-ScheduledTask -TaskName $Handle -Confirm:$false -ErrorAction SilentlyContinue
"task result $result"
}
}
$startArguments = $arguments.Root, $arguments.Edition, $arguments.RunLabel, $arguments.Pester, $arguments.Mode, $runCredential
$handle = if ($session) { Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $startArguments } else { & $start @startArguments }
Write-Sequence "machine $name $modeName $editionName started ($handle)"
$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes)
do {
Start-Sleep -Seconds 20
$alive = if ($session) { Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $handle } else { & $isRunning $handle }
} while ($alive -and [DateTime]::UtcNow -lt $deadline)
if ($alive) {
if ($session) { Invoke-Command -Session $session -ScriptBlock $stop -ArgumentList $handle } else { & $stop $handle }
Write-Sequence "machine $name $modeName $editionName TIMED OUT after $TimeoutMinutes minutes; stopped"
}
$outcome = if ($session) { Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $handle } else { & $finish $handle }
Write-Sequence "machine $name $modeName $editionName finished $outcome"
}
}
if ($session) { Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force }
else { Copy-Item -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force }
foreach ($modeName in $modes) {
foreach ($editionName in $editions) {
$expected = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant()
if (-not (Test-Path -LiteralPath (Join-Path -Path $cellFolder -ChildPath "$expected.json"))) {
Write-Sequence "machine ${name}: NO RESULT FILE for $expected"
$summaryRows.Add([pscustomobject]@{ Machine = $name; Edition = $editionName; Os = ''; PowerShell = ''; Elevated = ''; Result = 'NoResult'; Passed = ''; Failed = ''; Skipped = ''; Total = ''; Seconds = '' })
}
}
}
foreach ($json in Get-ChildItem -LiteralPath $cellFolder -Filter '*.json') {
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json
$summaryRows.Add([pscustomobject]@{
Machine = $name; Edition = $summary.Edition; Os = $summary.Os; PowerShell = $summary.PowerShell; Elevated = $summary.Elevated; Result = $summary.Result
Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds
})
Write-Sequence ('machine {0} {1}: {2} passed={3} failed={4} skipped={5} total={6} elevated={7} os={8}' -f $name, $summary.Edition, $summary.Result, $summary.Passed, $summary.Failed, $summary.Skipped, $summary.Total, $summary.Elevated, $summary.Os)
}
}
catch {
Write-Sequence "machine $name FAILED: $_"
}
finally {
if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue }
}
Write-Sequence "machine $name END"
}
$summaryRows | Export-Csv -LiteralPath (Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite-summary.csv") -NoTypeInformation
Write-Sequence "localsuite-$Label-DONE"
exit 0

104
Tests/Lab/Acceptance/Run-MatrixSequence.ps1

@ -0,0 +1,104 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label,
[Parameter(Mandatory)] [string] $FileServer,
[string] $Client = 'OSWin11',
[string] $ModulePath,
[string] $Version,
[string] $Edition = 'Desktop,Core',
[Parameter(Mandatory)] [string] $OutputRoot,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string] $DomainController = 'OSDC1',
[string] $LabFolder,
[string] $Machines = 'OSFile19,OSFile22,OSFile25,OSWin11'
)
# One detached sequence of the operating-system matrix (Decision 24) in Windows PowerShell 5.1 on the Hyper-V host. For each cell, a file
# server with the client, it runs: readiness of every machine, the unmodified controller of the repository for the source (a build
# folder or an exact Gallery version) in both editions, the validation of every role from the result files, a snapshot of the fixture
# SIDs, the removal of the fixture, and an independent check of the end state. An infrastructure failure (no summary of the controller)
# stops the later cells; failing tests don't. Case 9 (accounts of other forests) needs trusts that this lab doesn't have, so the cells
# run with -ForeignDomainController @(). Nothing secret is written: the controller keeps the passwords in memory.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File.
if (-not $LabFolder) { $LabFolder = Split-Path -Path $PSScriptRoot -Parent }
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]'
$kit = $PSScriptRoot
$cells = @($FileServer -split ',' | Where-Object -FilterScript { $_ })
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ })
$allMachines = @($Machines -split ',' | Where-Object -FilterScript { $_ })
if ([bool] $ModulePath -eq [bool] $Version) { throw 'Pass exactly one of -ModulePath and -Version.' }
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-sequence.log"
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog }
$source = if ($ModulePath) { "module=$ModulePath dll=$((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash)" } else { "version=$Version" }
($stamp -f [DateTime]::UtcNow) + " START matrix-sequence-$Label client=$Client cells=$($cells -join ',') editions=$($editions -join ',') $source" | Set-Content -LiteralPath $sequenceLog
Write-Sequence ('controller blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1')) -join ''))
Write-Sequence ('live tests blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'NTFSSecurity.Live.Tests.ps1')) -join ''))
$controller = Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1'
$infrastructureFailed = $false
foreach ($fileServerName in $cells) {
if ($infrastructureFailed) { Write-Sequence "cell $fileServerName SKIPPED after an infrastructure failure"; continue }
$cell = Join-Path -Path $OutputRoot -ChildPath "$Label-$fileServerName"
New-Item -ItemType Directory -Path $cell -Force | Out-Null
$runLog = Join-Path -Path $cell -ChildPath 'run.log'
$ran = $false
Write-Sequence "cell $fileServerName START (client $Client)"
try {
$readinessLog = Join-Path -Path $cell -ChildPath 'readiness.log'
& (Join-Path -Path $kit -ChildPath 'Test-MatrixReadiness.ps1') -LabName $LabName -DomainController @($DomainController) -Member @($allMachines) -OutFile $readinessLog
$readiness = Get-Content -LiteralPath $readinessLog -Raw
$notReady = 'wsman=failed|secure channel False|PowerShell 7 missing|Core missing|Pester Desktop (?!5\.7\.1)|=False|kerberos: .*Error'
$problem = [regex]::Match($readiness, $notReady).Value
if ($readiness -notmatch 'matrix-readiness-DONE' -or $problem) { throw "Readiness of cell $fileServerName failed ('$problem'); see $readinessLog" }
Write-Sequence "cell $fileServerName readiness ok"
$arguments = @{
LabName = $LabName; DomainController = $DomainController; FileServer = $fileServerName; Client = $Client
ForeignDomainController = @(); Edition = $editions; OutputPath = $cell; Confirm = $false
}
if ($ModulePath) { $arguments.Version = @(); $arguments.ModulePath = $ModulePath } else { $arguments.Version = @($Version) }
($stamp -f [DateTime]::UtcNow) + " START controller cell=$fileServerName" | Set-Content -LiteralPath $runLog
$ran = $true
& { & $controller @arguments } *>&1 | ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath $runLog -Append -Encoding utf8 -Width 500
$summaryPath = Get-ChildItem -LiteralPath (Join-Path -Path $cell -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue |
Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName
if (-not $summaryPath) { throw "The controller wrote no Summary.json for cell $fileServerName; see $runLog" }
Write-Sequence "cell $fileServerName controller done: $summaryPath"
$validationLog = Join-Path -Path $cell -ChildPath 'validation.log'
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path -Path $kit -ChildPath 'Validate-LabResults.ps1') -ResultsFolder (Split-Path -Path $summaryPath -Parent) -OutputPrefix (Join-Path -Path $cell -ChildPath "$Label-$fileServerName") -Edition ($editions -join ',') -Expect Candidate *>&1 |
Out-File -LiteralPath $validationLog -Encoding utf8 -Width 400
Write-Sequence "cell $fileServerName validation exit code $LASTEXITCODE (see validation.log)"
}
catch {
Write-Sequence "cell $fileServerName FAILED before the cleanup: $_"
if (-not $ran) { Write-Sequence "cell ${fileServerName}: the controller did not start" }
$infrastructureFailed = $true
}
try {
$sidFile = Join-Path -Path $cell -ChildPath 'fixture-sids.json'
$common = @{ LabName = $LabName; DomainController = @($DomainController); Machine = @($allMachines) }
if ($ran) {
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Snapshot -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-1-snapshot.log') @common
& { & $controller -RemoveFixture -LabName $LabName -DomainController $DomainController -FileServer $fileServerName -Client $Client -ForeignDomainController @() -Confirm:$false } *>&1 |
ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-2-remove.log') -Encoding utf8 -Width 500
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Verify -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') @common
$verify = Get-Content -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') -Raw
$clean = ($verify -match 'matrix-cleanup-Verify-DONE') -and ($verify -notmatch 'OU NTFSSecurityLive: True') -and ($verify -notmatch 'accounts: NtfsLive') -and
($verify -notmatch 'share=True|C:\\NTFSSecurityLive=True|C:\\NTFSSecurityLab=True|NtfsLiveLocal=True') -and ($verify -notmatch 'profiles=[1-9]') -and ($verify -notmatch ': [1-9]\d* fixture member')
Write-Sequence ("cell $fileServerName cleanup verdict from the verify log: {0}" -f $(if ($clean) { 'CLEAN' } else { 'DIRTY (read cleanup-3-verify.log)' }))
}
}
catch {
Write-Sequence "cell $fileServerName cleanup FAILED: $_"
}
Write-Sequence "cell $fileServerName END"
}
Write-Sequence "matrix-sequence-$Label-DONE"
exit 0

112
Tests/Lab/Acceptance/Test-MatrixCleanup.ps1

@ -0,0 +1,112 @@
[CmdletBinding()]
param (
[Parameter(Mandatory)] [ValidateSet('Snapshot', 'Verify', 'Repair')] [string] $Mode,
[Parameter(Mandatory)] [string] $SidFile,
[Parameter(Mandatory)] [string] $OutFile,
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string[]] $DomainController = @('OSDC1'),
[string[]] $Machine = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11')
)
# Independent end-state check of the fixture of Invoke-NTFSSecurityLabTest.ps1 in a lab (Windows PowerShell 5.1, on the host). Snapshot
# records the SIDs of the NtfsLive* accounts while the fixture exists. Verify reads the domains and every machine again, and reports
# the organizational unit, the accounts, the share, the folders, the local group, the memberships of Administrators, Access Control
# Assistance Operators, and Remote Management Users, and the profiles of those SIDs. The result is judged from this log, never from
# the wrapper of the controller or a global error count. Repair is for a run whose removal failed: with the SIDs of the snapshot, it
# removes what that run left on the machines (the memberships, also of orphaned SIDs, which net localgroup deletes by SID; the share; the
# local group; the folders) and then reports like Verify.
& {
$ErrorActionPreference = 'Stop'
# -File passes an array as one string, so a list may arrive as 'A,B'.
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
$Machine = @($Machine | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-cleanup-{1} lab={2}' -f [DateTime]::UtcNow, $Mode, $LabName
Import-Lab -Name $LabName -NoValidation -NoDisplay
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' }
$directoryScript = {
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$unit = Get-ADOrganizationalUnit -LDAPFilter '(ou=NTFSSecurityLive)' -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator
[pscustomobject]@{
Domain = $domain.DNSRoot
Unit = [bool] $unit
Sids = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsLive*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator -Properties objectSid, sAMAccountName |
ForEach-Object -Process { '{0}={1}' -f $_.sAMAccountName, $_.objectSid.Value })
}
}
$directory = @(foreach ($name in $DomainController) { Invoke-LabCommand -ComputerName $name -ActivityName "Read the fixture of $name" -ScriptBlock $directoryScript @labCommand })
foreach ($state in $directory) {
'{0,-14} OU NTFSSecurityLive: {1,-5} NtfsLive* accounts: {2}' -f $state.Domain, $state.Unit, ($(if ($state.Sids) { $state.Sids -join ', ' } else { 'none' }))
}
if ($Mode -eq 'Snapshot') {
$sids = @($directory | ForEach-Object -Process { $_.Sids } | ForEach-Object -Process { ($_ -split '=', 2)[1] })
ConvertTo-Json -InputObject $sids | Set-Content -LiteralPath $SidFile -Encoding utf8
"saved $($sids.Count) SIDs to $SidFile"
}
else {
$sids = [string[]] (Get-Content -LiteralPath $SidFile -Raw | ConvertFrom-Json)
"checking $($sids.Count) SIDs of the snapshot"
$machineScript = {
param ($Sid)
# net localgroup lists an orphaned SID, which Get-LocalGroupMember in Windows PowerShell 5.1 fails on and skips.
$groups = foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') {
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', ''
$members = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() })
$hits = @($members | Where-Object -FilterScript { $_ -match 'NtfsLive' -or $_ -in $Sid })
'{0}: {1} fixture member(s)' -f $groupSid, $hits.Count
}
[pscustomobject]@{
Share = [bool] (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue)
ShareRoot = Test-Path -LiteralPath 'C:\NTFSSecurityLive'
Payload = Test-Path -LiteralPath 'C:\NTFSSecurityLab'
LocalGroup = [bool] (Get-LocalGroup -Name 'NtfsLiveLocal' -ErrorAction SilentlyContinue)
Groups = $groups -join '; '
Profiles = @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid }).Count
}
}
foreach ($name in $Machine) {
if ($Mode -eq 'Repair') {
$repairScript = {
param ($Sid)
$messages = New-Object -TypeName 'System.Collections.Generic.List[string]'
foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') {
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', ''
$named = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() } | Where-Object -FilterScript { $_ -match 'NtfsLive' })
foreach ($member in @($Sid) + $named) { $null = & net.exe localgroup $groupName $member /delete 2>&1 }
}
if (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue) { Remove-SmbShare -Name 'NTFSSecurityLive' -Force }
$null = & net.exe localgroup 'NtfsLiveLocal' /delete 2>&1
foreach ($path in 'C:\NTFSSecurityLive', 'C:\NTFSSecurityLab') {
$command = '$errors = @(); Remove-Item -LiteralPath ''__PATH__'' -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $path)
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))
$attempt = 0
while ((Test-Path -LiteralPath $path) -and $attempt -lt 6) {
$attempt++
if ($attempt -gt 1) { Start-Sleep -Seconds 5 }
$null = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1
}
$messages.Add(('{0}: present after {1} attempt(s): {2}' -f $path, $attempt, (Test-Path -LiteralPath $path)))
}
$messages
}
foreach ($message in @(Invoke-LabCommand -ComputerName $name -ActivityName "Repair $name" -ScriptBlock $repairScript -ArgumentList (, $sids) @labCommand)) {
'{0,-9} repair: {1}' -f $name, $message
}
}
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Check $name" -ScriptBlock $machineScript -ArgumentList (, $sids) @labCommand
'{0,-9} share={1} C:\NTFSSecurityLive={2} C:\NTFSSecurityLab={3} NtfsLiveLocal={4} profiles={5}' -f $name, $state.Share, $state.ShareRoot, $state.Payload, $state.LocalGroup, $state.Profiles
' {0}' -f $state.Groups
}
}
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-cleanup-{1}-DONE' -f [DateTime]::UtcNow, $Mode
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400

79
Tests/Lab/Acceptance/Test-MatrixReadiness.ps1

@ -0,0 +1,79 @@
[CmdletBinding()]
param (
[string] $LabName = 'NtfsSecurityOsMatrixLab',
[string[]] $DomainController = @('OSDC1'),
[string[]] $Member = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11'),
[Parameter(Mandatory)] [string] $OutFile
)
# Readiness and identity of the machines of an AutomatedLab lab for the live tests of NTFSSecurity, in Windows PowerShell 5.1 on the
# Hyper-V host. It proves what the tests need, not that a VM runs: authenticated WinRM through AutomatedLab, the operating system
# build, the domain, the clock against the host, LDAP and a Kerberos ticket (a domain controller), or the secure channel, the domain
# controller locator and a service ticket for a peer (a member), the PowerShell 7 and Pester payloads, and the ports of SMB, RPC, and
# WinRM from the host. Nothing is changed in the lab. Passwords are never read or printed.
& {
$ErrorActionPreference = 'Stop'
# -File passes an array as one string, so a list may arrive as 'A,B'.
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-readiness lab={1}' -f [DateTime]::UtcNow, $LabName
Import-Lab -Name $LabName -NoValidation -NoDisplay
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' }
$everyMachine = @($DomainController) + @($Member)
$peerByMember = @{}
foreach ($name in $Member) { $peerByMember[$name] = @($Member | Where-Object -FilterScript { $_ -ne $name })[0] }
foreach ($name in $everyMachine) {
$address = (Get-LabVM -ComputerName $name).IpV4Address
$wsman = try { $null = Test-WSMan -ComputerName $address -ErrorAction Stop; 'ok' } catch { "failed: $($_.Exception.Message)" }
$ports = foreach ($port in 135, 445, 5985) {
$client = New-Object -TypeName 'System.Net.Sockets.TcpClient'
try { $open = $client.ConnectAsync($address, $port).Wait(3000) } catch { $open = $false } finally { $client.Dispose() }
'{0}={1}' -f $port, $open
}
$hostUtc = [DateTime]::UtcNow
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Readiness of $name" -ScriptBlock {
param ([bool] $IsDomainController, [string] $Peer)
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$computer = Get-CimInstance -ClassName Win32_ComputerSystem
$version = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
$dotNet = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -ErrorAction SilentlyContinue).Release
$pwshPath = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe'
$result = [ordered]@{
Os = '{0} {1}.{2}' -f $os.Caption, $os.Version, $version.UBR
ProductType = $os.ProductType
Edition = $version.EditionID
Domain = $computer.Domain
Utc = [DateTime]::UtcNow
DotNet = $dotNet
WindowsPowerShell = $PSVersionTable.PSVersion.ToString()
PowerShell7 = $(if (Test-Path -LiteralPath $pwshPath) { (Get-Item -LiteralPath $pwshPath).VersionInfo.ProductVersion } else { 'missing' })
PesterDesktop = $(@(Get-Module -Name Pester -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1 | ForEach-Object -Process { $_.Version.ToString() }) -join '')
PesterCore = $(if (Test-Path -LiteralPath (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules\Pester\5.7.1\Pester.psd1')) { '5.7.1' } else { 'missing' })
Ldap = ''
Channel = ''
Kerberos = ''
}
if ($IsDomainController) {
$rootDse = [adsi]'LDAP://RootDSE'
$result.Ldap = 'RootDSE {0}, synchronized {1}' -f $rootDse.dnsHostName.Value, $rootDse.isSynchronized.Value
$result.Kerberos = (& klist.exe get "krbtgt/$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: krbtgt' | Select-Object -First 1).Line
}
else {
$result.Ldap = (& nltest.exe "/dsgetdc:$($computer.Domain)" 2>&1 | Select-String -Pattern '^\s*DC: |ERROR' | Select-Object -First 1).Line
$result.Channel = 'secure channel {0}' -f (Test-ComputerSecureChannel)
$result.Kerberos = (& klist.exe get "host/$Peer.$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: host' | Select-Object -First 1).Line
}
[pscustomobject] $result
} -ArgumentList ($name -in $DomainController), $peerByMember[$name] @labCommand
$skew = [Math]::Round(($state.Utc - $hostUtc).TotalSeconds, 1)
'{0,-9} wsman={1} ports({2}) os={3} type={4} edition={5} domain={6} skew={7}s' -f $name, $wsman, ($ports -join ' '), $state.Os, $state.ProductType, $state.Edition, $state.Domain, $skew
' .NET release={0}; Windows PowerShell {1}; PowerShell 7 {2}; Pester Desktop {3}, Core {4}' -f $state.DotNet, $state.WindowsPowerShell, $state.PowerShell7, $state.PesterDesktop, $state.PesterCore
' ldap: {0}' -f ("$($state.Ldap)".Trim())
if ($state.Channel) { ' {0}' -f $state.Channel }
' kerberos: {0}' -f ("$($state.Kerberos)".Trim())
}
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-readiness-DONE' -f [DateTime]::UtcNow
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400
Loading…
Cancel
Save