Public/Tests/New-TcsEntraDepartmentalGroup.Tests.ps1

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    $RepoRoot = Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.azure.psd1') -Force
    . (Join-Path -Path $RepoRoot -ChildPath 'tests/Helpers/EntraStubs.ps1')
    $script:CreatedStubs = Add-TcsEntraStub
}

AfterAll {
    Remove-TcsEntraStub -Name $script:CreatedStubs
    Remove-Module -Name tcs.azure -Force -ErrorAction SilentlyContinue
}

Describe 'New-TcsEntraDepartmentalGroup' {
    BeforeEach {
        Mock -ModuleName tcs.azure Get-EntraContext { [pscustomobject]@{ Account = 'admin@contoso.com' } }
        Mock -ModuleName tcs.azure Get-EntraGroup { }
        Mock -ModuleName tcs.azure New-EntraGroup { [pscustomobject]@{ Id = "id-$DisplayName"; DisplayName = $DisplayName } }
        Mock -ModuleName tcs.azure Add-EntraGroupOwner { }
    }

    It 'Creates a security group named with the Get-DepartmentalGroupName rules' {
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Human Resources' -Department 'HR - Payroll' -Suffix 'Users' -Confirm:$false
        $result.PSObject.TypeNames | Should -Contain 'Tcs.Azure.DepartmentalGroup'
        $result.Name | Should -BeExactly 'SG-HR-Payroll-Users'
        $result.Id | Should -Be 'id-SG-HR-Payroll-Users'
        $result.Status | Should -Be 'Created'
        $result.MembershipRule | Should -BeNullOrEmpty
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly -ParameterFilter {
            $DisplayName -ceq 'SG-HR-Payroll-Users' -and $MailNickname -ceq 'SG-HR-Payroll-Users' -and $SecurityEnabled -and -not $MailEnabled -and
            -not $MembershipRule -and $Description -ceq 'Security group for the HR - Payroll department of Human Resources.'
        }
    }

    It 'Builds a dynamic membership rule from the department' {
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Department 'Accounts Payable' -DynamicMembership -Confirm:$false
        $result.MembershipRule | Should -BeExactly '(user.department -eq "Accounts Payable")'
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly -ParameterFilter {
            $MembershipRule -ceq '(user.department -eq "Accounts Payable")' -and $MembershipRuleProcessingState -eq 'On' -and $GroupTypes -contains 'DynamicMembership'
        }
    }

    It 'Uses the division in the rule when there is no department, and escapes double quotes' {
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'The "Lab"' -DynamicMembership -Confirm:$false
        $result.Name | Should -BeExactly 'SG-TheLab'
        $result.MembershipRule | Should -BeExactly '(user.department -eq "The `"Lab`"")'
    }

    It 'Uses New-EntraBetaGroup when New-EntraGroup cannot set a membership rule' {
        function global:New-EntraBetaGroup { [CmdletBinding()] param([string]$DisplayName, [string]$MailNickname, [string]$Description, [bool]$MailEnabled, [bool]$SecurityEnabled, [string[]]$GroupTypes, [string]$MembershipRule, [string]$MembershipRuleProcessingState) $null = $PSBoundParameters }
        try {
            Mock -ModuleName tcs.azure Get-Command { [pscustomobject]@{ Name = 'New-EntraGroup'; Parameters = @{ DisplayName = $null } } } -ParameterFilter { $Name -eq 'New-EntraGroup' }
            Mock -ModuleName tcs.azure New-EntraBetaGroup { [pscustomobject]@{ Id = 'beta-id' } }
            $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -DynamicMembership -Confirm:$false
            $result.Id | Should -Be 'beta-id'
            Should -Invoke -ModuleName tcs.azure New-EntraBetaGroup -Times 1 -Exactly -ParameterFilter { $MembershipRule -ceq '(user.department -eq "Finance")' }
            Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly
        }
        finally {
            Remove-Item -Path Function:\New-EntraBetaGroup -ErrorAction SilentlyContinue
        }
    }

    It 'Stops with a clear error when dynamic membership is not supported' {
        Mock -ModuleName tcs.azure Get-Command { [pscustomobject]@{ Name = 'New-EntraGroup'; Parameters = @{ DisplayName = $null } } } -ParameterFilter { $Name -eq 'New-EntraGroup' }
        Mock -ModuleName tcs.azure Get-Command { } -ParameterFilter { $Name -eq 'New-EntraBetaGroup' }
        { New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -DynamicMembership -Confirm:$false } | Should -Throw '*Microsoft.Entra.Beta.Groups*'
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly
    }

    It 'Adds owners to a created group' {
        $null = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Owner 'owner-1', 'owner-2' -Confirm:$false
        Should -Invoke -ModuleName tcs.azure Add-EntraGroupOwner -Times 2 -Exactly -ParameterFilter { $GroupId -eq 'id-SG-Finance' }
        Should -Invoke -ModuleName tcs.azure Add-EntraGroupOwner -Times 1 -Exactly -ParameterFilter { $OwnerId -eq 'owner-2' }
    }

    It 'Writes an error but keeps the group when an owner cannot be added' {
        Mock -ModuleName tcs.azure Add-EntraGroupOwner { throw 'Owner not found' }
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Owner 'bad' -Confirm:$false -ErrorVariable errors -ErrorAction SilentlyContinue
        $result.Status | Should -Be 'Created'
        ($errors | Out-String) | Should -Match 'Owner not found'
    }

    It 'Returns an existing group without creating it or adding owners' {
        Mock -ModuleName tcs.azure Get-EntraGroup { [pscustomobject]@{ Id = 'existing'; MailNickname = 'nick' } }
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Owner 'owner-1' -Confirm:$false -WarningVariable warnings -WarningAction SilentlyContinue
        $result.Status | Should -Be 'Existing'
        $result.Id | Should -Be 'existing'
        $warnings | Should -Match 'already exists'
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly
        Should -Invoke -ModuleName tcs.azure Add-EntraGroupOwner -Times 0 -Exactly
    }

    It 'Looks the group up but creates nothing with -WhatIf' {
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -WhatIf
        $result.Status | Should -Be 'WhatIf'
        Should -Invoke -ModuleName tcs.azure Get-EntraGroup -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly
    }

    It 'Creates one group for each piped row' {
        $rows = @(
            [pscustomobject]@{ Division = 'Finance'; Department = 'Payroll' }
            [pscustomobject]@{ Division = 'Legal'; Department = '' }
        )
        $result = $rows | New-TcsEntraDepartmentalGroup -Prefix 'SG' -Confirm:$false
        $result.Name | Should -BeExactly @('SG-F-Payroll', 'SG-Legal')
    }

    It 'Writes an InvalidDivision error for a punctuation-only division' {
        $null = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division '&' -Confirm:$false -ErrorVariable errors -ErrorAction SilentlyContinue
        $errors[0].FullyQualifiedErrorId | Should -BeLike 'InvalidDivision*'
        Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly
    }

    It 'Returns a Failed object and records failed telemetry when the group cannot be created' {
        Mock -ModuleName tcs.azure Invoke-TelemetryCollection { }
        Mock -ModuleName tcs.azure New-EntraGroup { throw 'Insufficient privileges' }
        $result = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Confirm:$false -ErrorAction SilentlyContinue
        $result.Status | Should -Be 'Failed'
        Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and $Failed } -Times 1 -Exactly
    }

    It 'Throws one clear error when there is no Connect-Entra session' {
        Mock -ModuleName tcs.azure Get-EntraContext { }
        { New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Confirm:$false } | Should -Throw '*Connect-Entra*'
    }

    It 'Records start and end telemetry' {
        Mock -ModuleName tcs.azure Invoke-TelemetryCollection { }
        $null = New-TcsEntraDepartmentalGroup -Prefix 'SG' -Division 'Finance' -Confirm:$false
        Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'Start' } -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and -not $Failed } -Times 1 -Exactly
    }
}