tests/Get-MsecAzureDevOpsExtension.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsExtension.
#
# The shape below was captured from a live organization: publisherId/publisherName, a scopes
# array of vso.* strings, and installState.flags as a comma-separated string.
#
# What matters here is that the Access grouping is a CONVENIENCE and the scope list is the fact.
# A reader who disagrees with the grouping must still be able to see what was granted, so Scopes
# is always returned and a scope this command has never seen must not disappear.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecAzureDevOpsExtension' {

    BeforeEach {
        InModuleScope msec {
            $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} }

            function script:New-Extension {
                param(
                    [string] $Name = 'Thing', [string] $Publisher = 'Contoso',
                    [string] $PublisherId = 'contoso', [string[]] $Scopes = @(), [string] $Flags = 'none'
                )
                [pscustomobject]@{
                    extensionId = $Name.ToLower(); extensionName = $Name
                    publisherId = $PublisherId; publisherName = $Publisher
                    version = '1.0.0'; lastPublished = [datetime]'2026-01-10'
                    scopes = $Scopes
                    installState = [pscustomobject]@{ flags = $Flags }
                }
            }

            function script:Set-ExtMock {
                param($Extensions)
                Mock Invoke-MsecAzureDevOpsRequest -MockWith { $Extensions }
            }
        }
    }

    It 'ranks manage above write above read' {
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @(
                (New-Extension -Name 'Manages'  -Scopes @('vso.build', 'vso.serviceendpoint_manage'))
                (New-Extension -Name 'Writes'   -Scopes @('vso.build', 'vso.code_write'))
                (New-Extension -Name 'Executes' -Scopes @('vso.build_execute'))
                (New-Extension -Name 'Reads'    -Scopes @('vso.build', 'vso.code'))
                (New-Extension -Name 'Nothing'))
            Get-MsecAzureDevOpsExtension -Organization 'contoso'
        }

        # A manage scope alongside read scopes is still manage - the highest grant decides.
        ($rows | Where-Object ExtensionName -eq 'Manages').Access  | Should -Be 'Manage'
        ($rows | Where-Object ExtensionName -eq 'Writes').Access   | Should -Be 'Write'
        # _execute runs code, so it groups with write rather than read.
        ($rows | Where-Object ExtensionName -eq 'Executes').Access | Should -Be 'Write'
        ($rows | Where-Object ExtensionName -eq 'Reads').Access    | Should -Be 'Read'
        ($rows | Where-Object ExtensionName -eq 'Nothing').Access  | Should -Be 'None'
    }

    It 'always returns the raw scopes, whatever the grouping says' {
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @(New-Extension -Name 'Odd' -Scopes @('vso.something_nobody_modelled', 'vso.code'))
            Get-MsecAzureDevOpsExtension -Organization 'contoso'
        }

        # The grouping is this command's judgement; the scope list is the fact, and a scope it
        # has never seen must survive into the output.
        $rows.Scopes | Should -Match 'vso.something_nobody_modelled'
        $rows.Access | Should -Be 'Read'
    }

    It 'identifies Microsoft publishers, including DevLabs' {
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @(
                (New-Extension -Name 'First'  -Publisher 'Microsoft' -PublisherId 'ms')
                (New-Extension -Name 'Labs'   -Publisher 'Microsoft DevLabs' -PublisherId 'ms-devlabs')
                (New-Extension -Name 'Other'  -Publisher 'Amazon Web Services' -PublisherId 'AmazonWebServices'))
            Get-MsecAzureDevOpsExtension -Organization 'contoso'
        }

        ($rows | Where-Object ExtensionName -eq 'First').IsMicrosoftPublisher | Should -BeTrue
        # DevLabs is Microsoft-published but experimental - counted as Microsoft, and the
        # publisher name is left visible so the reader can weigh it.
        ($rows | Where-Object ExtensionName -eq 'Labs').IsMicrosoftPublisher  | Should -BeTrue
        ($rows | Where-Object ExtensionName -eq 'Other').IsMicrosoftPublisher | Should -BeFalse
    }

    It 'keeps a disabled extension, because its grants survive' {
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @(New-Extension -Name 'Off' -Scopes @('vso.code_manage') -Flags 'disabled')
            Get-MsecAzureDevOpsExtension -Organization 'contoso'
        }

        # Disabling does not revoke the scopes, and re-enabling asks nobody to consent again.
        @($rows).Count   | Should -Be 1
        $rows.IsDisabled | Should -BeTrue
        $rows.Access     | Should -Be 'Manage'
    }

    It 'filters to third-party publishers when asked' {
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @(
                (New-Extension -Name 'Ours'  -Publisher 'Microsoft' -PublisherId 'ms')
                (New-Extension -Name 'Theirs' -Publisher 'Qameta Software' -PublisherId 'qameta'))
            Get-MsecAzureDevOpsExtension -Organization 'contoso' -ThirdPartyOnly
        }

        @($rows).Count      | Should -Be 1
        $rows.ExtensionName | Should -Be 'Theirs'
    }

    It 'warns rather than returning nothing when the list is empty' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-ExtMock -Extensions @()
            Get-MsecAzureDevOpsExtension -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # Every organization has built-in extensions, so an empty list means nothing was read.
        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'nothing was read'
    }

    It 'throws a clear error when not connected' {
        InModuleScope msec {
            $script:MsecSession = $null
            { Get-MsecAzureDevOpsExtension -Organization 'contoso' } | Should -Throw '*Connect-Msec*'
        }
    }
}