tests/DevOps.Module.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for the Azure DevOps extraction and its five collector modules.

.DESCRIPTION
    Covers AB#327. The extraction is exercised against a mocked Azure DevOps REST API;
    the collector modules are exercised in both the Processing and Reporting phases.
    No live Azure or Azure DevOps authentication is required.

.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
#>


BeforeAll {
    $script:ModuleRoot     = Split-Path -Parent $PSScriptRoot
    $script:ExtractionPath = Join-Path $script:ModuleRoot 'Modules' 'Private' 'Extraction'
    $script:ModulePath     = Join-Path $script:ModuleRoot 'Modules' 'Public' 'InventoryModules' 'Management'

    . (Join-Path $script:ExtractionPath 'Start-AZTIDevOpsExtraction.ps1')

    # Pseudo-resources in the shape Start-AZSCDevOpsExtraction produces, for the
    # collector-module tests.
    $script:DevOpsResources = @(
        [PSCustomObject]@{
            id = 'contoso/proj-1'; name = 'Payments'; type = 'devops/projects'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                id = 'proj-1'; description = 'Payments platform'; state = 'wellFormed'
                visibility = 'private'; revision = 42; lastUpdateTime = '2026-07-01T10:00:00Z'
                url = 'https://dev.azure.com/contoso/_apis/projects/proj-1'
            }
        }
        [PSCustomObject]@{
            id = 'contoso/1'; name = 'payments-ci'; type = 'devops/pipelines'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                id = 1; revision = 3; projectName = 'Payments'; folder = '\'
                url = 'https://dev.azure.com/contoso/_apis/pipelines/1'
                configuration = [PSCustomObject]@{ type = 'yaml'; path = '/azure-pipelines.yml' }
            }
        }
        [PSCustomObject]@{
            id = 'contoso/ep-1'; name = 'prod-arm'; type = 'devops/serviceconnections'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                type = 'azurerm'; projectName = 'Payments'; isShared = $false; isReady = $true
                data = [PSCustomObject]@{ subscriptionId = 'sub-in-scope'; subscriptionName = 'Production' }
                authorization = [PSCustomObject]@{
                    scheme = 'WorkloadIdentityFederation'
                    parameters = [PSCustomObject]@{ serviceprincipalid = 'spn-guid' }
                }
            }
        }
        [PSCustomObject]@{
            id = 'contoso/ep-2'; name = 'legacy-arm'; type = 'devops/serviceconnections'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                type = 'azurerm'; projectName = 'Payments'; isShared = $false; isReady = $true
                data = [PSCustomObject]@{ subscriptionId = 'sub-elsewhere'; subscriptionName = 'Other' }
                authorization = [PSCustomObject]@{
                    scheme = 'ServicePrincipal'
                    parameters = [PSCustomObject]@{ serviceprincipalid = 'spn-old' }
                }
            }
        }
        [PSCustomObject]@{
            id = 'contoso/repo-1'; name = 'payments-api'; type = 'devops/repositories'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                projectName = 'Payments'; defaultBranch = 'refs/heads/main'
                size = 5242880; isDisabled = $false
                webUrl = 'https://dev.azure.com/contoso/Payments/_git/payments-api'
            }
        }
        [PSCustomObject]@{
            id = 'contoso/pool-1'; name = 'Azure Pipelines'; type = 'devops/agentpools'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                id = 1; isHosted = $true; poolType = 'automation'; size = 10
                autoProvision = $true; isLegacy = $false; createdOn = '2025-01-01T00:00:00Z'
                owner = [PSCustomObject]@{ displayName = 'Project Collection Admins' }
            }
        }
        [PSCustomObject]@{
            id = 'contoso/pool-2'; name = 'onprem-linux'; type = 'devops/agentpools'
            tenantId = 'tenant-1'; organization = 'contoso'
            properties = [PSCustomObject]@{
                id = 2; isHosted = $false; poolType = 'automation'; size = 4
                autoProvision = $false; isLegacy = $false; createdOn = '2025-06-01T00:00:00Z'
                owner = [PSCustomObject]@{ displayName = 'Platform Team' }
            }
        }
    )

    $script:Subs = @(
        [PSCustomObject]@{ Id = 'sub-in-scope'; Name = 'Production' }
    )

    function Invoke-CollectorProcessing {
        param([string]$ModuleFile)
        $path = Join-Path $script:ModulePath $ModuleFile
        return & $path $null $script:Subs $false $script:DevOpsResources @() 'Processing' $null $null $null $null
    }
}

Describe 'Start-AZSCDevOpsExtraction — authentication (AB#327)' {

    It 'Uses basic auth when a PAT is supplied and does not request an Azure token' {
        Mock Get-AzAccessToken { throw 'should not be called' }
        Mock Invoke-RestMethod { [PSCustomObject]@{ value = @() } }

        $null = Start-AZSCDevOpsExtraction -TenantID 't' -Organization @('contoso') -Pat 'fake-pat'

        Should -Invoke Get-AzAccessToken -Times 0 -Exactly
        Should -Invoke Invoke-RestMethod -ParameterFilter { $Headers.Authorization -like 'Basic *' }
    }

    It 'Falls back to an Entra token for the Azure DevOps resource when no PAT is given' {
        Mock Get-AzAccessToken { [PSCustomObject]@{ Token = 'entra-token' } }
        Mock Invoke-RestMethod { [PSCustomObject]@{ value = @() } }

        $null = Start-AZSCDevOpsExtraction -TenantID 't' -Organization @('contoso')

        Should -Invoke Get-AzAccessToken -Times 1 -Exactly -ParameterFilter {
            $ResourceUrl -eq '499b84ac-1321-427f-aa17-267ca6975798'
        }
        Should -Invoke Invoke-RestMethod -ParameterFilter { $Headers.Authorization -eq 'Bearer entra-token' }
    }

    It 'Unwraps a SecureString token from newer Az versions' {
        Mock Get-AzAccessToken {
            [PSCustomObject]@{ Token = (ConvertTo-SecureString 'secure-token' -AsPlainText -Force) }
        }
        Mock Invoke-RestMethod { [PSCustomObject]@{ value = @() } }

        $null = Start-AZSCDevOpsExtraction -TenantID 't' -Organization @('contoso')

        Should -Invoke Invoke-RestMethod -ParameterFilter { $Headers.Authorization -eq 'Bearer secure-token' }
    }

    It 'Returns empty and does not throw when no token can be acquired' {
        Mock Get-AzAccessToken { throw 'no context' }
        Mock Write-Warning { }

        $result = Start-AZSCDevOpsExtraction -TenantID 't' -Organization @('contoso')

        @($result.DevOpsResources).Count | Should -Be 0
    }
}

Describe 'Start-AZSCDevOpsExtraction — collection (AB#327)' {

    BeforeEach {
        Mock Get-AzAccessToken { [PSCustomObject]@{ Token = 'entra-token' } }
        Mock Invoke-RestMethod {
            if ($Uri -match '/_apis/projects')              { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 'p1'; name = 'Payments'; state = 'wellFormed' }) } }
            if ($Uri -match '/_apis/pipelines')             { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 1; name = 'ci' }) } }
            if ($Uri -match '/serviceendpoint/endpoints')   { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 'e1'; name = 'arm' }) } }
            if ($Uri -match '/git/repositories')            { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 'r1'; name = 'api' }) } }
            if ($Uri -match '/distributedtask/pools')       { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 1; name = 'pool' }) } }
            return [PSCustomObject]@{ value = @() }
        }
    }

    It 'Produces one normalized resource per item across all five types' {
        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')
        @($result.DevOpsResources).Count | Should -Be 5
    }

    It 'Stamps the synthetic type onto every resource' {
        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')
        $types = @($result.DevOpsResources.type) | Sort-Object
        $types -join ',' | Should -Be 'devops/agentpools,devops/pipelines,devops/projects,devops/repositories,devops/serviceconnections'
    }

    It 'Prefixes each resource id with the organization so two orgs cannot collide' {
        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')
        foreach ($r in $result.DevOpsResources) { $r.id | Should -BeLike 'contoso/*' }
    }

    It 'Stamps the tenant ID onto every resource' {
        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')
        foreach ($r in $result.DevOpsResources) { $r.tenantId | Should -Be 'tenant-1' }
    }

    It 'Scans every organization it is given' {
        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso', 'fabrikam')
        @($result.DevOpsResources).Count | Should -Be 10
        @($result.DevOpsResources | Where-Object { $_.organization -eq 'fabrikam' }).Count | Should -Be 5
    }

    It 'Discovers organizations from the signed-in profile when none are supplied' {
        Mock Invoke-RestMethod {
            if ($Uri -match '/profile/profiles/me') { return [PSCustomObject]@{ id = 'member-1' } }
            if ($Uri -match '/_apis/accounts')      { return [PSCustomObject]@{ value = @([PSCustomObject]@{ accountName = 'discovered-org' }) } }
            if ($Uri -match '/_apis/projects')      { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 'p1'; name = 'P' }) } }
            return [PSCustomObject]@{ value = @() }
        }

        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1'

        @($result.DevOpsResources | Where-Object { $_.organization -eq 'discovered-org' }).Count | Should -BeGreaterThan 0
    }

    It 'Warns and returns empty when discovery finds no organizations' {
        Mock Invoke-RestMethod { [PSCustomObject]@{ } }
        Mock Write-Warning { }

        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1'

        @($result.DevOpsResources).Count | Should -Be 0
        Should -Invoke Write-Warning -Times 1 -Exactly
    }

    It 'Keeps collecting other data when one endpoint returns 403' {
        Mock Invoke-RestMethod {
            if ($Uri -match '/serviceendpoint/endpoints') { throw 'Access denied' }
            if ($Uri -match '/_apis/projects')            { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 'p1'; name = 'P' }) } }
            if ($Uri -match '/_apis/pipelines')           { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = 1; name = 'ci' }) } }
            return [PSCustomObject]@{ value = @() }
        }

        $result = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')

        @($result.DevOpsResources | Where-Object { $_.type -eq 'devops/serviceconnections' }).Count | Should -Be 0
        @($result.DevOpsResources | Where-Object { $_.type -eq 'devops/pipelines' }).Count | Should -Be 1
    }

    It 'Skips an organization with no project access without throwing' {
        Mock Invoke-RestMethod { throw 'Access denied' }
        { Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso') } | Should -Not -Throw
    }

    It 'Issues only GET requests — Azure Scout is read-only' {
        $null = Start-AZSCDevOpsExtraction -TenantID 'tenant-1' -Organization @('contoso')
        Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -ne 'Get' } -Times 0 -Exactly
    }
}

Describe 'DevOpsProjects collector (AB#327)' {

    It 'Emits one row per project' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsProjects.ps1')
        $rows.Count | Should -Be 1
    }

    It 'Maps the project fields' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsProjects.ps1')[0]
        $row.'Organization' | Should -Be 'contoso'
        $row.'Project Name' | Should -Be 'Payments'
        $row.'State'        | Should -Be 'wellFormed'
        $row.'Visibility'   | Should -Be 'private'
    }

    It 'Formats the last update timestamp' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsProjects.ps1')[0]
        $row.'Last Updated' | Should -Match '^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$'
    }
}

Describe 'DevOpsPipelines collector (AB#327)' {

    It 'Emits one row per pipeline' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsPipelines.ps1')
        $rows.Count | Should -Be 1
    }

    It 'Reports a root-folder pipeline as (root) rather than a bare backslash' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsPipelines.ps1')[0]
        $row.'Folder' | Should -Be '(root)'
    }

    It 'Flattens the YAML configuration' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsPipelines.ps1')[0]
        $row.'Configuration Type' | Should -Be 'yaml'
        $row.'YAML Path'          | Should -Be '/azure-pipelines.yml'
    }

    It 'Carries the owning project name' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsPipelines.ps1')[0]
        $row.'Project' | Should -Be 'Payments'
    }
}

Describe 'DevOpsServiceConnections collector (AB#327)' {

    It 'Emits one row per service connection' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $rows.Count | Should -Be 2
    }

    It 'Marks a connection targeting an in-scope subscription' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $inScope = $rows | Where-Object { $_.'Connection Name' -eq 'prod-arm' }
        $inScope.'Subscription In Scope' | Should -Be 'Yes'
    }

    It 'Marks a connection targeting a subscription outside the scan' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $outOfScope = $rows | Where-Object { $_.'Connection Name' -eq 'legacy-arm' }
        $outOfScope.'Subscription In Scope' | Should -Be 'No'
    }

    It 'Flags workload identity federation as credential free' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $federated = $rows | Where-Object { $_.'Connection Name' -eq 'prod-arm' }
        $federated.'Credential Free' | Should -Be 'Yes'
    }

    It 'Flags a service principal connection as not credential free' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $spn = $rows | Where-Object { $_.'Connection Name' -eq 'legacy-arm' }
        $spn.'Credential Free' | Should -Be 'No'
    }

    It 'Surfaces the service principal ID for an ARM connection' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsServiceConnections.ps1')
        $spn = $rows | Where-Object { $_.'Connection Name' -eq 'legacy-arm' }
        $spn.'Service Principal ID' | Should -Be 'spn-old'
    }
}

Describe 'DevOpsRepositories collector (AB#327)' {

    It 'Emits one row per repository' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsRepositories.ps1')
        $rows.Count | Should -Be 1
    }

    It 'Shortens the default branch ref' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsRepositories.ps1')[0]
        $row.'Default Branch' | Should -Be 'main'
    }

    It 'Converts the repository size to megabytes' {
        $row = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsRepositories.ps1')[0]
        $row.'Size (MB)' | Should -Be 5
    }
}

Describe 'DevOpsAgentPools collector (AB#327)' {

    It 'Emits one row per agent pool' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsAgentPools.ps1')
        $rows.Count | Should -Be 2
    }

    It 'Labels a Microsoft-hosted pool' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsAgentPools.ps1')
        $hosted = $rows | Where-Object { $_.'Pool Name' -eq 'Azure Pipelines' }
        $hosted.'Hosting Model' | Should -Be 'Microsoft-hosted'
    }

    It 'Labels a self-hosted pool — the set carrying patching and capacity obligations' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsAgentPools.ps1')
        $selfHosted = $rows | Where-Object { $_.'Pool Name' -eq 'onprem-linux' }
        $selfHosted.'Hosting Model' | Should -Be 'Self-hosted'
    }

    It 'Surfaces the pool owner' {
        $rows = @(Invoke-CollectorProcessing -ModuleFile 'DevOpsAgentPools.ps1')
        $selfHosted = $rows | Where-Object { $_.'Pool Name' -eq 'onprem-linux' }
        $selfHosted.'Owner' | Should -Be 'Platform Team'
    }
}

Describe 'DevOps collectors — no matching resources' {

    It 'Every collector returns nothing when the resource array holds no DevOps data' {
        foreach ($file in 'DevOpsProjects.ps1', 'DevOpsPipelines.ps1', 'DevOpsServiceConnections.ps1', 'DevOpsRepositories.ps1', 'DevOpsAgentPools.ps1') {
            $path = Join-Path $script:ModulePath $file
            $rows = @(& $path $null $script:Subs $false @() @() 'Processing' $null $null $null $null)
            $rows.Count | Should -Be 0 -Because "$file should emit nothing for an empty resource set"
        }
    }

    It 'Every collector survives a resource array holding only unrelated types' {
        $unrelated = @([PSCustomObject]@{ id = 'x'; name = 'vm'; type = 'microsoft.compute/virtualmachines'; properties = [PSCustomObject]@{} })
        foreach ($file in 'DevOpsProjects.ps1', 'DevOpsPipelines.ps1', 'DevOpsServiceConnections.ps1', 'DevOpsRepositories.ps1', 'DevOpsAgentPools.ps1') {
            $path = Join-Path $script:ModulePath $file
            { & $path $null $script:Subs $false $unrelated @() 'Processing' $null $null $null $null } | Should -Not -Throw
        }
    }
}