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:
| |
-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:
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:
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:
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:
| |
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:
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:
| |
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:
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:
| |
The result object built its entry count like this:
| |
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:
| |
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:
| |
Invoke-RemovalWorkflow writes the backup with New-ACLBackup before it calls Set-ModifiedACL. Set-ModifiedACL then tried to write a second one:
| |
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:
-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:
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:
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:
| |
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:
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.
