tests/RunIsolation.Tests.ps1

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
.SYNOPSIS
    Pester tests for run isolation, subscription context restore, and the management
    group access probe.

.DESCRIPTION
    Covers AB#331 (non-destructive cache), AB#368 (cross-subscription context switching
    with restore) and AB#351 (post-login management group access probe).
    No live Azure authentication is required — all Az cmdlets are mocked.

.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
#>


BeforeAll {
    $script:ModuleRoot = Split-Path -Parent $PSScriptRoot
    $script:MainPath   = Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main'
    $script:TempDir    = Join-Path $env:TEMP 'AZSC_RunIsolationTests'

    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
    New-Item -ItemType Directory -Path $script:TempDir -Force | Out-Null

    . (Join-Path $script:MainPath 'Set-AZTIReportPath.ps1')
    . (Join-Path $script:MainPath 'Clear-AZTICacheFolder.ps1')
    . (Join-Path $script:MainPath 'Invoke-AZTISubscriptionContext.ps1')
    . (Join-Path $script:MainPath 'Test-AZTIManagementGroupAccess.ps1')
}

AfterAll {
    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
}

Describe 'Set-AZSCReportPath — run isolation (AB#331)' {

    It 'Places each run in its own folder under the base path by default' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir
        $result.BasePath    | Should -Be $script:TempDir
        $result.DefaultPath | Should -Not -Be $script:TempDir
        $result.DefaultPath | Should -BeLike "$script:TempDir*"
        $result.RunFolder   | Should -Not -BeNullOrEmpty
    }

    It 'Names the generated run folder with a sortable timestamp' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir
        $result.RunFolder | Should -Match '^\d{4}-\d{2}-\d{2}_\d{6}'
    }

    It 'Nests DiagramCache and ReportCache inside the run folder, not the base path' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir
        $result.DiagramCache | Should -Be (Join-Path $result.DefaultPath 'DiagramCache')
        $result.ReportCache  | Should -Be (Join-Path $result.DefaultPath 'ReportCache')
    }

    It 'Does not reuse a previous run folder — two runs never collide' {
        # A same-second second call would produce an identical timestamp, so drive the
        # two runs through -RunName to assert the isolation contract deterministically.
        $first  = Set-AZSCReportPath -ReportDir $script:TempDir -RunName 'tenantA'
        $second = Set-AZSCReportPath -ReportDir $script:TempDir -RunName 'tenantB'
        $first.DefaultPath | Should -Not -Be $second.DefaultPath
    }

    It 'Uses the supplied RunName as the folder name' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -RunName 'Production-TenantA'
        $result.RunFolder   | Should -Be 'Production-TenantA'
        $result.DefaultPath | Should -Be (Join-Path $script:TempDir 'Production-TenantA')
    }

    It 'Replaces invalid path characters in a RunName' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -RunName 'bad:name*here?'
        $result.RunFolder | Should -Not -Match '[:*?]'
        $result.RunFolder | Should -Be 'bad-name-here-'
    }

    It 'Sanitizes rather than discards a RunName made only of invalid characters' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -RunName '???'
        $result.RunFolder | Should -Be '---'
    }

    It 'Falls back to a timestamp when RunName is whitespace only' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -RunName ' '
        $result.RunFolder | Should -Match '^\d{4}-\d{2}-\d{2}_\d{6}$'
    }

    It 'Appends a truncated scope identifier to the generated folder name' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -ScopeId '12345678-aaaa-bbbb-cccc-dddddddddddd'
        $result.RunFolder | Should -Match '^\d{4}-\d{2}-\d{2}_\d{6}_12345678$'
    }

    It 'Writes into the base path directly when -Force is supplied' {
        $result = Set-AZSCReportPath -ReportDir $script:TempDir -Force
        $result.DefaultPath  | Should -Be $script:TempDir
        $result.RunFolder    | Should -BeNullOrEmpty
        $result.ReportCache  | Should -Be (Join-Path $script:TempDir 'ReportCache')
    }

    It 'Still resolves a default base path when no ReportDir is given' {
        $result = Set-AZSCReportPath -ReportDir $null -Force
        $result.DefaultPath | Should -Not -BeNullOrEmpty
        $result.DefaultPath | Should -BeLike '*AzureScout*'
    }
}

Describe 'Clear-AZSCCacheFolder — prune mode (AB#331)' {

    BeforeEach {
        $script:PruneBase = Join-Path $script:TempDir 'prune'
        if (Test-Path $script:PruneBase) { Remove-Item $script:PruneBase -Recurse -Force }
        New-Item -ItemType Directory -Path $script:PruneBase -Force | Out-Null

        $script:OldRun = Join-Path $script:PruneBase '2020-01-01_000000'
        $script:NewRun = Join-Path $script:PruneBase '2026-07-25_120000'
        New-Item -ItemType Directory -Path $script:OldRun -Force | Out-Null
        New-Item -ItemType Directory -Path $script:NewRun -Force | Out-Null
        (Get-Item $script:OldRun).LastWriteTime = (Get-Date).AddDays(-90)
    }

    It 'Removes run folders older than the cutoff' {
        Clear-AZSCCacheFolder -OlderThan 30 -BasePath $script:PruneBase
        $script:OldRun | Should -Not -Exist
    }

    It 'Keeps run folders newer than the cutoff' {
        Clear-AZSCCacheFolder -OlderThan 30 -BasePath $script:PruneBase
        $script:NewRun | Should -Exist
    }

    It 'Does not throw when the base path does not exist' {
        { Clear-AZSCCacheFolder -OlderThan 30 -BasePath (Join-Path $script:TempDir 'nope') } | Should -Not -Throw
    }

    It 'Still clears a single cache folder in the original mode' {
        $cache = Join-Path $script:TempDir 'legacy_cache'
        New-Item -ItemType Directory -Path $cache -Force | Out-Null
        'x' | Out-File (Join-Path $cache 'a.json')
        'y' | Out-File (Join-Path $cache 'b.json')

        Clear-AZSCCacheFolder -ReportCache $cache

        @(Get-ChildItem -Path $cache -File -Recurse).Count | Should -Be 0
    }

    It 'Does not throw when the cache folder does not exist' {
        { Clear-AZSCCacheFolder -ReportCache (Join-Path $script:TempDir 'missing_cache') } | Should -Not -Throw
    }
}

Describe 'Invoke-AZSCInSubscriptionContext — context restore (AB#368)' {

    BeforeAll {
        $script:Subs = @(
            [PSCustomObject]@{ Id = 'sub-1'; Name = 'One' }
            [PSCustomObject]@{ Id = 'sub-2'; Name = 'Two' }
            [PSCustomObject]@{ Id = 'sub-3'; Name = 'Three' }
        )
    }

    It 'Switches context once per subscription and restores once at the end' {
        Mock Get-AzContext { [PSCustomObject]@{ Subscription = [PSCustomObject]@{ Id = 'original-sub' } } }
        Mock Set-AzContext { }

        Invoke-AZSCInSubscriptionContext -Subscription $script:Subs -Process { param($s) $null = $s }

        Should -Invoke Set-AzContext -Times 3 -Exactly -ParameterFilter { $SubscriptionId -in 'sub-1','sub-2','sub-3' }
        Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'original-sub' }
    }

    It 'Runs the process block once per subscription' {
        Mock Get-AzContext { [PSCustomObject]@{ Subscription = [PSCustomObject]@{ Id = 'original-sub' } } }
        Mock Set-AzContext { }

        $seen = [System.Collections.Generic.List[string]]::new()
        Invoke-AZSCInSubscriptionContext -Subscription $script:Subs -Process { param($s) $seen.Add($s.Id) }

        $seen.Count | Should -Be 3
        $seen -join ',' | Should -Be 'sub-1,sub-2,sub-3'
    }

    It 'Restores the original context when the loop throws part way through' {
        Mock Get-AzContext { [PSCustomObject]@{ Subscription = [PSCustomObject]@{ Id = 'original-sub' } } }
        Mock Set-AzContext { }

        {
            Invoke-AZSCInSubscriptionContext -Subscription $script:Subs -Process {
                param($s)
                if ($s.Id -eq 'sub-2') { throw 'boom' }
            }
        } | Should -Throw

        Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'original-sub' }
    }

    It 'Accepts plain subscription ID strings' {
        Mock Get-AzContext { [PSCustomObject]@{ Subscription = [PSCustomObject]@{ Id = 'original-sub' } } }
        Mock Set-AzContext { }

        Invoke-AZSCInSubscriptionContext -Subscription @('sub-a', 'sub-b') -Process { param($s) $null = $s }

        Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'sub-a' }
        Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'sub-b' }
    }

    It 'Does not attempt a restore when there was no original context' {
        Mock Get-AzContext { $null }
        Mock Set-AzContext { }

        Invoke-AZSCInSubscriptionContext -Subscription @('sub-a') -Process { param($s) $null = $s }

        Should -Invoke Set-AzContext -Times 1 -Exactly
    }

    It 'Skips entries with no resolvable subscription Id' {
        Mock Get-AzContext { $null }
        Mock Set-AzContext { }

        Invoke-AZSCInSubscriptionContext -Subscription @([PSCustomObject]@{ Name = 'no-id' }) -Process { param($s) $null = $s }

        Should -Invoke Set-AzContext -Times 0 -Exactly
    }
}

Describe 'Restore-AZSCContext (AB#368)' {

    It 'Is a no-op for a null context' {
        Mock Set-AzContext { }
        { Restore-AZSCContext -Context $null } | Should -Not -Throw
        Should -Invoke Set-AzContext -Times 0 -Exactly
    }

    It 'Is a no-op for a context with no subscription' {
        Mock Set-AzContext { }
        Restore-AZSCContext -Context ([PSCustomObject]@{ Subscription = $null })
        Should -Invoke Set-AzContext -Times 0 -Exactly
    }

    It 'Restores a valid context' {
        Mock Set-AzContext { }
        Restore-AZSCContext -Context ([PSCustomObject]@{ Subscription = [PSCustomObject]@{ Id = 'restore-me' } })
        Should -Invoke Set-AzContext -Times 1 -Exactly -ParameterFilter { $SubscriptionId -eq 'restore-me' }
    }
}

Describe 'Test-AZSCManagementGroupAccess — post-login probe (AB#351)' {

    It 'Reports the management group count when access succeeds' {
        Mock Get-AzManagementGroup {
            @(
                [PSCustomObject]@{ Name = 'mg-root' }
                [PSCustomObject]@{ Name = 'mg-prod' }
            )
        }

        $result = Test-AZSCManagementGroupAccess

        $result.HasAccess | Should -BeTrue
        $result.Count     | Should -Be 2
        $result.Message   | Should -Match '2'
    }

    It 'Emits a cyan Management Group Reader tip on AuthorizationFailed' {
        Mock Get-AzManagementGroup { throw "AuthorizationFailed: the client does not have authorization" }
        Mock Write-Host { }

        $null = Test-AZSCManagementGroupAccess

        Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter {
            $ForegroundColor -eq 'Cyan' -and $Object -match "Management Group Reader"
        }
    }

    It 'Does not throw when the probe fails' {
        Mock Get-AzManagementGroup { throw "AuthorizationFailed" }
        Mock Write-Host { }

        { Test-AZSCManagementGroupAccess } | Should -Not -Throw
    }

    It 'Returns a zero count and no access when the probe fails' {
        Mock Get-AzManagementGroup { throw "AuthorizationFailed" }
        Mock Write-Host { }

        $result = Test-AZSCManagementGroupAccess

        $result.HasAccess | Should -BeFalse
        $result.Count     | Should -Be 0
    }

    It 'Stays quiet for a non-authorization failure — no role tip to give' {
        Mock Get-AzManagementGroup { throw "The request was throttled" }
        Mock Write-Host { }

        $result = Test-AZSCManagementGroupAccess

        $result.HasAccess | Should -BeFalse
        Should -Invoke Write-Host -Times 0 -Exactly
    }
}