By early afternoon on November 8, OrphanedSid was a module, its suite was on Pester 6, and 2,412 of 2,414 tests passed on both PowerShell 7 and Windows PowerShell 5.1. None of those tests had ever touched Active Directory. The suite runs without RSAT on purpose, so every AD call in it is a mock.

I always try to check against my lab rather than trust CI and tests; the previous post shows why my tests aren’t always the definitive answer they should be.

So I staged the module on a lab domain controller and ran it. Discovery found nothing, every restore failed, and every removal logged an error while succeeding.

Getting a command onto the DC

The DC is a Windows Server 2022 VM on my Proxmox host, and I don’t RDP into it to test. The Proxmox guest agent can run a process inside a VM and hand back its output. One SSH hop to the host is enough:

1
2
3
4
5
6
$script = @'
Import-Module C:\OrphanedSidTest\OrphanedSid\OrphanedSid.psd1 -Force
Find-OrphanedSid -SearchBase 'OU=Orphaned,OU=EdgeCases,OU=ZZ-TEST-TestData,DC=ad,DC=homelabdomain,DC=xyz'
'@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
ssh pve "qm guest exec 106 --timeout 280 -- powershell.exe -NoProfile -EncodedCommand $encoded"

-EncodedCommand takes Base64 of UTF-16LE text. That gets a whole script through SSH, the Proxmox shell and the guest agent without anyone’s quoting rules getting a vote. The answer comes back as JSON:

1
2
3
4
5
{
   "exitcode" : 0,
   "exited" : 1,
   "out-data" : "MODULE 1.0.0 ...\r\n=== FIND 1\r\n..."
}

If you try this, the process runs as the VM’s computer account, so anything that logs “who ran this” names the DC itself. And --timeout is how long qm waits before it gives up and hands you a PID, which isn’t the same as the command finishing.

The orphans come from TestEnvironment’s New-ADTestEdgeCase OrphanedSid, the same seed the first post used.

Discovery returned nothing

The first Find-OrphanedSid processed zero objects and logged a failed search base for every one I gave it. The loop in Get-ADObjectsSequential looked like this:

1
2
3
4
5
6
7
8
9
param(
    [Parameter(Mandatory = $true)]
    [string[]]$SearchBase,
    # ...
)
# ...
foreach ($searchBase in $SearchBase) {
    try {
        $searchResults = Get-ADObjectFromSearchBase -SearchBase $searchBase -IncludeInherited:$IncludeInherited -CorrelationId $CorrelationId

To me, $searchBase and $SearchBase are two variables. PowerShell variable names ignore case, so they’re one, and that one has a [string[]] type constraint from the param block. Every time foreach assigns an element to it, the constraint wraps the string back into a one-element array.

I pulled it out into a few lines to watch it happen on both shells:

1
2
3
4
function Get-All {
    param([string[]]$SearchBase)
    foreach ($searchBase in $SearchBase) {
        "loop variable is a $($searchBase.GetType().Name)"
1
2
loop variable is a String[]
loop variable is a String[]

A one-element array will turn into a string if you pass it to a plain function. Get-ADObjectFromSearchBase is an advanced function: its $SearchBase carries a [Parameter()] attribute, and the binder won’t convert an array to a [string] there:

1
FAILED: Cannot process argument transformation on parameter 'SearchBase'. Cannot convert value to type System.String.

Windows PowerShell 5.1.26100 and PowerShell 7.6.9 print the same thing. The catch in the loop logged it as a failed search base, carried on to the next one, and failed that too.

If you’re wondering how the unit test missed it: the test for Get-ADObjectsSequential mocked Get-ADObjectFromSearchBase with an untyped parameter, so the mock took the array without complaint and returned its canned objects. The fix renames the loop variable to $currentSearchBase, with a comment explaining why. The test’s mock now records whether it got a string:

1
2
3
# The real Get-ADObjectFromSearchBase declares [string]$SearchBase; a one-element array does
# not bind to it, so the type the caller passed is recorded for the regression test below
SearchBaseIsString = $SearchBase -is [string]

The monorepo post’s clash was between two command names. This one was variables, same rule.

Maybe one of the downsides of playing in Linux and containers so much is I expect case sensitivity, and sometimes those habits catch me when dealing with AD and Windows.

Every restore failed on -BackupData

With discovery fixed, remove worked and restore didn’t. Every attempt came back with this:

1
A parameter cannot be found that matches parameter name 'BackupData'.

The restore path calls Test-BackupIntegrity -BackupData $BackupData -ExpectedObjectDN $TargetObjectDN, and there was a function by that name with those parameters in Private/Backup/Test-BackupValidation.ps1. There was also one in Private/Utilities/Test-BackupIntegrity.ps1:

1
2
3
4
param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$BackupFilePath,

The root module dot-sources everything into one scope, and the second definition of a name replaces the first. Utilities loads after Backup, so the file-path version won, and the restore path called a function that had never heard of -BackupData. Each unit test dot-sources the one file it’s testing, so each test only ever saw one definition, and both were fine.

The file-path helper is Test-BackupFileIntegrity now. Looking for others turned up Get-BackupMetadata with the same clash, and Test-LogLevel and Test-BackupFormat defined twice.

Then restore applied the ACL and called it a failure

Past that, restore wrote the ACL back to the object and then reported Success = False:

1
The term 'if' is not recognized as the name of a cmdlet, function, script file, or operable program.

The result object built its entry count like this:

1
EntriesRestored = [int](if ($aclOperation.ModificationsApplied) { $aclOperation.ModificationsApplied } else { 0 })

if is a statement in PowerShell, and parentheses only hold expressions. Inside (...) the parser reads if as a command name and goes looking for it. A subexpression, $(...), can hold statements:

1
EntriesRestored = [int]$(if ($aclOperation.ModificationsApplied) { $aclOperation.ModificationsApplied } else { 0 })

The fix is a single character. The restore had worked all along; it just couldn’t count. Restore-IndividualObject had no tests of its own, so nothing ever ran that line.

Removals that worked and logged an error

Every removal succeeded, wrote its backup, and logged this anyway:

1
ACL backup failed: A parameter cannot be found that matches parameter name 'DistinguishedName'.

Invoke-RemovalWorkflow writes the backup with New-ACLBackup before it calls Set-ModifiedACL. Set-ModifiedACL then tried to write a second one:

1
$backupResult = New-ACLBackup -DistinguishedName $ObjectDN -CorrelationId $CorrelationId

New-ACLBackup’s parameter is -ObjectDN, and it also needs the ACL and the backup folder, neither of which this call passed. The second backup could never have worked, and it had nothing to add, since the first one was already on disk. It’s gone, along with four Backup* properties on the result object that only ever described the failed attempt.

The same kind of drift was in the security logger. It passed -Data to Write-StructuredLogEntry. That function’s parameter is -Details.

-SuppressConsoleOutput and -WhatIf

-SuppressConsoleOutput was stored in $script:SuppressConsoleOutput and then never read. Every entry still reached the console. The console writer checks it first now:

1
2
3
4
# -SuppressConsoleOutput on the public commands sets this; the file still receives every entry
if ($script:SuppressConsoleOutput) {
    return
}

-WhatIf had the opposite problem, reaching further than it should. A public command’s -WhatIf sets $WhatIfPreference for everything it calls, and the engine was full of housekeeping that honored it. A rehearsal wrote no log file at all, because Add-Content was rehearsing too. It also printed What if: lines about appending log entries, creating the memory manager and disposing resources, none of which anybody was asking about.

Each of those calls is pinned now, with a comment saying why:

1
2
3
# -WhatIf on the public command reaches here through $WhatIfPreference; the log is not
# what is being rehearsed, so the write is pinned
Add-Content -Path $effectiveLogPath -Value $formattedEntryForFile -Encoding UTF8 -WhatIf:$false

The engine used to work out whether it was in WhatIf mode by calling ShouldProcess on a made-up target, which printed one more of those lines. It reads $WhatIfPreference directly now.

A contract test for the module

Most of those defects have the same shape. Each file was right about itself and wrong about a neighbor, and the unit tests couldn’t see it because they test one file at a time with its neighbors mocked.

Tests/Unit/OrphanedSid.Contract.Tests.ps1 reads the module the way the root module loads it, all at once. It parses every file in Classes, Private and Public, records each function definition, and flags a name it has already seen:

1
2
3
4
if ($script:Definitions.ContainsKey($function.Name)) {
    $first = $script:Definitions[$function.Name]
    $script:Duplicates.Add("$($function.Name) in $($first.File):$($first.Line) and ${relative}:$($function.Extent.StartLineNumber)")
}

It skips functions nested in other functions, class methods, and fallbacks defined inside an if (-not (Get-Command ...)) guard. None of those land in module scope as a second definition.

Then it finds every call from one module function to another and checks each named parameter against what the callee declares, including aliases, common parameters, and -WhatIf and -Confirm for a function with SupportsShouldProcess:

1
2
3
4
5
6
7
foreach ($element in $call.CommandElements) {
    if ($element -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue }
    $used = $element.ParameterName
    if (-not ($accepted | Where-Object { $_ -like "$used*" })) {
        $script:DriftedCalls.Add("${relative}:$($call.Extent.StartLineNumber) passes -$used to $name, which declares no such parameter (defined in $($script:Definitions[$name].File))")
    }
}

The -like "$used*" is there because PowerShell binds any unambiguous prefix, so -Correlation is a legal way to pass -CorrelationId.

That covers the duplicate Test-BackupIntegrity, the -DistinguishedName call and the -Data call. It can’t see the loop variable, the [int](if ...), or either switch, so those got regression tests where they live. The contract test also imports the module fresh and checks which Test-BackupIntegrity the restore path resolves to:

1
2
3
$parameters = & (Get-Module OrphanedSid) { (Get-Command Test-BackupIntegrity).Parameters.Keys }
$parameters | Should -Contain 'BackupData'
$parameters | Should -Contain 'ExpectedObjectDN'

With all of it in, the second run on the DC found the 2 seeded orphans, removed them with a backup each, found 0, restored both, and found 2 again.