tests/Pipeline.Tests.ps1

#Requires -Version 7.0
#Requires -Modules Pester

<#
.SYNOPSIS
    Pester tests for the unattended pipeline wrapper (src/Invoke-ScoutPipeline.ps1).

.DESCRIPTION
    Invoke-ScoutAssessment (and, transitively, Test-ScoutPermission) is mocked
    throughout -- no Azure call is ever made. Validates:
      - pipeline-summary.json is written with the documented schema keys
      - outcome is computed correctly for Success / PartialSuccess (exporter
        throws mid-run, or the permission audit hard-fails) / Failed (the
        orchestrator returns nothing and creates no run folder)
      - Failed throws (and sets $global:LASTEXITCODE = 1); Success/PartialSuccess
        return the run folder normally
      - non-interactive preferences ($ConfirmPreference / $ProgressPreference) are
        set for the duration of the orchestrator call
      - -SkipPermissionAudit actually skips the -PermissionAudit call
      - -ManagementGroupId / -Category are threaded through to the orchestrator call

    Tracks ADO AB#5050.
#>


BeforeAll {
    $root = Split-Path $PSScriptRoot -Parent
    # Dot-source the real orchestrator purely so Mock has a command with a matching
    # parameter set to attach a proxy to -- its body is never actually executed here.
    . "$root/src/Invoke-ScoutAssessment.ps1"
    . "$root/src/Get-ScoutNewErrorCount.ps1"
    . "$root/src/Invoke-ScoutPipeline.ps1"

    function Get-OkPermissionCheck {
        [pscustomobject]@{ Check = 'ARM Reader @ MG root'; Ok = $true; Fix = 'Assign Reader at the tenant root management group scope.' }
    }
    function Get-FailedPermissionCheck {
        [pscustomobject]@{ Check = 'ARM Reader @ MG root'; Ok = $false; Fix = 'Assign Reader at the tenant root management group scope.' }
    }
}

Describe 'Invoke-ScoutPipeline -- Success path' {
    BeforeEach {
        # A unique folder name per test -- the mock below writes a fixed run-folder
        # name, and leftover folders from an earlier test in the same $TestDrive
        # would otherwise be mistaken for "already existed before this call" by the
        # exception-recovery directory scan.
        $script:outPath = Join-Path $TestDrive "output-$([guid]::NewGuid())"
        $script:seenConfirm  = $null
        $script:seenProgress = $null

        Mock Invoke-ScoutAssessment {
            if ($PermissionAudit) { return @(Get-OkPermissionCheck) }

            $script:seenConfirm  = $ConfirmPreference
            $script:seenProgress = $ProgressPreference

            $folder = Join-Path $OutputPath '20260101_000000'
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            '{}' | Out-File (Join-Path $folder 'collect.json')
            @{ Findings = @(@{ Status = 'Pass' }, @{ Status = 'Pass' }, @{ Status = 'Fail' }, @{ Status = 'Manual' }) } |
                ConvertTo-Json -Depth 5 | Out-File (Join-Path $folder 'findings.json')
            return $folder
        }
    }

    It 'returns the run folder path' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $runFolder | Should -Not -BeNullOrEmpty
        Test-Path $runFolder | Should -BeTrue
    }

    It 'writes pipeline-summary.json with every documented schema key' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $summaryPath = Join-Path $runFolder 'pipeline-summary.json'
        Test-Path $summaryPath | Should -BeTrue

        $summary = Get-Content $summaryPath -Raw | ConvertFrom-Json
        $keys = $summary.PSObject.Properties.Name
        foreach ($expected in 'schemaVersion', 'startedOn', 'finishedOn', 'elapsedSeconds',
                              'assessments', 'formats', 'managementGroupId', 'runFolder',
                              'assessmentStatus', 'findingsByStatus', 'permissionAudit',
                              'assessmentError', 'outcome') {
            $keys | Should -Contain $expected
        }
    }

    It 'writes a human-readable pipeline-summary.md' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        Test-Path (Join-Path $runFolder 'pipeline-summary.md') | Should -BeTrue
    }

    It 'computes outcome Success when the audit passes and the orchestrator returns cleanly' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $summary = Get-Content (Join-Path $runFolder 'pipeline-summary.json') -Raw | ConvertFrom-Json
        $summary.outcome | Should -Be 'Success'
        $summary.assessmentError | Should -BeNullOrEmpty
    }

    It 'computes findings counts by Status from findings.json' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $summary = Get-Content (Join-Path $runFolder 'pipeline-summary.json') -Raw | ConvertFrom-Json
        $summary.findingsByStatus.Pass   | Should -Be 2
        $summary.findingsByStatus.Fail   | Should -Be 1
        $summary.findingsByStatus.Manual | Should -Be 1
    }

    It 'sets non-interactive preferences for the duration of the orchestrator call' {
        Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath | Out-Null
        "$script:seenConfirm"  | Should -Be 'None'
        "$script:seenProgress" | Should -Be 'SilentlyContinue'
    }

    It 'does not throw' {
        { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath } | Should -Not -Throw
    }

    It 'threads -ManagementGroupId and -Category through to the orchestrator call' {
        Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath -ManagementGroupId 'contoso-root-mg' -Category 'Security' | Out-Null
        Should -Invoke Invoke-ScoutAssessment -ParameterFilter {
            -not $PermissionAudit -and $ManagementGroupId -eq 'contoso-root-mg' -and ($Category -contains 'Security')
        } -Times 1
    }
}

Describe 'Invoke-ScoutPipeline -- -SkipPermissionAudit' {
    BeforeEach {
        $script:outPath = Join-Path $TestDrive "output-$([guid]::NewGuid())"
        Mock Invoke-ScoutAssessment {
            if ($PermissionAudit) { return @(Get-OkPermissionCheck) }
            $folder = Join-Path $OutputPath '20260101_000000'
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            '{}' | Out-File (Join-Path $folder 'collect.json')
            return $folder
        }
    }

    It 'never calls Invoke-ScoutAssessment -PermissionAudit' {
        Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath -SkipPermissionAudit | Out-Null
        Should -Invoke Invoke-ScoutAssessment -ParameterFilter { $PermissionAudit } -Times 0 -Exactly
    }

    It 'records the audit as Skipped in the summary' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath -SkipPermissionAudit
        $summary = Get-Content (Join-Path $runFolder 'pipeline-summary.json') -Raw | ConvertFrom-Json
        $summary.permissionAudit.Skipped | Should -BeTrue
        $summary.permissionAudit.Ran     | Should -BeFalse
    }
}

Describe 'Invoke-ScoutPipeline -- PartialSuccess (exporter throws mid-run)' {
    BeforeEach {
        $script:outPath = Join-Path $TestDrive "output-$([guid]::NewGuid())"
        Mock Invoke-ScoutAssessment {
            if ($PermissionAudit) { return @(Get-OkPermissionCheck) }
            # Simulate the orchestrator getting partway through (collect.json written)
            # before an exporter throws -- the run folder exists but is incomplete.
            $folder = Join-Path $OutputPath '20260101_000000'
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            '{}' | Out-File (Join-Path $folder 'collect.json')
            throw 'Export-Pptx: simulated exporter failure'
        }
    }

    It 'does not throw -- degrades to PartialSuccess instead of killing the run' {
        { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath } | Should -Not -Throw
    }

    It 'recovers the run folder the orchestrator had already created before it threw' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $runFolder | Should -Not -BeNullOrEmpty
        Test-Path (Join-Path $runFolder 'collect.json') | Should -BeTrue
    }

    It 'computes outcome PartialSuccess and captures the error message' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $summary = Get-Content (Join-Path $runFolder 'pipeline-summary.json') -Raw | ConvertFrom-Json
        $summary.outcome | Should -Be 'PartialSuccess'
        $summary.assessmentError | Should -Match 'simulated exporter failure'
    }
}

Describe 'Invoke-ScoutPipeline -- PartialSuccess (permission audit hard failure)' {
    BeforeEach {
        $script:outPath = Join-Path $TestDrive "output-$([guid]::NewGuid())"
        Mock Invoke-ScoutAssessment {
            if ($PermissionAudit) { return @(Get-FailedPermissionCheck) }
            $folder = Join-Path $OutputPath '20260101_000000'
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            '{}' | Out-File (Join-Path $folder 'collect.json')
            @{ Findings = @(@{ Status = 'Pass' }) } | ConvertTo-Json -Depth 5 | Out-File (Join-Path $folder 'findings.json')
            return $folder
        }
    }

    It 'does not throw even when the permission audit hard-fails' {
        { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath } | Should -Not -Throw
    }

    It 'computes outcome PartialSuccess despite a clean orchestrator run' {
        $runFolder = Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath
        $summary = Get-Content (Join-Path $runFolder 'pipeline-summary.json') -Raw | ConvertFrom-Json
        $summary.outcome | Should -Be 'PartialSuccess'
        $summary.permissionAudit.Ok | Should -BeFalse
    }
}

Describe 'Invoke-ScoutPipeline -- Failed (assess returns nothing)' {
    BeforeEach {
        $script:outPath = Join-Path $TestDrive "output-$([guid]::NewGuid())"
        Mock Invoke-ScoutAssessment {
            if ($PermissionAudit) { return @(Get-OkPermissionCheck) }
            # Simulate total failure: no folder created, nothing returned.
            return $null
        }
    }

    It 'throws' {
        { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath } | Should -Throw
    }

    It 'sets $global:LASTEXITCODE to 1' {
        try { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath }
        catch { Write-Verbose "Expected failure captured: $_" -Verbose:$false }
        $global:LASTEXITCODE | Should -Be 1
    }

    It 'the thrown message mentions the run produced no output' {
        { Invoke-ScoutPipeline -Assessment LandingZone -OutputPath $script:outPath } | Should -Throw -ExpectedMessage '*produced no output*'
    }
}