tests/functions/Update-MtRoleDefinitions.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/../../internal/Get-MtRoleInfo.ps1"

    # Import only the helper functions from the build script without executing the main body.
    # The main execution body (network calls, file writes) lives after '#region Main execution'.
    $buildScriptPath = Resolve-Path "$PSScriptRoot/../../../build/Update-MtRoleDefinitions.ps1"
    $buildContent = Get-Content $buildScriptPath -Raw
    $helperSection = ($buildContent -split [regex]::Escape('#region Main execution'))[0]
    $sb = [scriptblock]::Create($helperSection)
    . $sb
}

Describe 'Get-RoleDataFromMarkdown' {

    Context 'Valid table with 3 roles including 1 privileged' {
        It 'returns 3 entries with correct IsPrivileged flag' {
            $md = @'
## All roles
 
| Role | Description | ID |
| --- | --- | --- |
| [Global Administrator](#global-administrator) | Can manage all aspects of Azure AD. [![Privileged label icon.](./media/permissions-reference/privileged-label.png)](privileged-roles-permissions.md) | 62e90394-69f5-4237-9190-012177145e10 |
| [Reports Reader](#reports-reader) | Can read sign-in and audit reports. | 4a5d8f65-41da-4de4-8968-e035b65339cf |
| [Search Administrator](#search-administrator) | Can create and manage all aspects of Microsoft Search settings. | 0964bb5e-9bdb-4d7b-ac29-58e794862a40 |
'@

            $result = Get-RoleDataFromMarkdown -Markdown $md
            $result.Count | Should -Be 3
            $privileged = @($result | Where-Object { $_.IsPrivileged })
            $privileged.Count | Should -Be 1
            $privileged[0].Name | Should -Be 'GlobalAdministrator'
        }
    }

    Context 'Missing All roles heading' {
        It 'throws an error mentioning All roles' {
            $md = @'
## Other Section
 
| Role | Description | ID |
| --- | --- | --- |
| [Some Role](#some-role) | Description | 62e90394-69f5-4237-9190-012177145e10 |
'@

            { Get-RoleDataFromMarkdown -Markdown $md } | Should -Throw -ExpectedMessage '*All roles*'
        }
    }

    Context 'Missing table header separator row' {
        It 'throws an error about the header separator' {
            $md = @'
## All roles
 
| Role | Description | ID |
| [Global Administrator](#global-administrator) | Description | 62e90394-69f5-4237-9190-012177145e10 |
'@

            { Get-RoleDataFromMarkdown -Markdown $md } | Should -Throw -ExpectedMessage '*table header separator*'
        }
    }

    Context 'Table header has fewer than 3 columns' {
        It 'throws an error about column count' {
            $md = @'
## All roles
 
| Role | ID |
| --- | --- |
| [Global Administrator](#global-administrator) | 62e90394-69f5-4237-9190-012177145e10 |
'@

            { Get-RoleDataFromMarkdown -Markdown $md } | Should -Throw -ExpectedMessage '*column*'
        }
    }

    Context 'Row missing pipe delimiters (too few columns)' {
        It 'skips the malformed row without a terminating error' {
            $md = @'
## All roles
 
| Role | Description | ID |
| --- | --- | --- |
| [Global Administrator](#global-administrator) | Description | 62e90394-69f5-4237-9190-012177145e10 |
This is a prose line with a guid 11111111-1111-1111-1111-111111111111 but no pipes
'@

            $result = @(Get-RoleDataFromMarkdown -Markdown $md)
            @($result).Count | Should -Be 1
            $result[0].Name | Should -Be 'GlobalAdministrator'
        }
    }

    Context 'Duplicate role names' {
        It 'is handled by the caller deduplication (both entries returned from parser)' {
            $md = @'
## All roles
 
| Role | Description | ID |
| --- | --- | --- |
| [Global Administrator](#global-administrator) | First | 62e90394-69f5-4237-9190-012177145e10 |
| [Global Administrator](#global-administrator) | Duplicate | 62e90394-69f5-4237-9190-012177145e10 |
'@

            # Parser returns duplicates; caller is responsible for dedup
            $result = Get-RoleDataFromMarkdown -Markdown $md
            $result.Count | Should -Be 2
        }
    }

    Context 'GUID normalization' {
        It 'normalizes parsed GUIDs to lowercase' {
            $md = @'
## All roles
 
| Role | Description | ID |
| --- | --- | --- |
| [Global Administrator](#global-administrator) | Description | 62e90394-69f5-4237-9190-012177145e10 |
'@

            $result = @(Get-RoleDataFromMarkdown -Markdown $md)
            # GUIDs from the source document are already lowercase; .ToLower() is a no-op safeguard
            $result[0].Id | Should -Be '62e90394-69f5-4237-9190-012177145e10'
        }
    }
}

Describe 'Test-KnownRolesPresent' {

    Context 'All 5 expected roles present' {
        It 'returns true' {
            $roles = [System.Collections.Generic.List[hashtable]]::new()
            @(
                @{ Name = 'GlobalAdministrator'; Id = '62e90394-69f5-4237-9190-012177145e10'; IsPrivileged = $true; DisplayName = 'Global Administrator' }
                @{ Name = 'SecurityAdministrator'; Id = '194ae4cb-b126-40b2-bd5b-6091b380977d'; IsPrivileged = $true; DisplayName = 'Security Administrator' }
                @{ Name = 'UserAdministrator'; Id = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; IsPrivileged = $true; DisplayName = 'User Administrator' }
                @{ Name = 'HelpdeskAdministrator'; Id = '729827e3-9c14-49f7-bb1b-9608f156bbb8'; IsPrivileged = $true; DisplayName = 'Helpdesk Administrator' }
                @{ Name = 'ExchangeAdministrator'; Id = '29232cdf-9323-42fd-ade2-1d097af3e4de'; IsPrivileged = $false; DisplayName = 'Exchange Administrator' }
            ) | ForEach-Object { $roles.Add($_) }

            $result = Test-KnownRolesPresent -Roles $roles
            $result | Should -BeTrue
        }
    }

    Context 'One expected role missing' {
        It 'returns false and emits a warning' {
            $roles = [System.Collections.Generic.List[hashtable]]::new()
            @(
                @{ Name = 'GlobalAdministrator'; Id = '62e90394-69f5-4237-9190-012177145e10'; IsPrivileged = $true; DisplayName = 'Global Administrator' }
                @{ Name = 'SecurityAdministrator'; Id = '194ae4cb-b126-40b2-bd5b-6091b380977d'; IsPrivileged = $true; DisplayName = 'Security Administrator' }
                # UserAdministrator intentionally omitted
                @{ Name = 'HelpdeskAdministrator'; Id = '729827e3-9c14-49f7-bb1b-9608f156bbb8'; IsPrivileged = $true; DisplayName = 'Helpdesk Administrator' }
                @{ Name = 'ExchangeAdministrator'; Id = '29232cdf-9323-42fd-ade2-1d097af3e4de'; IsPrivileged = $false; DisplayName = 'Exchange Administrator' }
            ) | ForEach-Object { $roles.Add($_) }

            $result = Test-KnownRolesPresent -Roles $roles 3>&1
            # Last element is the boolean return; warnings are mixed in via 3>&1
            ($result | Where-Object { $_ -is [bool] }) | Should -BeFalse
        }
    }
}

Describe 'Test-AllGuidsValid' {

    Context 'All GUIDs valid' {
        It 'returns true' {
            $roles = [System.Collections.Generic.List[hashtable]]::new()
            $roles.Add(@{ Name = 'TestRole'; Id = '62e90394-69f5-4237-9190-012177145e10'; IsPrivileged = $false; DisplayName = 'Test Role' })

            $result = Test-AllGuidsValid -Roles $roles
            $result | Should -BeTrue
        }
    }

    Context 'One invalid GUID' {
        It 'returns false and emits a warning' {
            $roles = [System.Collections.Generic.List[hashtable]]::new()
            $roles.Add(@{ Name = 'GoodRole'; Id = '62e90394-69f5-4237-9190-012177145e10'; IsPrivileged = $false; DisplayName = 'Good Role' })
            $roles.Add(@{ Name = 'BadRole'; Id = 'not-a-valid-guid'; IsPrivileged = $false; DisplayName = 'Bad Role' })

            $result = Test-AllGuidsValid -Roles $roles 3>&1
            ($result | Where-Object { $_ -is [bool] }) | Should -BeFalse
        }
    }
}

Describe 'Get-ExistingRoles' {

    Context 'File with 3 roles, 2 overlap with new roles' {
        It 'returns only the 1 role not present in new data (preserved)' {
            $fileContent = @"
    'GlobalAdministrator' = [MtRoleDefinition]::new('62e90394-69f5-4237-9190-012177145e10', `$true)
    'ReportsReader' = [MtRoleDefinition]::new('4a5d8f65-41da-4de4-8968-e035b65339cf', `$false)
    'SystemRole' = [MtRoleDefinition]::new('aaaabbbb-cccc-dddd-eeee-ffffffffffff', `$false)
"@

            $newRoles = [System.Collections.Generic.List[hashtable]]::new()
            $newRoles.Add(@{ Name = 'GlobalAdministrator'; Id = '62e90394-69f5-4237-9190-012177145e10'; IsPrivileged = $true; DisplayName = 'Global Administrator' })
            $newRoles.Add(@{ Name = 'ReportsReader'; Id = '4a5d8f65-41da-4de4-8968-e035b65339cf'; IsPrivileged = $false; DisplayName = 'Reports Reader' })

            $preserved = @(Get-ExistingRoles -FileContent $fileContent -NewRoles $newRoles)
            @($preserved).Count | Should -Be 1
            $preserved[0].Name | Should -Be 'SystemRole'
        }
    }

    Context 'Existing role has same GUID as a renamed new role' {
        It 'does not preserve the old-name entry when its GUID is already covered by a new role' {
            # Simulates: old name 'OldRoleName' was renamed to 'NewRoleName' in public docs.
            # Both share the same GUID. The old entry should NOT be preserved, to avoid
            # two hashtable keys mapping to the same underlying Entra ID role.
            $fileContent = @"
    'OldRoleName' = [MtRoleDefinition]::new('9f06204d-73c1-4d4c-880a-6edb90606fd8', `$false)
    'SystemRole' = [MtRoleDefinition]::new('aaaabbbb-cccc-dddd-eeee-ffffffffffff', `$false)
"@

            $newRoles = [System.Collections.Generic.List[hashtable]]::new()
            # New data contains the renamed role (same GUID, new name)
            $newRoles.Add(@{ Name = 'NewRoleName'; Id = '9f06204d-73c1-4d4c-880a-6edb90606fd8'; IsPrivileged = $false; DisplayName = 'New Role Name' })

            $preserved = @(Get-ExistingRoles -FileContent $fileContent -NewRoles $newRoles)
            # Only SystemRole should be preserved; OldRoleName should be skipped (GUID already in new data)
            @($preserved).Count | Should -Be 1
            $preserved[0].Name | Should -Be 'SystemRole'
        }
    }
}

Describe 'Update-FileSection' {

    Context 'Marker present in file' {
        It 'replaces content between markers' {
            $tmpFile = [System.IO.Path]::GetTempFileName()
            Set-Content -Path $tmpFile -Value @'
before
# BEGIN MARKER
old content
# END MARKER
after
'@

            Update-FileSection -FilePath $tmpFile `
                -BeginMarker '# BEGIN MARKER' `
                -EndMarker '# END MARKER' `
                -NewContent 'new content'

            $result = Get-Content -Path $tmpFile -Raw
            $result | Should -Match 'new content'
            $result | Should -Not -Match 'old content'
            Remove-Item $tmpFile -Force
        }
    }

    Context 'Begin marker missing' {
        It 'throws an error containing the begin marker text' {
            $tmpFile = [System.IO.Path]::GetTempFileName()
            Set-Content -Path $tmpFile -Value 'no markers here'

            { Update-FileSection -FilePath $tmpFile `
                    -BeginMarker '# BEGIN MARKER' `
                    -EndMarker '# END MARKER' `
                    -NewContent 'new content' } | Should -Throw -ExpectedMessage '*BEGIN MARKER*'

            Remove-Item $tmpFile -Force
        }
    }
}

Describe 'Get-MtRoleInfo type contract' {

    Context 'Known privileged role' {
        It 'returns an MtRoleDefinition with a valid GUID and IsPrivileged = true' {
            $result = Get-MtRoleInfo -RoleName 'GlobalAdministrator'
            $result | Should -Not -BeNullOrEmpty
            $result.GetType().Name | Should -Be 'MtRoleDefinition'
            $result.ToString() | Should -Match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
            $result.IsPrivileged | Should -BeTrue
        }
    }

    Context 'Null guard for empty role name' {
        It 'returns null for whitespace input without throwing' {
            $result = Get-MtRoleInfo -RoleName ' '
            $result | Should -BeNullOrEmpty
        }

        It 'returns null for empty string without throwing' {
            $result = Get-MtRoleInfo -RoleName ''
            $result | Should -BeNullOrEmpty
        }
    }

    Context 'Unknown role name' {
        It 'returns null for an unrecognized role name' {
            $result = Get-MtRoleInfo -RoleName 'ThisRoleDoesNotExist99'
            $result | Should -BeNullOrEmpty
        }
    }
}