tests/BugAudit.Tests.ps1

# Regression tests for the bugs found in the 011 exhaustive audit. Each Describe names the real
# defect it locks down, so a future change that reintroduces it fails here instead of in a user's
# terminal (or, for the uninstaller one, on a user's registry).

$moduleRoot = Split-Path -Parent $PSScriptRoot
Import-Module (Join-Path $moduleRoot 'Octa.psd1') -Force
. (Join-Path $moduleRoot 'private\RunRecord.ps1')
. (Join-Path $moduleRoot 'private\StateSnapshot.ps1')
. (Join-Path $moduleRoot 'public\Invoke-OctaUninstaller.ps1')
. (Join-Path $moduleRoot 'public\Invoke-OctaDiskCleanup.ps1')
# Dot-sourced so Pester's Mock (which only sees the test scope, not the module's own session
# state) can substitute them for the restore-point Describe below.
. (Join-Path $moduleRoot 'private\ActionHelpers.ps1')
. (Join-Path $moduleRoot 'private\ElevationCheck.ps1')
. (Join-Path $moduleRoot 'private\StatusPanel.ps1')
. (Join-Path $moduleRoot 'private\RestorePoint.ps1')
. (Join-Path $moduleRoot 'private\Language.ps1')
. (Join-Path $moduleRoot 'public\Get-OctaCategory.ps1')
. (Join-Path $moduleRoot 'public\Invoke-OctaCategory.ps1')
. (Join-Path $moduleRoot 'public\Invoke-OctaScheduler.ps1')
# The dot-sourced Get-OctaCategory resolves each category by calling its metadata function by
# name, so those have to exist in this scope too - without them it throws CommandNotFoundException
# instead of returning the catalog.
Get-ChildItem (Join-Path $moduleRoot 'categories\*.ps1') | ForEach-Object { . $_.FullName }

Describe 'Uninstaller leftovers never target a still-installed vendor (011 audit)' {
    # The real defect: leftover detection matched on Publisher alone, so uninstalling one of the
    # 17 programs shipping as Publisher 'NVIDIA Corporation' on a real machine offered to delete
    # HKLM:\SOFTWARE\NVIDIA Corporation and %ProgramData%\NVIDIA Corporation - shared by the
    # graphics driver and the 16 other NVIDIA programs still installed. Publisher 'Microsoft'
    # would have offered HKLM:\SOFTWARE\Microsoft, i.e. Windows itself.
    It 'ignores a Publisher that another installed program still uses' {
        $installed = @(Get-OctaInstalledPrograms)
        $sharedPublisher = $installed | Group-Object Publisher |
            Where-Object { $_.Name -and $_.Count -gt 1 } | Select-Object -First 1

        if (-not $sharedPublisher) {
            Set-ItResult -Skipped -Because 'no vendor ships more than one program on this machine'
            return
        }

        $markerPath = Get-OctaUninstallMarkerPath
        $backup = if (Test-Path $markerPath) { Get-Content $markerPath -Raw } else { $null }
        try {
            [pscustomobject]@{
                DisplayName     = 'Octa Audit Fake Program'
                Publisher       = $sharedPublisher.Name
                InstallLocation = $null
            } | ConvertTo-Json | Set-Content -Path $markerPath -Encoding utf8

            $output = Invoke-OctaUninstaller -CleanLeftovers 6>&1 | Out-String
            $output | Should Not Match ([regex]::Escape($sharedPublisher.Name))
        }
        finally {
            if ($backup) { Set-Content -Path $markerPath -Value $backup -Encoding utf8 }
            elseif (Test-Path $markerPath) { Remove-Item $markerPath -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'Disk cleanup reports bytes actually freed, not bytes measured (011 audit)' {
    # The real defect: FreedBytes summed each target's pre-scan MeasuredBytes, while the deletion
    # ran with -ErrorAction SilentlyContinue. A locked file was skipped and still counted as
    # reclaimed - measured for real: a 1 MB open file survived and Octa reported 1 MB freed.
    It 'excludes files that survived the delete from the freed total' {
        $dir = Join-Path $env:TEMP "octa-audit-$([guid]::NewGuid().ToString('N'))"
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        $stream = $null
        try {
            [System.IO.File]::WriteAllBytes((Join-Path $dir 'gone.bin'), (New-Object byte[] 262144))
            [System.IO.File]::WriteAllBytes((Join-Path $dir 'kept.bin'), (New-Object byte[] 262144))

            $measured = Measure-OctaPathBytes -Paths @("$dir\*")
            $stream = [System.IO.File]::Open((Join-Path $dir 'kept.bin'), 'Open', 'Read', 'None')
            Remove-Item -Path "$dir\*" -Recurse -Force -ErrorAction SilentlyContinue
            $remaining = Measure-OctaPathBytes -Paths @("$dir\*")

            $freed = [Math]::Max(0L, [int64]$measured - [int64]$remaining)
            $measured | Should Be 524288
            $remaining | Should Be 262144
            $freed | Should Be 262144
        }
        finally {
            if ($stream) { $stream.Dispose() }
            Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

Describe 'Undo reports failure honestly (011 audit)' {
    # The real defect: the run was marked 'Undone' and Status 'Success' returned unconditionally,
    # even when every restore threw - which then made a retry answer 'AlreadyUndone', permanently
    # blocking an undo that never happened.
    $runsRoot = Get-OctaRunsRoot
    $runId = '20991231-235959'
    $runFolder = Join-Path $runsRoot $runId

    BeforeEach {
        New-Item -ItemType Directory -Path $runFolder -Force | Out-Null
        [pscustomobject]@{
            RunId                  = $runId
            Timestamp              = (Get-Date).ToString('o')
            CategoriesApplied      = @('telemetry')
            ActionSnapshots        = @(
                [pscustomobject]@{
                    ActionRef = 'OctaAuditMissingService'
                    Type      = 'Service'
                    State     = [pscustomobject]@{ Type = 'Service'; Name = 'OctaAuditNoSuchService'; PriorStartMode = 'Auto' }
                }
            )
            IrreversibleActionRefs = @()
            Status                 = 'Completed'
        } | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $runFolder 'run.json') -Encoding utf8
    }

    AfterEach {
        Remove-Item $runFolder -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'returns Failed - not Success - when nothing could be restored' {
        $result = Undo-OctaRun -RunId $runId 6>$null
        $result.Status | Should Be 'Failed'
        @($result.Restored).Count | Should Be 0
    }

    It 'still allows a retry instead of answering AlreadyUndone' {
        Undo-OctaRun -RunId $runId 6>$null | Out-Null
        $second = Undo-OctaRun -RunId $runId 6>$null
        $second.Status | Should Not Be 'AlreadyUndone'
    }
}

Describe 'Run folders are unique within the same second (011 audit)' {
    # The real defect: the id is second-resolution, so two runs started in the same second shared
    # one folder and the second run's run.json overwrote the first's - silently destroying the
    # first run's ability to be undone. Verified real: two back-to-back calls returned one id.
    It 'never hands out the same RunId twice' {
        $created = @()
        try {
            $created = @(1..3 | ForEach-Object { New-OctaRunFolder })
            @($created.RunId | Select-Object -Unique).Count | Should Be 3
        }
        finally {
            foreach ($c in $created) { Remove-Item $c.Path -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'keeps ids sortable so Get-OctaRunRecord -RunId latest still resolves newest-last' {
        $created = @()
        try {
            $created = @(1..3 | ForEach-Object { New-OctaRunFolder })
            $sorted = @($created.RunId | Sort-Object)
            $sorted[-1] | Should Be $created[-1].RunId
        }
        finally {
            foreach ($c in $created) { Remove-Item $c.Path -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}

Describe 'History survives an unreadable run record (011 audit)' {
    # The real defect: one malformed run.json threw out of ConvertFrom-Json and took the entire
    # history listing with it.
    It 'skips the bad record instead of throwing' {
        $badRun = Join-Path (Get-OctaRunsRoot) '20990101-010101'
        New-Item -ItemType Directory -Path $badRun -Force | Out-Null
        Set-Content -Path (Join-Path $badRun 'run.json') -Value '{ not valid json' -Encoding utf8
        try {
            { Show-OctaHistory 6>$null } | Should Not Throw
        }
        finally {
            Remove-Item $badRun -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

Describe 'Read-only tools fail loudly, not silently (011 audit)' {
    It 'Show-OctaDiskUsage reports a missing path instead of an empty result' {
        $result = Show-OctaDiskUsage -Path 'C:\OctaAuditPathThatDoesNotExist' 6>$null
        $result.Status | Should Be 'PathNotFound'
    }

    It 'Invoke-OctaScheduler refuses an unknown category before registering a task' {
        $result = Invoke-OctaScheduler -Create -Category 'octa-audit-not-a-category' -Frequency 'Daily' 6>$null
        $result.Status | Should Be 'UnknownCategory'
    }

    It 'Invoke-OctaTaskBrowser reports NotFound for a task that does not exist' {
        $result = Invoke-OctaTaskBrowser -Disable '\OctaAudit\NoSuchTask' 6>$null
        $result.Status | Should Be 'NotFound'
    }
}

Describe 'Wi-Fi profile names keep their colons (011 audit)' {
    # The real defect: the regex was greedy, so it split on the LAST colon - a profile named
    # "Red: Casa" came back as "Casa", which then failed its own -notin $known lookup.
    It 'splits on the first colon, matching the documented behaviour' {
        $line = ' All User Profile : Red: Casa'
        $line -match '^\s+.+?:\s*(\S.*)$' | Should Be $true
        $Matches[1].Trim() | Should Be 'Red: Casa'
    }
}

Describe 'Restore-point warning is shown once, not twice (011 audit)' {
    # The real defect, captured verbatim in a terminal screenshot: declining "Enable System
    # Protection?" printed the identical "System Protection is off" warning a second time and
    # asked a second confirm prompt, because the offer-to-enable block and the proceed-anyway
    # block both emitted the same string for the same status.
    #
    # Fully synthetic category so this touches nothing real on the machine running the tests -
    # no registry write, no service change, no restore point.
    function Get-OctaAuditFakeActions {
        return @(New-OctaAction -TargetType Registry -TargetIdentifier 'HKCU:\Software\OctaAudit|FakeValue' `
                -CurrentValue '(not set)' -PlannedValue 1 -Reversible $true)
    }
    function Set-OctaAuditFakeAction { param($Action) }

    It 'prints the warning exactly once and asks to proceed exactly once' {
        Mock Get-OctaCategory {
            [pscustomobject]@{
                Id                          = 'octa-audit-fake'
                DisplayName                 = 'Octa Audit Fake'
                Description                 = 'synthetic'
                RequiresElevation           = $false
                ContainsIrreversibleActions = $false
                GetActionsFunction          = 'Get-OctaAuditFakeActions'
                ApplyActionFunction         = 'Set-OctaAuditFakeAction'
                HighestRiskLevel            = 'Safe'
            }
        }
        Mock New-OctaRestorePoint { [pscustomobject]@{ Status = 'SkippedProtectionDisabled'; SequenceNumber = $null } }
        Mock Save-OctaActionSnapshot { $null }
        Mock Save-OctaRunRecord { }
        Mock New-OctaRunFolder { [pscustomobject]@{ RunId = 'audit'; Path = $env:TEMP } }
        Mock Read-Host {
            if ($Prompt -match 'Enable System Protection') { return 'n' }
            return 's'
        }

        $strings = Get-OctaStrings
        $warning = $strings.restorePointSkippedProtectionDisabled

        $output = Invoke-OctaCategory -CategoryId 'octa-audit-fake' -Apply 6>&1 | Out-String
        $warningCount = ([regex]::Matches($output, [regex]::Escape($warning))).Count

        $warningCount | Should Be 1
        Assert-MockCalled Read-Host -Times 1 -Exactly -ParameterFilter { $Prompt -match 'Enable System Protection' }
    }
}

Describe 'Recycle Bin measurement does not overflow for large files (011 audit)' {
    # The real defect, found while re-verifying the earlier "flaky" DiskHealth non-mutation fix
    # on this same machine: Shell.Application's Namespace(10).Items().Size is a signed Int32,
    # which overflows negative for any single Recycle Bin item over 2 GB. Measured for real: a
    # Recycle Bin holding a ~4 GB Windows ISO and several 1+ GB files summed to
    # -1,052,385,010 bytes via the COM property, and 25,916,231,884 (the correct figure) via
    # direct filesystem measurement - which then fed into the overall "Total reclaimable" GB
    # figure Octa shows the user.
    It 'never reports negative bytes for any cleanup target' {
        $targets = @(Get-OctaCleanupTargets)
        $negative = @($targets | Where-Object { $_.MeasuredBytes -lt 0 })
        $negative.Count | Should Be 0
    }

    It 'measures the Recycle Bin the same way as a direct filesystem scan' {
        $viaOcta = Measure-OctaRecycleBinBytes
        $direct = 0L
        foreach ($drive in @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3')) {
            $p = Join-Path $drive.DeviceID '$Recycle.Bin'
            if (Test-Path -LiteralPath $p) {
                $direct += (Get-ChildItem -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue |
                        Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
            }
        }
        $viaOcta | Should Be $direct
    }
}

Describe 'Scheduler reports a failed registration as failed (011 audit)' {
    # The real defect behind the "flaky" Monthly test: Register-ScheduledTask only raises a
    # NON-terminating error, so execution fell straight through to the Success return. A Monthly
    # task that never registered was reported as created, with a TaskName that could not then be
    # found or removed.
    It 'returns Error, not Success, when registration fails' {
        Mock Register-ScheduledTask { throw 'simulated registration failure' }
        $result = Invoke-OctaScheduler -Create -Category 'telemetry' -Frequency 'Daily' 6>$null
        $result.Status | Should Be 'Error'
    }
}

Describe 'Scheduler Monthly trigger genuinely registers (011 audit)' {
    # Not a flake: MSFT_TaskMonthlyTrigger built via CIM was rejected by Register-ScheduledTask
    # with E_INVALIDARG on every variant tried (plain assignment, explicit [uint16] casts,
    # RunOnLastDayOfMonth, timezone-qualified StartBoundary, New-ScheduledTask + -InputObject).
    # It failed 100% of the time. Registering the schedule as task XML works.
    It 'creates a real day-1-of-every-month task that can be listed and removed' {
        if (-not (Test-OctaElevation)) {
            Set-ItResult -Skipped -Because 'registering a scheduled task requires elevation'
            return
        }
        $created = Invoke-OctaScheduler -Create -Category 'telemetry' -Frequency 'Monthly' 6>$null
        try {
            $created.Status | Should Be 'Success'

            $found = Get-ScheduledTask -TaskPath '\Octa\' -TaskName $created.TaskName -ErrorAction SilentlyContinue
            $found | Should Not Be $null

            $doc = [xml](Export-ScheduledTask -TaskPath '\Octa\' -TaskName $created.TaskName)
            $ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
            $ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')

            # A genuine calendar-monthly schedule, not an "every 4 weeks" approximation.
            @($doc.SelectNodes('//t:ScheduleByMonth/t:DaysOfMonth/t:Day', $ns)).Count | Should Be 1
            @($doc.SelectNodes('//t:ScheduleByMonth/t:Months/*', $ns)).Count | Should Be 12
        }
        finally {
            if ($created.TaskName) {
                Invoke-OctaScheduler -Remove $created.TaskName 6>$null | Out-Null
            }
        }
    }
}

Describe 'Run records describe the restore point honestly (011 audit)' {
    # Tools that never attempt a restore point recorded 'SkippedByUser', which describes a
    # decision the user was never asked to make.
    It 'never claims the user skipped a restore point that was never offered' {
        $offenders = @(
            Get-ChildItem (Join-Path $moduleRoot 'public\*.ps1') | Where-Object {
                (Get-Content $_.FullName -Raw) -match "RestorePointStatus\s*=\s*'SkippedByUser'"
            }
        )
        $offenders.Count | Should Be 0
    }
}

Describe 'ExplorerStart never offers an HKLM-only action without elevation (011 audit, second pass)' {
    # Real defect: "Include in library" and "Give access to" context-menu-handler keys exist
    # ONLY under HKLM\SOFTWARE\Classes on a real machine, never under HKCU\Software\Classes.
    # Measured directly: BUILTIN\Usuarios (the standard Users group) has ReadKey only on both,
    # no delete/write right at all - yet the whole category is RequiresElevation = $false,
    # because most of its OTHER actions genuinely are per-user HKCU values. Without gating these
    # two specifically, a non-elevated run offered to remove them, the delete then failed
    # silently (-ErrorAction SilentlyContinue), and the category still reported Success.
    It 'drops HKCR-only actions when not elevated, keeps genuine HKCU ones' {
        Mock Test-OctaElevation { return $false }
        $actions = @(Get-OctaExplorerStartActions)
        $hklmOnly = @($actions | Where-Object { $_.TargetIdentifier -like 'HKCR:*' })
        foreach ($a in $hklmOnly) {
            $userOverride = $a.TargetIdentifier -replace '^HKCR:', 'HKCU:\Software\Classes'
            $userOverride = ($userOverride -split '\|', 2)[0]
            (Test-Path -LiteralPath $userOverride) | Should Be $true
        }
    }

    It 'still offers every action when elevated' {
        Mock Test-OctaElevation { return $true }
        $elevated = @(Get-OctaExplorerStartActions)
        Mock Test-OctaElevation { return $false }
        $nonElevated = @(Get-OctaExplorerStartActions)
        $nonElevated.Count | Should BeLessThan ($elevated.Count + 1)
        $elevated.Count | Should BeGreaterThan 0
    }
}

Describe 'OemSuites AURA pattern does not match unrelated words (011 audit, second pass)' {
    # Real defect: bare "AURA" is an unanchored, case-insensitive substring match, so it matched
    # inside the Spanish word "restauracion" (as in "restore"). Verified on a real Spanish-
    # language Windows install: Microsoft's own "Cloud backup and restore" service -
    # "Servicio de copia de seguridad y restauracion en la nube" - got hardcoded-pattern-matched
    # as ASUS bloat, through the branch with NO separate confirmation gate (unlike a
    # HeuristicMatch hit).
    It 'does not match a Spanish word that merely contains the substring AURA' {
        $pattern = ($script:OctaOemHardcodedPatterns | Where-Object { $_.Vendor -eq 'ASUS' }).Pattern
        'Servicio de copia de seguridad y restauracion en la nube' -match $pattern | Should Be $false
    }

    It 'still matches the real ASUS AURA Sync lighting service' {
        $pattern = ($script:OctaOemHardcodedPatterns | Where-Object { $_.Vendor -eq 'ASUS' }).Pattern
        'ASUS AURA SYNC lighting service' -match $pattern | Should Be $true
    }
}

Describe 'Services category scan completes quickly (011 audit, second pass)' {
    # Real defect, found while re-running the full suite after the ExplorerStart/OemSuites
    # fixes above (unrelated to them - the code had already been like this): one -Filter
    # "Name='X'" CIM query per catalog entry (35 of them) took ~10.1s total on a real machine,
    # right at Categories.Tests.ps1's own 10-second "completes quickly" threshold - measured
    # ~290ms of WQL overhead per filtered call, vs 0.47s to fetch every service in one
    # unfiltered call. Same root cause as the earlier Get-OctaCategory startup-delay fix: many
    # small expensive calls instead of one cheap bulk one.
    It 'scans the whole 35-entry catalog in well under a second' {
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        Get-OctaServicesActions | Out-Null
        $sw.Stop()
        $sw.Elapsed.TotalSeconds | Should BeLessThan 3
    }
}

Describe 'Category apply functions use the action PlannedValue (011 audit)' {
    # Copilot/Recall hardcoded "-Value 1" instead of reading PlannedValue, so a future change to
    # the planned value would be silently ignored at apply time while the preview still showed it.
    It 'has no category writing a hardcoded DWord instead of PlannedValue' {
        $offenders = @(
            Get-ChildItem (Join-Path $moduleRoot 'categories\*.ps1') | Where-Object {
                (Get-Content $_.FullName -Raw) -match 'Set-ItemProperty[^\r\n]*-Value \d+ -Type DWord'
            }
        )
        $offenders.Count | Should Be 0
    }
}