Providers/FreeIPA/Public/Remove-FreeIPAEnvironment.ps1

function Remove-FreeIPAEnvironment {
    <#
    .SYNOPSIS
        Removes everything the seed created, proving ownership of each object first

    .DESCRIPTION
        Tears down in the reverse of the order the seed built: the certificates (revoked,
        since a CA never forgets one) and the CA ACLs, then the certificate mapping rules,
        SELinux maps, automount location, automember rules, tokens and ID views (unapplied
        from their hosts first), then the delegation rules and targets and the services, the
        password policies, the roles, privileges and
        permissions, the sudo rules, command groups and tagged commands, the HBAC rules,
        service groups and services, the netgroups, then hosts, the seed's two DNS zones with
        every record in them, host groups, users in every lifecycle state, the RADIUS proxies
        and identity providers those users linked to, and groups deepest first. Nothing is
        deleted for merely carrying the
        prefix. Each type has to satisfy the evidence the seed wrote - the tag in a user's or
        host's userclass, the marker in a description, a policy's group being seeded - and
        Get-FreeIPASeededObject is the one place that evidence is judged. A sudo command that
        existed before the seed, without the marker, is left behind; so is any stock rule,
        service or privilege a seeded object referenced.

        Deletes go to the server in batches, because FreeIPA takes a list of names per delete
        and a run of four hundred hosts one at a time is a run of four hundred round trips.
        Each object is still confirmed on its own, which is what keeps -WhatIf listing every
        one by name. A private group goes with its user, and a managed netgroup with its host
        group, because FreeIPA removes those itself.

        The automation service account is a seeded user and is the one that must not be
        deleted while it is the credential in use, so it is kept unless -RemoveServiceAccount
        is passed, and then removed last. Its credential record goes with it only under
        -RemoveCredentialFile, and the vault secret the record names is read before the file
        is deleted, or the secret is orphaned.

        -WhatIf wins over -Force. -Force suppresses the prompts by setting the confirm
        preference rather than by bypassing ShouldProcess, so ShouldProcess still runs and
        still returns false under -WhatIf. That distinction is pinned by the tests because
        -Force defeating -WhatIf was the worst defect an earlier module shipped.

    .PARAMETER Keep
        Object types to leave in place: Certificates, CaAcls, CertMapRules, SelinuxUserMaps,
        Automount, AutomemberRules, OtpTokens, IdViews, Services, PasswordPolicies, Roles, SudoRules,
        HbacRules, Netgroups, Hosts, Dns, Hostgroups, Users, IdentityProviders, Groups.

    .PARAMETER RemoveServiceAccount
        Also delete the automation service account. It is removed last, after everything it
        was used to remove.

    .PARAMETER RemoveCredentialFile
        With -RemoveServiceAccount, also delete the credential record and the vault secret it
        names.

    .PARAMETER Force
        Do not prompt. Has no effect under -WhatIf.

    .PARAMETER PassThru
        Returns the result object.

    .OUTPUTS
        PSCustomObject with BaseUrl, Prefix, StartTime, EndTime and a Removed and Errors list
        per object type.

    .EXAMPLE
        PS> Remove-FreeIPAEnvironment -WhatIf

        DESCRIPTION: Lists everything that would be removed
        OUTPUT: One WhatIf line per object the module can prove it owns
        USE CASE: Always the first teardown call

    .EXAMPLE
        PS> Remove-FreeIPAEnvironment -Keep Groups -Force -PassThru

        DESCRIPTION: Removes everything except the groups, without prompting
        OUTPUT: The result object with counts per type
        USE CASE: Re-seeding users against groups a report was already written against

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The teardown summary is written for the person watching; the result object carries the same data.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'attribute',
        Justification = 'Read inside the name-of script block the sweep is handed, which the analyzer does not follow.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('Certificates', 'CaAcls', 'CertMapRules', 'SelinuxUserMaps', 'Automount', 'AutomemberRules', 'OtpTokens', 'IdViews', 'Services',
            'PasswordPolicies', 'Roles', 'SudoRules', 'HbacRules', 'Netgroups', 'Hosts', 'Dns', 'Hostgroups', 'Users', 'IdentityProviders', 'Groups')]
        [string[]]$Keep = @(),

        [Parameter()]
        [switch]$RemoveServiceAccount,

        [Parameter()]
        [switch]$RemoveCredentialFile,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-FreeIPAConnection
    $marker = Get-FreeIPASeedMarker -Connection $connection

    $results = [PSCustomObject]@{
        BaseUrl        = $connection.BaseUrl
        Prefix         = $connection.Prefix
        StartTime      = Get-Date
        EndTime          = $null
        Certificates     = @{ Removed = @(); Errors = @() }
        CaAcls           = @{ Removed = @(); Errors = @() }
        CertMapRules     = @{ Removed = @(); Errors = @() }
        SelinuxUserMaps  = @{ Removed = @(); Errors = @() }
        Automount        = @{ Removed = @(); Errors = @() }
        AutomemberRules  = @{ Removed = @(); Errors = @() }
        OtpTokens        = @{ Removed = @(); Errors = @() }
        IdViews          = @{ Removed = @(); Errors = @() }
        Services         = @{ Removed = @(); Errors = @() }
        PasswordPolicies = @{ Removed = @(); Errors = @() }
        Roles            = @{ Removed = @(); Errors = @() }
        SudoRules        = @{ Removed = @(); Errors = @() }
        HbacRules        = @{ Removed = @(); Errors = @() }
        Netgroups        = @{ Removed = @(); Errors = @() }
        Hosts            = @{ Removed = @(); Errors = @() }
        Dns              = @{ Removed = @(); Errors = @() }
        Hostgroups       = @{ Removed = @(); Errors = @() }
        Users            = @{ Removed = @(); Errors = @() }
        IdentityProviders = @{ Removed = @(); Errors = @() }
        Groups           = @{ Removed = @(); Errors = @() }
        ServiceAccount   = @{ Removed = @(); Errors = @() }
    }

    Write-TestMessage -Message "FreeIPA Test Environment Teardown ($($connection.BaseUrl))" -Type Header

    # Read directly rather than through ShouldProcess alone, so -Force can skip the prompt
    # without also skipping the preview.
    $isWhatIf = $WhatIfPreference

    # -Force sets the preference rather than short-circuiting ShouldProcess, which is what
    # keeps -WhatIf working: ShouldProcess is still called and still returns false under it.
    if ($Force -and -not $isWhatIf) {
        $ConfirmPreference = 'None'
    }

    if (-not $Force -and -not $isWhatIf) {
        $prompt = ("This permanently deletes every certificate mapping rule, SELinux map, automount location, automember rule, " +
            "token, ID view, service, delegation rule, password policy, role, privilege, permission, " +
            "sudo rule, HBAC rule, netgroup, host, host group, user and group tagged '$($marker.Tag)' " +
            "in $($connection.BaseUrl), including preserved and staged users. FreeIPA has no undo.")
        if (-not $PSCmdlet.ShouldContinue($prompt, 'Remove FreeIPA test environment')) {
            Write-TestMessage -Message 'Teardown cancelled.' -Type Warning
            if ($PassThru) { return $results }
            return
        }
    }

    $first = { param($value) if ($value -is [array]) { if ($value.Count -gt 0) { [string]$value[0] } else { '' } } else { [string]$value } }

    # A sweep is: confirm each object by name, then delete the confirmed ones in batches of
    # fifty with 'continue', so one refusal does not abandon the batch, and record what the
    # server says it could not do. Extra options win over the defaults, so a method that
    # refuses 'continue' can null it out.
    $sweep = {
        param($key, $label, $one, $items, $nameOf, $method, $extraOptions)
        Write-TestMessage -Message "Removing $label" -Type Info
        $approved = [System.Collections.Generic.List[string]]::new()
        foreach ($item in $items) {
            $name = & $nameOf $item
            if ($PSCmdlet.ShouldProcess($name, "Delete FreeIPA $one")) { $approved.Add($name) }
        }
        for ($start = 0; $start -lt $approved.Count; $start += 50) {
            $chunk = @($approved[$start..([Math]::Min($start + 49, $approved.Count - 1))])
            $options = @{ continue = $true }
            if ($extraOptions) { foreach ($k in $extraOptions.Keys) { $options[$k] = $extraOptions[$k] } }
            try {
                $outcome = Invoke-FreeIPARequest -Method $method -Arguments $chunk -Options $options -Connection $connection
                $failed = @{}
                if ($outcome -and $outcome.result -and $outcome.result.PSObject.Properties['failed']) {
                    foreach ($failure in @($outcome.result.failed)) {
                        $text = [string]$failure
                        $failedName = ($text -split ':', 2)[0].Trim()
                        $failed[$failedName] = $text
                    }
                }
                foreach ($name in $chunk) {
                    if ($failed.ContainsKey($name)) {
                        $results.$key.Errors += $failed[$name]
                        Write-Error "Failed to delete '$name': $($failed[$name])"
                    }
                    else { $results.$key.Removed += $name }
                }
            }
            catch {
                foreach ($name in $chunk) { $results.$key.Errors += "${name}: $($_.Exception.Message)" }
                Write-Error "Failed to delete a batch of $($chunk.Count) $label`: $($_.Exception.Message)"
            }
        }
    }

    # A type whose objects are found by one discovery call and deleted by one method, with
    # the name in a given attribute. The access layers are all this shape.
    $sweepType = {
        param($key, $type, $label, $one, $method, $attribute, $extraOptions)
        try {
            $items = @(Get-FreeIPASeededObject -Type $type -Connection $connection)
            & $sweep $key $label $one $items { param($i) & $first $i.$attribute } $method $extraOptions
        }
        catch {
            $results.$key.Errors += $_.Exception.Message
            Write-Error "Could not enumerate ${label}: $($_.Exception.Message)"
        }
    }

    # --- 1. The identity detail, in the reverse of the order it was built --------------------
    # A view is unapplied from its hosts before it goes, because a deleted view would leave
    # every one of them pointing at nothing. An automember rule is deleted per kind, because
    # the API keeps group and host group rules apart. An automount location takes its maps
    # and keys with it.
    # A certificate is revoked, one serial at a time, because a CA has no delete: the record
    # stays, marked ceased. Only a valid one is touched; a revoked one is already what
    # teardown would make it. The serial goes as the hex form the CA returned, which is text
    # in both editions, never the decimal number.
    if ('Certificates' -notin $Keep) {
        Write-TestMessage -Message 'Revoking certificates' -Type Info
        try {
            $held = @(Get-FreeIPASeededObject -Type Certificates -Connection $connection | Where-Object { (& $first $_.status) -eq 'VALID' })
            foreach ($cert in $held) {
                $serial = if ($cert.PSObject.Properties['serial_number_hex']) { & $first $cert.serial_number_hex } else { & $first $cert.serial_number }
                $owner = & $first $cert.subject
                if (-not $PSCmdlet.ShouldProcess("$serial ($owner)", 'Revoke FreeIPA certificate')) { continue }
                try {
                    $null = Invoke-FreeIPARequest -Method 'cert_revoke' -Arguments $serial -Options @{ revocation_reason = 5 } -Connection $connection
                    $results.Certificates.Removed += $serial
                }
                catch {
                    $results.Certificates.Errors += "${serial}: $($_.Exception.Message)"
                    Write-Error "Failed to revoke certificate ${serial}: $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.Certificates.Errors += $_.Exception.Message
            Write-Error "Could not enumerate certificates: $($_.Exception.Message)"
        }
    }
    if ('CaAcls' -notin $Keep) {
        & $sweepType 'CaAcls' 'CaAcls' 'CA ACLs' 'CA ACL' 'caacl_del' 'cn' $null
    }
    if ('CertMapRules' -notin $Keep) {
        & $sweepType 'CertMapRules' 'CertMapRules' 'certificate mapping rules' 'certificate mapping rule' 'certmaprule_del' 'cn' $null
    }
    if ('SelinuxUserMaps' -notin $Keep) {
        & $sweepType 'SelinuxUserMaps' 'SelinuxUserMaps' 'SELinux user maps' 'SELinux user map' 'selinuxusermap_del' 'cn' $null
    }
    if ('Automount' -notin $Keep) {
        & $sweepType 'Automount' 'AutomountLocations' 'automount locations' 'automount location' 'automountlocation_del' 'cn' $null
    }
    if ('AutomemberRules' -notin $Keep) {
        try {
            $automemberRules = @(Get-FreeIPASeededObject -Type AutomemberRules -Connection $connection)
            foreach ($kind in 'group', 'hostgroup') {
                $ofKind = @($automemberRules | Where-Object { $_.automembertype -eq $kind })
                # automember_del takes no 'continue'; a null option is dropped before it is sent.
                & $sweep 'AutomemberRules' "automember $kind rules" "automember $kind rule" $ofKind { param($r) & $first $r.cn } 'automember_del' @{ type = $kind; continue = $null }
            }
        }
        catch {
            $results.AutomemberRules.Errors += $_.Exception.Message
            Write-Error "Could not enumerate automember rules: $($_.Exception.Message)"
        }
    }
    if ('OtpTokens' -notin $Keep) {
        & $sweepType 'OtpTokens' 'OtpTokens' 'OTP tokens' 'OTP token' 'otptoken_del' 'ipatokenuniqueid' $null
    }
    if ('IdViews' -notin $Keep) {
        try {
            $views = @(Get-FreeIPASeededObject -Type IdViews -Connection $connection)
            foreach ($view in $views) {
                $viewName = & $first $view.cn
                $shown = Invoke-FreeIPARequest -Method 'idview_show' -Arguments $viewName -Options @{ show_hosts = $true } -Connection $connection -IgnoreError 'NotFound'
                $applied = @()
                if ($shown -and $shown.result -and $shown.result.PSObject.Properties['appliedtohosts']) { $applied = @($shown.result.appliedtohosts | ForEach-Object { [string]$_ }) }
                if ($applied.Count -gt 0 -and $PSCmdlet.ShouldProcess($viewName, "Unapply from $($applied.Count) host(s)")) {
                    $null = Invoke-FreeIPARequest -Method 'idview_unapply' -Options @{ host = [object[]]$applied } -Connection $connection
                }
            }
            & $sweep 'IdViews' 'ID views' 'ID view' $views { param($v) & $first $v.cn } 'idview_del' $null
        }
        catch {
            $results.IdViews.Errors += $_.Exception.Message
            Write-Error "Could not enumerate ID views: $($_.Exception.Message)"
        }
    }

    # --- 2. The access layers, each in the reverse of the order it was built ----------------
    # A service goes before its host; a delegation rule before the target it names. A policy
    # goes before its group. A role before its privileges, and those before their permissions.
    # A rule before the command group it allows, and a command only when the seed made it.
    if ('Services' -notin $Keep) {
        & $sweepType 'Services' 'ServiceDelegationRules' 'service delegation rules' 'service delegation rule' 'servicedelegationrule_del' 'cn' $null
        & $sweepType 'Services' 'ServiceDelegationTargets' 'service delegation targets' 'service delegation target' 'servicedelegationtarget_del' 'cn' $null
        & $sweepType 'Services' 'Services' 'services' 'service' 'service_del' 'krbcanonicalname' $null
    }
    if ('PasswordPolicies' -notin $Keep) {
        & $sweepType 'PasswordPolicies' 'PasswordPolicies' 'password policies' 'password policy' 'pwpolicy_del' 'cn' $null
    }
    if ('Roles' -notin $Keep) {
        & $sweepType 'Roles' 'Roles' 'roles' 'role' 'role_del' 'cn' $null
        & $sweepType 'Roles' 'Privileges' 'privileges' 'privilege' 'privilege_del' 'cn' $null
        & $sweepType 'Roles' 'Permissions' 'permissions' 'permission' 'permission_del' 'cn' $null
    }
    if ('SudoRules' -notin $Keep) {
        & $sweepType 'SudoRules' 'SudoRules' 'sudo rules' 'sudo rule' 'sudorule_del' 'cn' $null
        & $sweepType 'SudoRules' 'SudoCommandGroups' 'sudo command groups' 'sudo command group' 'sudocmdgroup_del' 'cn' $null
        & $sweepType 'SudoRules' 'SudoCommands' 'sudo commands the seed made' 'sudo command' 'sudocmd_del' 'sudocmd' $null
    }
    if ('HbacRules' -notin $Keep) {
        & $sweepType 'HbacRules' 'HbacRules' 'HBAC rules' 'HBAC rule' 'hbacrule_del' 'cn' $null
        & $sweepType 'HbacRules' 'HbacServiceGroups' 'HBAC service groups' 'HBAC service group' 'hbacsvcgroup_del' 'cn' $null
        & $sweepType 'HbacRules' 'HbacServices' 'HBAC services' 'HBAC service' 'hbacsvc_del' 'cn' $null
    }
    if ('Netgroups' -notin $Keep) {
        & $sweepType 'Netgroups' 'Netgroups' 'netgroups' 'netgroup' 'netgroup_del' 'cn' $null
    }

    # --- 3. Hosts, then host groups ---------------------------------------------------------
    if ('Hosts' -notin $Keep) {
        try {
            $hosts = @(Get-FreeIPASeededObject -Type Hosts -Connection $connection)
            & $sweep 'Hosts' 'hosts' 'host' $hosts { param($h) & $first $h.fqdn } 'host_del' @{ updatedns = $false }
        }
        catch {
            $results.Hosts.Errors += $_.Exception.Message
            Write-Error "Could not enumerate hosts: $($_.Exception.Message)"
        }
    }

    # The seed's zones go after the hosts whose records they hold, whole: a zone deleted
    # takes every record in it, so nothing is deleted one name at a time. Only a zone that
    # carries the seed's SOA contact is ours; the realm's own zones never match.
    if ('Dns' -notin $Keep) {
        try {
            $zones = @(Get-FreeIPASeededObject -Type DnsZones -Connection $connection)
            & $sweep 'Dns' 'DNS zones' 'DNS zone' $zones { param($z) ConvertFrom-FreeIPADnsName -Value $z.idnsname } 'dnszone_del' $null
        }
        catch {
            $results.Dns.Errors += $_.Exception.Message
            Write-Error "Could not enumerate DNS zones: $($_.Exception.Message)"
        }
    }

    if ('Hostgroups' -notin $Keep) {
        try {
            $hostgroups = @(Get-FreeIPASeededObject -Type Hostgroups -Connection $connection)
            & $sweep 'Hostgroups' 'host groups' 'host group' $hostgroups { param($g) & $first $g.cn } 'hostgroup_del' $null
        }
        catch {
            $results.Hostgroups.Errors += $_.Exception.Message
            Write-Error "Could not enumerate host groups: $($_.Exception.Message)"
        }
    }

    # --- 4. Users in every state --------------------------------------------------------------
    # A preserved user is deleted for good by the same call that deleted it the first time; a
    # staged one lives in its own container and has its own call.
    if ('Users' -notin $Keep) {
        try {
            $users = @(Get-FreeIPASeededObject -Type Users -Connection $connection)
            & $sweep 'Users' 'users' 'user' $users { param($u) & $first $u.uid } 'user_del' $null
            $preserved = @(Get-FreeIPASeededObject -Type PreservedUsers -Connection $connection)
            & $sweep 'Users' 'preserved users' 'preserved user' $preserved { param($u) & $first $u.uid } 'user_del' $null
            $staged = @(Get-FreeIPASeededObject -Type StagedUsers -Connection $connection)
            & $sweep 'Users' 'staged users' 'staged user' $staged { param($u) & $first $u.uid } 'stageuser_del' $null
        }
        catch {
            $results.Users.Errors += $_.Exception.Message
            Write-Error "Could not enumerate users: $($_.Exception.Message)"
        }
    }

    # --- 5. Groups, deepest first -------------------------------------------------------------
    # A member group names its parents in memberof, so removing the leaves first leaves
    # nothing dangling if a deletion midway fails.
    # The proxies and providers go after the users that linked to them; a link is dropped by
    # the server when its target goes, so the order is for tidiness, not correctness.
    if ('IdentityProviders' -notin $Keep) {
        & $sweepType 'IdentityProviders' 'RadiusProxies' 'RADIUS proxies' 'RADIUS proxy' 'radiusproxy_del' 'cn' $null
        & $sweepType 'IdentityProviders' 'IdentityProviders' 'identity providers' 'identity provider' 'idp_del' 'cn' $null
    }

    if ('Groups' -notin $Keep) {
        try {
            $groups = @(Get-FreeIPASeededObject -Type Groups -Connection $connection)
            $seededNames = @{}
            foreach ($group in $groups) { $seededNames[(& $first $group.cn)] = $group }
            $depthOf = {
                param($group)
                $depth = 0
                $frontier = @()
                if ($group.PSObject.Properties['memberof_group']) { $frontier = @($group.memberof_group | Where-Object { $seededNames.ContainsKey([string]$_) }) }
                while ($frontier.Count -gt 0 -and $depth -lt 20) {
                    $depth++
                    $frontier = @($frontier | ForEach-Object {
                            $parent = $seededNames[[string]$_]
                            if ($parent.PSObject.Properties['memberof_group']) { @($parent.memberof_group | Where-Object { $seededNames.ContainsKey([string]$_) }) }
                        })
                }
                $depth
            }
            $ordered = @($groups | Sort-Object -Property @{ Expression = { & $depthOf $_ }; Descending = $true }, @{ Expression = { & $first $_.cn } })
            & $sweep 'Groups' 'groups' 'group' $ordered { param($g) & $first $g.cn } 'group_del' $null
        }
        catch {
            $results.Groups.Errors += $_.Exception.Message
            Write-Error "Could not enumerate groups: $($_.Exception.Message)"
        }
    }

    # --- 6. The service account, last ---------------------------------------------------------
    if ($RemoveServiceAccount) {
        try {
            $accountName = Get-FreeIPAServiceAccountName -Marker $marker
            $account = @(Get-FreeIPASeededObject -Type Users -IncludeServiceAccount -Connection $connection |
                    Where-Object { (& $first $_.uid) -eq $accountName })

            if ($connection.AuthType -eq 'ServiceAccount' -and $account.Count -gt 0) {
                Write-Warning "Removing the service account this session is connected as. Nothing else will work afterwards until you reconnect with a credential."
            }

            & $sweep 'ServiceAccount' 'the service account' 'service account' $account { param($u) & $first $u.uid } 'user_del' $null

            if ($RemoveCredentialFile) {
                $recordPath = Get-FreeIPACredentialPath -BaseUrl $connection.BaseUrl -Path $connection.CredentialPath
                if (Test-Path -LiteralPath $recordPath) {
                    if ($PSCmdlet.ShouldProcess($recordPath, 'Delete the service account credential record')) {
                        # The vault pointer is read before the file goes, or the secret is orphaned.
                        $record = $null
                        try { $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json } catch { $record = $null }
                        if ($record -and $record.PSObject.Properties['secretName'] -and $record.secretName) {
                            try { Remove-TestVaultSecret -VaultName $record.vaultName -SecretName $record.secretName -Confirm:$false }
                            catch { Write-Warning "Could not remove the vault secret '$($record.secretName)': $($_.Exception.Message)" }
                        }
                        Remove-Item -LiteralPath $recordPath -Force
                        $results.ServiceAccount.Removed += $recordPath
                    }
                }
            }
        }
        catch {
            $results.ServiceAccount.Errors += $_.Exception.Message
            Write-Error "Could not remove the service account: $($_.Exception.Message)"
        }
    }

    $results.EndTime = Get-Date

    $tracked = @('Certificates', 'CaAcls', 'CertMapRules', 'SelinuxUserMaps', 'Automount', 'AutomemberRules', 'OtpTokens', 'IdViews', 'Services', 'PasswordPolicies',
        'Roles', 'SudoRules', 'HbacRules', 'Netgroups', 'Hosts', 'Dns', 'Hostgroups', 'Users', 'IdentityProviders', 'Groups', 'ServiceAccount')
    $removedCount = @($tracked | ForEach-Object { @($results.$_.Removed).Count } | Measure-Object -Sum).Sum
    $errorCount = @($tracked | ForEach-Object { @($results.$_.Errors).Count } | Measure-Object -Sum).Sum

    Write-TestMessage -Message 'Teardown Summary' -Type Header
    Write-Host "Objects removed: $removedCount" -ForegroundColor Green
    if ($errorCount -gt 0) {
        Write-Warning "Failures: $errorCount. Inspect the results object with -PassThru."
    }

    if ($PassThru) { return $results }
}