From 9344ff7634f5e157a4612db6f030ef5a9dbcdd22 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Sat, 10 Oct 2026 06:08:19 +0000 Subject: [PATCH] test(lab): check the probe residue, read the cell timeline, and model the stale managers Test-MatrixCleanup.ps1 now counts and repairs the probe folders (C:\NtfsProbeRecreation and C:\NtfsProbeModules), the local NtfsProbe* users with their profiles, and the NtfsProbe* objects of the directory. The scripts of the matrix default to the client OSWin11E. Export-CellTimeline.ps1 writes one row for every cell, edition, and Admin role: the module, the account and its relative ID, the times, and the three effective-access tests. Test-StaleAuthzModel.ps1 replays such a timeline against a model of the failures: the fit, a listing of the runs, the cells of a controller that reuses the account name, and a permutation test. The comment in the controller says what the replay showed. The code of the controller is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Lab/Acceptance/Export-CellTimeline.ps1 | 112 +++++++++++++++ Tests/Lab/Acceptance/Run-MatrixSequence.ps1 | 4 +- Tests/Lab/Acceptance/Test-MatrixCleanup.ps1 | 48 +++++-- Tests/Lab/Acceptance/Test-MatrixReadiness.ps1 | 2 +- Tests/Lab/Acceptance/Test-StaleAuthzModel.ps1 | 129 ++++++++++++++++++ Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 | 10 +- 6 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 Tests/Lab/Acceptance/Export-CellTimeline.ps1 create mode 100644 Tests/Lab/Acceptance/Test-StaleAuthzModel.ps1 diff --git a/Tests/Lab/Acceptance/Export-CellTimeline.ps1 b/Tests/Lab/Acceptance/Export-CellTimeline.ps1 new file mode 100644 index 0000000..2456ec4 --- /dev/null +++ b/Tests/Lab/Acceptance/Export-CellTimeline.ps1 @@ -0,0 +1,112 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $MatrixRoot, + [Parameter(Mandatory)] [string[]] $Label, + [Parameter(Mandatory)] [string] $OutputPath +) + +# The timeline of the cells of the operating-system matrix (Decision 24): for each cell and edition, in the order in which the cells ran, the module under +# test, the account of case 3 (its name and its relative ID, which tells two accounts of one name apart within a domain), when the previous fixture was +# removed, when the accounts were created, when the Admin role started, the minutes between +# them, and the three effective-access tests of case 3 in the Admin role (result, milliseconds, and the rights that a failing test received). A failing +# effective-access test of the Admin role is easy to blame on the module or on the environment; this table puts it beside the module, the position of +# the cell in the sequence, and the age of the accounts. The times come from the logs of Run-MatrixSequence.ps1 and of the controller (UTC). It reads +# files only; Windows PowerShell 5.1 or PowerShell 7. +$ErrorActionPreference = 'Stop' +# -File passes an array as one string, so a list may arrive as 'A,B'. +$Label = @($Label | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) +function Get-LogTime { + param ([string[]] $Lines, [string] $Pattern) + $line = $Lines | Where-Object -FilterScript { $_ -match $Pattern } | Select-Object -First 1 + if ($line -and $line -match '^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)Z?\]') { + [DateTime]::ParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) + } +} + +$cells = New-Object -TypeName 'System.Collections.Generic.List[object]' +foreach ($name in $Label) { + $sequenceLog = Join-Path -Path $MatrixRoot -ChildPath ('{0}-sequence.log' -f $name) + if (-not (Test-Path -LiteralPath $sequenceLog)) { continue } + $candidate = if ((Get-Content -LiteralPath $sequenceLog -TotalCount 1) -match 'candidate-(\w+)') { $Matches[1] } else { '' } + foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter ('{0}-*' -f $name))) { + $runLog = Join-Path -Path $folder.FullName -ChildPath 'run.log' + if (-not (Test-Path -LiteralPath $runLog)) { continue } + $run = @(Get-Content -LiteralPath $runLog) + $removeLog = Join-Path -Path $folder.FullName -ChildPath 'cleanup-2-remove.log' + $removed = if (Test-Path -LiteralPath $removeLog) { Get-LogTime -Lines @(Get-Content -LiteralPath $removeLog) -Pattern 'Removed the live tests' } + $configuration = Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Recurse -Filter 'local-*.json' -ErrorAction SilentlyContinue | + Where-Object -FilterScript { $_.Name -match '-\d{14}\.json$' } | Select-Object -First 1 + $subject = if ($configuration) { (Get-Content -LiteralPath $configuration.FullName -Raw | ConvertFrom-Json).Accounts.Subject } else { $null } + $cells.Add([pscustomobject]@{ + Run = $name + Candidate = $candidate + FileServer = $folder.Name.Substring($name.Length + 1) + Folder = $folder.FullName + Lines = $run + Subject = $(if ($subject) { $subject.Name } else { '' }) + SubjectRid = $(if ($subject) { ($subject.Sid -split '-')[-1] } else { '' }) + Started = Get-LogTime -Lines $run -Pattern 'START live tests' + Created = Get-LogTime -Lines $run -Pattern 'Preparing the accounts' + Removed = $removed + }) + } +} + +$rows = New-Object -TypeName 'System.Collections.Generic.List[object]' +$previous = $null +foreach ($cell in ($cells | Sort-Object -Property Started)) { + foreach ($edition in 'Desktop', 'Core') { + $adminStart = Get-LogTime -Lines $cell.Lines -Pattern "local-$edition-\d+: role Admin$" + if (-not $adminStart) { continue } + $tests = @{ T1 = ''; T2 = ''; T3 = '' } + $failures = 0 + $adminLog = Get-ChildItem -LiteralPath (Join-Path -Path $cell.Folder -ChildPath 'Results') -Recurse -Filter "local-$edition-*-Admin.log" | Select-Object -First 1 + if ($adminLog) { + $text = @(Get-Content -LiteralPath $adminLog.FullName) + $start = ($text | Select-String -Pattern 'Describing Get-NTFSEffectiveAccess for a domain account on a share folder' | Select-Object -First 1).LineNumber + $seen = 0 + for ($index = $start; $start -and $index -lt $text.Count -and $seen -lt 3; $index++) { + if ($text[$index] -notmatch '^\s+\[([+-])\] (.+?) (\d+(?:\.\d+)?m?s) \(') { continue } + $outcome = $Matches[1]; $title = $Matches[2]; $duration = $Matches[3] + $received = '' + if ($outcome -eq '-') { + $failures++ + for ($next = $index + 1; $next -lt [Math]::Min($index + 12, $text.Count); $next++) { + if ($text[$next] -match "But was:\s+'(0x\w+)'") { $received = ' ' + $Matches[1]; break } + if ($text[$next] -match 'Expected \$null or empty') { $received = ' errors'; break } + } + } + + $key = if ($title -match 'with -ServerName, without') { 'T1' } elseif ($title -match 'without -ServerName') { 'T2' } else { 'T3' } + $tests[$key] = '{0} {1}{2}' -f $(if ($outcome -eq '+') { 'pass' } else { 'FAIL' }), $duration, $received + $seen++ + } + } + + $hasPrevious = $null -ne $previous -and $null -ne $previous.Removed + $rows.Add([pscustomobject][ordered]@{ + Run = $cell.Run + Candidate = $cell.Candidate + FileServer = $cell.FileServer + Edition = $edition + Subject = $cell.Subject + SubjectRid = $cell.SubjectRid + SameSubjectAsPreviousCell = [bool] ($null -ne $previous -and $cell.Subject -and $previous.Subject -eq $cell.Subject) + PreviousRemoval = $(if ($hasPrevious) { '{0:yyyy-MM-dd HH:mm:ss}' -f $previous.Removed }) + AccountsCreated = '{0:yyyy-MM-dd HH:mm:ss}' -f $cell.Created + AdminRoleStarted = '{0:yyyy-MM-dd HH:mm:ss}' -f $adminStart + MinutesRemovalToCreation = $(if ($hasPrevious) { '{0:N1}' -f ($cell.Created - $previous.Removed).TotalMinutes }) + MinutesCreationToAdmin = '{0:N1}' -f ($adminStart - $cell.Created).TotalMinutes + MinutesRemovalToAdmin = $(if ($hasPrevious) { '{0:N1}' -f ($adminStart - $previous.Removed).TotalMinutes }) + T1ServerNameFileServer = $tests.T1 + T2DefaultServerName = $tests.T2 + T3UnreachableServerName = $tests.T3 + EffectiveAccessFailures = $failures + }) + } + + $previous = [pscustomobject]@{ Removed = $cell.Removed; Subject = $cell.Subject } +} + +$rows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding ASCII +'{0} rows for {1} cells written to {2}' -f $rows.Count, $cells.Count, $OutputPath diff --git a/Tests/Lab/Acceptance/Run-MatrixSequence.ps1 b/Tests/Lab/Acceptance/Run-MatrixSequence.ps1 index 17124a4..f6a27d6 100644 --- a/Tests/Lab/Acceptance/Run-MatrixSequence.ps1 +++ b/Tests/Lab/Acceptance/Run-MatrixSequence.ps1 @@ -2,7 +2,7 @@ param ( [Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, [Parameter(Mandatory)] [string] $FileServer, - [string] $Client = 'OSWin11', + [string] $Client = 'OSWin11E', [string] $ModulePath, [string] $Version, [string] $Edition = 'Desktop,Core', @@ -10,7 +10,7 @@ param ( [string] $LabName = 'NtfsSecurityOsMatrixLab', [string] $DomainController = 'OSDC1', [string] $LabFolder, - [string] $Machines = 'OSFile19,OSFile22,OSFile25,OSWin11' + [string] $Machines = 'OSFile19,OSFile22,OSFile25,OSWin11E' ) # 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 diff --git a/Tests/Lab/Acceptance/Test-MatrixCleanup.ps1 b/Tests/Lab/Acceptance/Test-MatrixCleanup.ps1 index a74f40f..4ff48d4 100644 --- a/Tests/Lab/Acceptance/Test-MatrixCleanup.ps1 +++ b/Tests/Lab/Acceptance/Test-MatrixCleanup.ps1 @@ -5,17 +5,18 @@ param ( [Parameter(Mandatory)] [string] $OutFile, [string] $LabName = 'NtfsSecurityOsMatrixLab', [string[]] $DomainController = @('OSDC1'), - [string[]] $Machine = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11') + [string[]] $Machine = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E') ) # 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, and what the suite runs and the probes of the kit leave -# behind (scheduled tasks, items in the stage folders, standard users, probe accounts of the domain). 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; the stage folders and scheduled tasks of the kit) and then reports like Verify. +# behind (scheduled tasks, items in the stage folders, the folders of the account probe, standard users, probe accounts of the domain). 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 what the kit leaves (the items in the stage folders, the folders of the +# account probe, the scheduled tasks NtfsMatrix*, and the standard users and domain accounts NtfsProbe*), and then reports like Verify. & { $ErrorActionPreference = 'Stop' # -File passes an array as one string, so a list may arrive as 'A,B'. @@ -24,6 +25,23 @@ param ( '[{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' } + if ($Mode -eq 'Repair') { + # The accounts that the probes of the kit create in the domain, by their prefix; this runs before the directory is read, so that the report shows the result. + $repairDirectoryScript = { + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $objects = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsProbe*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator) + foreach ($object in $objects) { Remove-ADObject -Identity $object -Recursive -Confirm:$false -Server $domain.PDCEmulator } + '{0}: removed {1} account(s) named NtfsProbe*' -f $domain.DNSRoot, $objects.Count + } + + foreach ($name in $DomainController) { + foreach ($message in @(Invoke-LabCommand -ComputerName $name -ActivityName "Repair the directory of $name" -ScriptBlock $repairDirectoryScript @labCommand)) { + '{0,-9} repair: {1}' -f $name, $message + } + } + } + $directoryScript = { Import-Module -Name ActiveDirectory $domain = Get-ADDomain @@ -67,9 +85,11 @@ param ( LocalGroup = [bool] (Get-LocalGroup -Name 'NtfsLiveLocal' -ErrorAction SilentlyContinue) Groups = $groups -join '; ' Profiles = @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid }).Count - # What the suite runs and the probes of the kit leave behind: scheduled tasks, items in the stage folders, and standard users + # What the suite runs and the probes of the kit leave behind: scheduled tasks, items in the stage folders, the folders of the + # account probe, and standard users Tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.TaskName -like 'NtfsMatrix*' }).Count - Stages = @('C:\NtfsMatrixLocal', 'C:\NtfsMatrixProbe' | Where-Object -FilterScript { Test-Path -LiteralPath $_ } | ForEach-Object -Process { Get-ChildItem -LiteralPath $_ -Force -ErrorAction SilentlyContinue }).Count + Stages = @('C:\NtfsMatrixLocal', 'C:\NtfsMatrixProbe' | Where-Object -FilterScript { Test-Path -LiteralPath $_ } | ForEach-Object -Process { Get-ChildItem -LiteralPath $_ -Force -ErrorAction SilentlyContinue }).Count + + @('C:\NtfsProbeRecreation', 'C:\NtfsProbeModules' | Where-Object -FilterScript { Test-Path -LiteralPath $_ }).Count Users = @(Get-LocalUser -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.Name -like 'NtfsProbe*' }).Count } } @@ -100,13 +120,23 @@ param ( $messages.Add(('{0}: present after {1} attempt(s): {2}' -f $path, $attempt, (Test-Path -LiteralPath $path))) } - # What the suite runner and the probes of the kit left in their stage folders, and their scheduled tasks + # What the suite runner and the probes of the kit left: the items in their stage folders, the folders of the account probe, + # their scheduled tasks, and the standard users that the probe of the authorization managers creates (with their profiles) foreach ($stage in 'C:\NtfsMatrixLocal', 'C:\NtfsMatrixProbe') { if (Test-Path -LiteralPath $stage) { Get-ChildItem -LiteralPath $stage -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } } + foreach ($folder in 'C:\NtfsProbeRecreation', 'C:\NtfsProbeModules') { + if (Test-Path -LiteralPath $folder) { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue } + } + Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.TaskName -like 'NtfsMatrix*' } | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } - $messages.Add('stage folders and scheduled tasks of the kit removed') + foreach ($user in @(Get-LocalUser -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.Name -like 'NtfsProbe*' })) { + foreach ($userProfile in @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -eq $user.SID.Value })) { Remove-CimInstance -InputObject $userProfile -ErrorAction SilentlyContinue } + Remove-LocalUser -SID $user.SID -ErrorAction SilentlyContinue + } + + $messages.Add('stage items, probe folders, probe users, and scheduled tasks of the kit removed') $messages } diff --git a/Tests/Lab/Acceptance/Test-MatrixReadiness.ps1 b/Tests/Lab/Acceptance/Test-MatrixReadiness.ps1 index 85e7fa0..e2a473b 100644 --- a/Tests/Lab/Acceptance/Test-MatrixReadiness.ps1 +++ b/Tests/Lab/Acceptance/Test-MatrixReadiness.ps1 @@ -2,7 +2,7 @@ param ( [string] $LabName = 'NtfsSecurityOsMatrixLab', [string[]] $DomainController = @('OSDC1'), - [string[]] $Member = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11'), + [string[]] $Member = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'), [Parameter(Mandatory)] [string] $OutFile ) diff --git a/Tests/Lab/Acceptance/Test-StaleAuthzModel.ps1 b/Tests/Lab/Acceptance/Test-StaleAuthzModel.ps1 new file mode 100644 index 0000000..63c101e --- /dev/null +++ b/Tests/Lab/Acceptance/Test-StaleAuthzModel.ps1 @@ -0,0 +1,129 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $Timeline, + [ValidateRange(1, 60)] [double] $FromMinutes = 3, + [ValidateRange(1, 60)] [double] $ToMinutes = 16, + [ValidateRange(0.01, 5)] [double] $StepMinutes = 0.25, + [ValidateRange(1, 60)] [double] $Lifetime, + [ValidateRange(0, 100000)] [int] $Permutations = 0, + [switch] $AsIfSameSubject, + [switch] $ShowMismatches +) + +# Replays the Admin roles of a timeline (Export-CellTimeline.ps1) against a model of the failures that the effective-access tests of the Admin role showed in +# the cells of the operating-system matrix (Decision 24). The model is a description of the observations, not an explanation of Windows: +# +# A remote authorization manager (the one of the client for the default -ServerName, the one of the file server for its own name) computes the groups of an +# account at the first request for the account name and answers from that result for L minutes, also when the account was deleted and created again under +# the same name in the meantime, with a new SID and new group memberships. The answer then has no access through the groups (0x100000, Synchronize only). +# +# For each L from -FromMinutes to -ToMinutes, the script walks the Admin roles in time order, keeps one entry per computer and account name, and predicts +# whether the first (file server) and the second (client) test pass: a test passes when no entry that is younger than L minutes and was made for another +# account instance exists. It reports how many of the observed outcomes each L predicts. A fit says that the position of a cell in the sequence is enough to +# explain the failures, whichever module was under test; it doesn't say how Windows does it, or that the lifetime is a constant. A run whose account name is +# new always passes, which is what the controller relies on since 1dec389. It reads files only; Windows PowerShell 5.1 or PowerShell 7. +# +# -Lifetime L lists every run with the observed and the predicted outcome of both tests for that one L instead of searching for the best L. -AsIfSameSubject +# gives every run the same account name: for the cells of a controller that gives each fixture a new name (rc7l and later), the listing then shows where a +# controller that reuses the name would have met a stale entry. -Permutations N asks how often a random assignment of the observed outcomes to the runs +# (the same number of failures, a fixed random seed) reaches the best agreement of the real outcomes for some L: if the position of a cell decides the +# outcome, it should almost never. +$ErrorActionPreference = 'Stop' +$random = New-Object -TypeName 'System.Random' -ArgumentList 20261010 +$rows = @(Import-Csv -LiteralPath $Timeline | ForEach-Object -Process { + [pscustomobject]@{ + Time = [DateTime]::ParseExact($_.AdminRoleStarted, 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) + Run = $_.Run + Candidate = $_.Candidate + Server = $_.FileServer + Edition = $_.Edition + Subject = if ($AsIfSameSubject) { 'one name' } else { $_.Subject } + Sid = $_.SubjectRid + Test1 = $_.T1ServerNameFileServer -like 'pass*' + Test2 = $_.T2DefaultServerName -like 'pass*' + } + } | Sort-Object -Property Time) + +function Test-Model { + param ([double] $Minutes, [ValidateSet('Test1', 'Test2')] [string] $Test) + + $entries = @{} + $mismatch = New-Object -TypeName 'System.Collections.Generic.List[string]' + $predictions = New-Object -TypeName 'System.Collections.Generic.List[bool]' + $agree = 0 + foreach ($row in $rows) { + $scope = if ($Test -eq 'Test2') { 'client|' + $row.Subject } else { $row.Server + '|' + $row.Subject } + $entry = $entries[$scope] + if ($entry -and ($row.Time - $entry.Created).TotalMinutes -lt $Minutes) { + $predicted = $entry.Sid -eq $row.Sid + } + else { + $entries[$scope] = @{ Created = $row.Time; Sid = $row.Sid } + $predicted = $true + } + + $predictions.Add($predicted) + $observed = $row.$Test + if ($predicted -eq $observed) { + $agree++ + } + else { + $mismatch.Add(('{0:HH:mm} {1} {2} {3} [{4}]: observed {5}, model {6}' -f $row.Time, $row.Run, $row.Server, $row.Edition, $row.Candidate, + $(if ($observed) { 'pass' } else { 'FAIL' }), $(if ($predicted) { 'pass' } else { 'FAIL' }))) + } + } + + [pscustomobject]@{ Minutes = $Minutes; Agree = $agree; Mismatch = $mismatch; Predictions = $predictions } +} + +if ($PSBoundParameters.ContainsKey('Lifetime')) { + $first = Test-Model -Minutes $Lifetime -Test Test1 + $second = Test-Model -Minutes $Lifetime -Test Test2 + $word = { param ($Passed) if ($Passed) { 'pass' } else { 'FAIL' } } + for ($index = 0; $index -lt $rows.Count; $index++) { + $row = $rows[$index] + [pscustomobject]@{ + Time = $row.Time.ToString('MM-dd HH:mm:ss') + Run = $row.Run + Server = $row.Server + Edition = $row.Edition + Candidate = $row.Candidate + Subject = $row.Subject + Test1 = & $word $row.Test1 + Test1Model = & $word $first.Predictions[$index] + Test2 = & $word $row.Test2 + Test2Model = & $word $second.Predictions[$index] + } + } + + return +} + +foreach ($test in 'Test1', 'Test2') { + $results = for ($minutes = $FromMinutes; $minutes -le $ToMinutes; $minutes += $StepMinutes) { Test-Model -Minutes $minutes -Test $test } + $best = ($results | Measure-Object -Property Agree -Maximum).Maximum + $bestResults = @($results | Where-Object -FilterScript { $_.Agree -eq $best }) + $failures = @($rows | Where-Object -FilterScript { -not $_.$test }).Count + '{0} ({1}): the model predicts {2} of {3} outcomes for L from {4:N2} to {5:N2} minutes; {6} runs failed' -f $test, + $(if ($test -eq 'Test1') { 'the name of the file server' } else { 'the default server name, the client' }), $best, $rows.Count, $bestResults[0].Minutes, $bestResults[-1].Minutes, $failures + if ($ShowMismatches) { foreach ($line in $bestResults[0].Mismatch) { ' mismatch: ' + $line } } + if ($Permutations -gt 0) { + $observed = [bool[]] @($rows | ForEach-Object -Process { $_.$test }) + $reached = 0 + $highest = 0 + for ($shuffle = 0; $shuffle -lt $Permutations; $shuffle++) { + $shuffled = [bool[]] @($observed | Sort-Object -Property { $random.Next() }) + $agreement = 0 + foreach ($result in $results) { + $agree = 0 + for ($index = 0; $index -lt $shuffled.Count; $index++) { if ($result.Predictions[$index] -eq $shuffled[$index]) { $agree++ } } + if ($agree -gt $agreement) { $agreement = $agree } + } + + if ($agreement -ge $best) { $reached++ } + if ($agreement -gt $highest) { $highest = $agreement } + } + + ' {0} of {1} random assignments of the outcomes to the runs reach {2} of {3} for some L; the best of them reaches {4}' -f $reached, $Permutations, $best, $rows.Count, $highest + } +} diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 index 9917cac..d269e0f 100644 --- a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -1074,11 +1074,11 @@ $modules = @( ) Write-LabProgress 'Preparing the accounts, the file server, and the client' -# When an account is deleted and created again with the same name, a Kerberos S4U logon for it keeps returning the SID and the groups of -# the deleted account for a while: on the domain controller, the client, and the file server of the operating-system matrix, for every -# version of the module. The Authz functions behind Get-NTFSEffectiveAccess log an account on this way, so the cmdlet returned no access -# for the new account. A new fixture therefore gets a name for the account of case 3 that no earlier fixture used; a fixture that -# exists keeps its account. +# When an account is deleted and created again with the same name, the remote authorization managers of the client and of the file server, which +# Get-NTFSEffectiveAccess asks for its default -ServerName and for the name of the file server, keep answering for about ten minutes as if the new +# account had no groups (Synchronize only), whichever version of the module runs. The local manager and a Kerberos S4U logon of the account, which +# the oracle uses, are right at that moment (Decision 24). So a new fixture gets a name for the account of case 3 that an earlier fixture is unlikely +# to have used (four random digits); a fixture that exists keeps its account. $existingSubjects = @(Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Look for the account of case 3' -ScriptBlock $findSubjectScript -ArgumentList $organizationalUnitName, $subjectBaseName @labCommand) $subjectAccount = if ($existingSubjects) { [string]$existingSubjects[0] } else { '{0}{1:D4}' -f $subjectBaseName, (Get-Random -Minimum 0 -Maximum 10000) } $groupMembers['NtfsLiveInner'] = @($subjectAccount)