tests/IntuneRemediationScripts.Tests.ps1

#Requires -Module Pester
#
# Guards for the bundled Intune scripts.
#
# These CANNOT be executed here: they use SecurityIdentifier.Translate and the WinNT ADSI
# provider, both Windows-only, so a macOS or Linux runner cannot even construct a SID. What
# is testable is the set of invariants that do not need Windows - and the first of them is the
# exact hazard the scripts warn about:
#
# A detect/remediate pair is TWO separate uploads to Intune and nothing enforces that they
# agree. A $TargetAccount edited in one and not the other means detection fires on one
# account while remediation removes a different one - on every device the pair is assigned
# to, silently, because both scripts would still report success.
#
# Not every folder is a pair. Intune allows a remediation with detection ONLY, which is how a
# script becomes a fleet-wide inventory report read out of the detection output column, so a
# missing remediate.ps1 is a legitimate shape rather than a half-finished one.

BeforeAll {
    $script:IntuneRoot = Join-Path $PSScriptRoot '..' 'scripts' 'Intune'
    $script:WindowsRoot = Join-Path $script:IntuneRoot 'Windows'

    $script:Pairs = @(Get-ChildItem -Path $script:WindowsRoot -Directory -ErrorAction SilentlyContinue)

    # Present halves only, so an inventory-only folder does not read as a broken pair.
    $script:Halves = @(
        foreach ($pair in $script:Pairs) {
            foreach ($half in 'detect.ps1', 'remediate.ps1') {
                $path = Join-Path $pair.FullName $half
                if (Test-Path $path) {
                    [pscustomobject]@{ Pair = $pair.Name; Half = $half; Path = $path }
                }
            }
        }
    )

    # Command invocations, from the AST - so a cmdlet NAMED in a comment explaining why it is
    # avoided does not read as a use of it. That distinction matters here: the scripts carry
    # long notes about Get-LocalGroupMember precisely because they do not call it.
    $script:CommandsIn = {
        param($Path)
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $null, [ref] $null)
        @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true) |
            ForEach-Object { $_.GetCommandName() } | Where-Object { $_ })
    }
}

Describe 'Intune scripts' {

    It 'gives every remediation folder a detection half' {
        @($script:Pairs).Count | Should -BeGreaterThan 0

        foreach ($pair in $script:Pairs) {
            # Detection is always required; remediation is optional - see the header.
            Test-Path (Join-Path $pair.FullName 'detect.ps1') | Should -BeTrue -Because "$($pair.Name) needs detect.ps1"
        }
    }

    It 'parses on any platform, so a syntax error is caught before upload' {
        foreach ($half in $script:Halves) {
            $errors = $null
            [System.Management.Automation.Language.Parser]::ParseFile($half.Path, [ref] $null, [ref] $errors) | Out-Null
            @($errors) -join '; ' | Should -BeNullOrEmpty -Because "$($half.Pair)/$($half.Half) must parse"
        }
    }

    It 'keeps $TargetAccount identical across a pair that uses one' {
        foreach ($pair in $script:Pairs) {
            $values = @(
                foreach ($half in @($script:Halves | Where-Object Pair -eq $pair.Name)) {
                    $content = Get-Content -Path $half.Path -Raw
                    # The assignment, not a mention in a comment.
                    $match = [regex]::Match($content, '(?m)^\s*\$TargetAccount\s*=\s*(?<v>.+?)\s*$')
                    if ($match.Success) { $match.Groups['v'].Value }
                }
            )

            # An inventory script targets nobody, so having none is fine. Having ONE where the
            # folder has two halves is not: that is exactly the drift this guards.
            if (-not $values.Count) { continue }

            $halfCount = @($script:Halves | Where-Object Pair -eq $pair.Name).Count
            @($values).Count | Should -Be $halfCount -Because "$($pair.Name): every half must name the account, or none"
            @($values | Select-Object -Unique).Count | Should -Be 1 -Because "$($pair.Name): detect.ps1 and remediate.ps1 must name the SAME account"
        }
    }

    It 'resolves the Administrators group by SID, never by name' {
        # 'Administrators' is renamed on localised Windows - Administratoren, Administratörer -
        # so a script hard-coding the English name silently finds no group at all there and
        # reports every such machine as clean.
        foreach ($half in $script:Halves) {
            (Get-Content -Path $half.Path -Raw) | Should -Match 'S-1-5-32-544' -Because "$($half.Pair)/$($half.Half) must bind the group by SID"
        }
    }

    It 'never calls Get-LocalGroupMember' {
        # It raises "A local account with the SID '<sid>' was not found" for any member it
        # cannot resolve - an Entra user who has never signed in here, or an object since
        # deleted. The membership is real; only the name lookup fails. The error is
        # NON-TERMINATING, but $ErrorActionPreference = 'Stop' promotes it, so ONE unresolvable
        # member aborts the enumeration and a machine full of Entra admins looks like a
        # collection failure.
        foreach ($half in $script:Halves) {
            $commands = & $script:CommandsIn $half.Path
            $commands | Should -Not -Contain 'Get-LocalGroupMember' -Because "$($half.Pair)/$($half.Half) must enumerate through ADSI instead"
        }
    }

    It 'resolves Entra members to their UPN, or matching by UPN cannot work' {
        # ADSI and LSA give an Entra member its SAM-COMPATIBLE name - 'AzureAD\JaneDoe' -
        # never the UPN. A target written as 'AzureAD\jane@contoso.com' therefore never
        # matches, the account stays an administrator, and the device reports clean. The
        # IdentityStore caches are the only way back to the UPN.
        foreach ($half in $script:Halves) {
            $content = Get-Content -Path $half.Path -Raw
            $content | Should -Match 'S-1-12-1-'   -Because "$($half.Pair)/$($half.Half) must recognise the Entra SID authority"
            $content | Should -Match 'IdentityStore' -Because "$($half.Pair)/$($half.Half) must recover the UPN from the IdentityStore caches"
            $content | Should -Match 'LogonCache'  -Because "$($half.Pair)/$($half.Half) must try LogonCache as well as IdentityCache"
        }
    }

    It 'keeps the duplicated helper functions byte-identical across a pair' {
        # The member enumeration is repeated in both halves, because Intune uploads each script
        # as one standalone file and there is nowhere shared to put it. Duplication that drifts
        # is worse than duplication: detection would match an account the remediation then
        # fails to find, and the device would report remediated forever while the account
        # stayed an administrator.
        #
        # Compared through the AST, so reformatting or moving a function is not a false failure.
        $functionsOf = {
            param($Path)
            $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $null, [ref] $null)
            $map = @{}
            foreach ($f in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
                $map[$f.Name] = $f.Extent.Text
            }
            $map
        }

        foreach ($pair in $script:Pairs) {
            $detect    = Join-Path $pair.FullName 'detect.ps1'
            $remediate = Join-Path $pair.FullName 'remediate.ps1'
            if (-not (Test-Path $remediate)) { continue }

            $a = & $functionsOf $detect
            $b = & $functionsOf $remediate

            $shared = @($a.Keys | Where-Object { $b.ContainsKey($_) })
            @($shared).Count | Should -BeGreaterThan 0 -Because "$($pair.Name) should share its enumeration between the halves"

            foreach ($name in $shared) {
                $a[$name] | Should -Be $b[$name] -Because "$($pair.Name): $name has drifted between detect.ps1 and remediate.ps1"
            }
        }
    }

    It 'keeps the safety rails in the remediation half' {
        $remediate = Get-Content -Path (Join-Path $script:WindowsRoot 'remove-local-admin' 'remediate.ps1') -Raw

        # Removing the last administrator leaves a device that cannot be recovered locally,
        # and a fleet-wide assignment would do it everywhere at once.
        $remediate | Should -Match 'ProtectBuiltInAdministrator'
        $remediate | Should -Match '\-500\$'
        $remediate | Should -Match 'only member'

        # It must re-read the group rather than trust the Remove call: the WinNT provider
        # reports success for a removal that policy quietly undid, and 'fixed' while the
        # account is still an administrator is worse than 'failed'.
        $remediate | Should -Match 'Get-AdminGroupMember \| Where-Object'
    }

    It 'documents every script folder' {
        # These are uploaded by hand into a portal, and the settings they need - SYSTEM,
        # 64-bit, which Intune blade - live nowhere in the script itself.
        foreach ($folder in @(Get-ChildItem -Path $script:IntuneRoot -Directory -Recurse |
                                Where-Object { @(Get-ChildItem $_.FullName -File -Filter '*.ps1') -or
                                               @(Get-ChildItem $_.FullName -File -Filter '*.sh') })) {
            Test-Path (Join-Path $folder.FullName 'README.md') | Should -BeTrue -Because "$($folder.Name) needs a README"
        }
    }

    It 'is documented as writing, unlike every other bundled script' {
        $readme = Get-Content -Path (Join-Path $script:IntuneRoot '..' 'README.md') -Raw
        # Scripts/README.md tells authors bundled scripts are read-only and idempotent. This
        # channel is the exception, and an undocumented exception is how someone assigns a
        # destructive script fleet-wide expecting a report.
        $readme | Should -Match '(?s)Intune/.*(writes|WRITE)'
    }
}