Public/Tests/New-IntuneAppGroup.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-IntuneAppGroup' { 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; MailNickname = $MailNickname } } } Context 'Group names and properties' { It 'Creates an Available and a Required app group for each name' { $result = New-IntuneAppGroup -Name 'App1', 'App2' -Confirm:$false $result.Name | Should -Be @('Intune-AG-App1-Available', 'Intune-AG-App1-Required', 'Intune-AG-App2-Available', 'Intune-AG-App2-Required') Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 4 -Exactly } It 'Returns objects with Name, Id, AppName, Intent, Status and MailNickname' { $result = @(New-IntuneAppGroup -Name 'Company Portal' -Confirm:$false) $result[0].PSObject.TypeNames | Should -Contain 'Tcs.Azure.IntuneAppGroup' $result[0].Name | Should -BeExactly 'Intune-AG-CompanyPortal-Available' $result[0].DisplayName | Should -BeExactly 'Intune-AG-CompanyPortal-Available' $result[0].Id | Should -Be 'id-Intune-AG-CompanyPortal-Available' $result[0].AppName | Should -BeExactly 'Company Portal' $result[0].Intent | Should -Be 'Available' $result[0].Status | Should -Be 'Created' $result[0].MailNickname | Should -BeExactly 'Intune-AG-CompanyPortal-Available' } It 'Creates security groups that are not mail enabled, with a description' { $null = New-IntuneAppGroup -Name 'App1' -Confirm:$false Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly -ParameterFilter { $DisplayName -eq 'Intune-AG-App1-Required' -and $SecurityEnabled -eq $true -and $MailEnabled -eq $false -and $Description -ceq 'Intune Required assignment group for App1.' } } It 'Uses the ACG prefix with -Collection' { $result = New-IntuneAppGroup -Name 'Office Apps' -Collection -Confirm:$false $result.Name | Should -Be @('Intune-ACG-OfficeApps-Available', 'Intune-ACG-OfficeApps-Required') } It 'Converts names to PascalCase and tolerates repeated spaces' { $result = New-IntuneAppGroup -Name 'company portal ' -Confirm:$false $result[0].Name | Should -BeExactly 'Intune-AG-CompanyPortal-Available' $result[0].AppName | Should -BeExactly 'company portal' } It 'Keeps acronyms, mixed case and hyphenated names' { $result = New-IntuneAppGroup -Name 'VLC', 'PowerShell 7', '7-zip' -Intent Available -Confirm:$false $result.Name | Should -BeExactly @('Intune-AG-VLC-Available', 'Intune-AG-PowerShell7-Available', 'Intune-AG-7-zip-Available') } It 'Removes characters that are not valid in a mail nickname' { $result = New-IntuneAppGroup -Name 'App(x64)' -Confirm:$false $result[0].Name | Should -BeExactly 'Intune-AG-App(x64)-Available' $result[0].MailNickname | Should -BeExactly 'Intune-AG-Appx64-Available' } It 'Escapes single quotes in the OData filter' { $null = New-IntuneAppGroup -Name "O'Brien" -Confirm:$false Should -Invoke -ModuleName tcs.azure Get-EntraGroup -ParameterFilter { $Filter -eq "DisplayName eq 'Intune-AG-O''Brien-Available'" } -Times 1 -Exactly } } Context 'Mail nicknames' { It 'Keeps a long nickname within 64 characters and ends it with the intent' { $result = @(New-IntuneAppGroup -Name ('x' * 80) -Collection -Intent Uninstall -Confirm:$false) $result[0].MailNickname.Length | Should -BeLessOrEqual 64 $result[0].MailNickname | Should -Match '^Intune-ACG-X+-[0-9a-f]{8}-Uninstall$' Should -Invoke -ModuleName tcs.azure New-EntraGroup -ParameterFilter { $MailNickname.Length -le 64 } -Times 1 -Exactly } It 'Gives long names that only differ at the end different nicknames' { $first = New-IntuneAppGroup -Name (('a' * 70) + 'One') -Intent Available -Confirm:$false $second = New-IntuneAppGroup -Name (('a' * 70) + 'Two') -Intent Available -Confirm:$false $first.MailNickname | Should -Not -Be $second.MailNickname } It 'Returns the same nickname for the same name every time' { $first = New-IntuneAppGroup -Name ('y' * 90) -Intent Available -Confirm:$false $second = New-IntuneAppGroup -Name ('y' * 90) -Intent Available -Confirm:$false $first.MailNickname | Should -BeExactly $second.MailNickname } It 'Removes accents from non-ASCII letters' { $name = -join [char[]](0xC4, 0xD6, 0xDC, 0x20, 0xE9, 0x74, 0xE9) $result = @(New-IntuneAppGroup -Name $name -Intent Available -Confirm:$false) $result[0].Name | Should -BeExactly ('Intune-AG-' + (-join [char[]](0xC4, 0xD6, 0xDC, 0xC9, 0x74, 0xE9)) + '-Available') $result[0].MailNickname | Should -BeExactly 'Intune-AG-AOUEte-Available' } It 'Uses a hash for names without any ASCII letters, so they do not collide' { $first = New-IntuneAppGroup -Name (-join [char[]](0x5FAE, 0x4FE1)) -Intent Available -Confirm:$false $second = New-IntuneAppGroup -Name (-join [char[]](0x9489, 0x9489)) -Intent Available -Confirm:$false $first.MailNickname | Should -Match '^Intune-AG-[0-9a-f]{8}-Available$' $second.MailNickname | Should -Match '^Intune-AG-[0-9a-f]{8}-Available$' $first.MailNickname | Should -Not -Be $second.MailNickname Should -Invoke -ModuleName tcs.azure New-EntraGroup -ParameterFilter { $MailNickname -eq 'Intune-AG--Available' } -Times 0 -Exactly } } Context 'Culture' { BeforeAll { $script:OriginalCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture } AfterEach { [System.Threading.Thread]::CurrentThread.CurrentCulture = $script:OriginalCulture } It 'Uses invariant casing under tr-TR' { [System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('tr-TR') $result = @(New-IntuneAppGroup -Name 'internet explorer' -Intent Available -Confirm:$false) $result[0].Name | Should -BeExactly 'Intune-AG-InternetExplorer-Available' $result[0].MailNickname | Should -BeExactly 'Intune-AG-InternetExplorer-Available' } } Context 'Intent' { It 'Creates only the requested intents' { $result = New-IntuneAppGroup -Name 'App1' -Intent Uninstall -Confirm:$false $result.Name | Should -BeExactly 'Intune-AG-App1-Uninstall' $result.Intent | Should -Be 'Uninstall' Should -Invoke -ModuleName tcs.azure New-EntraGroup -ParameterFilter { $Description -ceq 'Intune Uninstall assignment group for App1.' } -Times 1 -Exactly } It 'Creates all three intents in the order given, once each' { $result = New-IntuneAppGroup -Name 'App1' -Intent required, Uninstall, Available, Required -Confirm:$false $result.Name | Should -BeExactly @('Intune-AG-App1-Required', 'Intune-AG-App1-Uninstall', 'Intune-AG-App1-Available') } It 'Defaults to Available and Required' { $result = New-IntuneAppGroup -Name 'App1' -Confirm:$false $result.Intent | Should -Be @('Available', 'Required') } It 'Rejects an unknown intent' { { New-IntuneAppGroup -Name 'App1' -Intent 'Optional' -Confirm:$false } | Should -Throw } } Context 'Pipeline input' { It 'Accepts names from the pipeline' { $result = 'App1', 'App2' | New-IntuneAppGroup -Intent Available -Confirm:$false $result.Name | Should -Be @('Intune-AG-App1-Available', 'Intune-AG-App2-Available') } It 'Accepts objects with a <Property> property' -ForEach @( @{ Property = 'Name' } @{ Property = 'AppName' } @{ Property = 'DisplayName' } ) { $result = [pscustomobject]@{ $Property = 'Company Portal' } | New-IntuneAppGroup -Intent Available -Confirm:$false $result.Name | Should -Be 'Intune-AG-CompanyPortal-Available' } It 'Records one start and one end for a piped batch' { Mock -ModuleName tcs.azure Invoke-TelemetryCollection { } $null = 'App1', 'App2', 'App3' | New-IntuneAppGroup -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' } -Times 1 -Exactly } It 'Records end telemetry when a downstream command stops the pipeline early' { Mock -ModuleName tcs.azure Invoke-TelemetryCollection { } $result = 'App1', 'App2' | New-IntuneAppGroup -Confirm:$false | Select-Object -First 1 $result.Name | Should -Be 'Intune-AG-App1-Available' Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and -not $Failed } -Times 1 -Exactly } } Context 'Existing groups and -WhatIf' { It 'Returns groups that already exist with Status Existing and a warning' { Mock -ModuleName tcs.azure Get-EntraGroup { [pscustomobject]@{ Id = 'existing-id'; DisplayName = 'existing'; MailNickname = 'existing-nick' } } -ParameterFilter { $Filter -like '*-Available*' } $result = New-IntuneAppGroup -Name 'App1' -Confirm:$false -WarningVariable warnings -WarningAction SilentlyContinue @($result).Count | Should -Be 2 $result[0].Status | Should -Be 'Existing' $result[0].Id | Should -Be 'existing-id' $result[0].MailNickname | Should -Be 'existing-nick' $result[1].Status | Should -Be 'Created' $warnings | Should -Match 'already exists' Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly } It 'Looks groups up but creates nothing with -WhatIf' { $result = New-IntuneAppGroup -Name 'App1' -WhatIf Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly Should -Invoke -ModuleName tcs.azure Get-EntraGroup -Times 2 -Exactly $result.Status | Should -Be @('WhatIf', 'WhatIf') @($result | Where-Object { $_.Id }).Count | Should -Be 0 } It 'Reports existing groups as Existing under -WhatIf and only offers the others' { Mock -ModuleName tcs.azure Get-EntraGroup { [pscustomobject]@{ Id = 'existing-id' } } -ParameterFilter { $Filter -like '*-Required*' } $result = New-IntuneAppGroup -Name 'App1' -WhatIf -WarningAction SilentlyContinue $result.Status | Should -Be @('WhatIf', 'Existing') } } Context 'Errors' { It 'Writes a non-terminating error and continues when a group cannot be created' { Mock -ModuleName tcs.azure New-EntraGroup { throw 'Insufficient privileges' } -ParameterFilter { $DisplayName -like '*-Available' } $result = New-IntuneAppGroup -Name 'App1' -Confirm:$false -ErrorVariable errors -ErrorAction SilentlyContinue ($errors | Out-String) | Should -Match 'Insufficient privileges' @($result).Count | Should -Be 2 $result[0].Status | Should -Be 'Failed' $result[1].Name | Should -Be 'Intune-AG-App1-Required' $result[1].Status | Should -Be 'Created' } It 'Stops on the first failure with -ErrorAction Stop' { Mock -ModuleName tcs.azure New-EntraGroup { throw 'Insufficient privileges' } { New-IntuneAppGroup -Name 'App1' -Confirm:$false -ErrorAction Stop } | Should -Throw '*Insufficient privileges*' Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 1 -Exactly } It 'Writes an error for a whitespace-only name' { $null = New-IntuneAppGroup -Name ' ' -Confirm:$false -ErrorVariable errors -ErrorAction SilentlyContinue @($errors).Count | Should -Be 1 $errors[0].FullyQualifiedErrorId | Should -BeLike 'BlankName*' Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly } It 'Throws one clear error when there is no Connect-Entra session' { Mock -ModuleName tcs.azure Get-EntraContext { } { New-IntuneAppGroup -Name 'App1', 'App2' -Confirm:$false } | Should -Throw '*Connect-Entra*' Should -Invoke -ModuleName tcs.azure Get-EntraGroup -Times 0 -Exactly Should -Invoke -ModuleName tcs.azure New-EntraGroup -Times 0 -Exactly } It 'Uses the EntraNotConnected error id when there is no session' { Mock -ModuleName tcs.azure Get-EntraContext { } try { New-IntuneAppGroup -Name 'App1' -Confirm:$false } catch { $caught = $_ } $caught.FullyQualifiedErrorId | Should -BeLike 'EntraNotConnected*' } } Context 'Telemetry' { BeforeEach { Mock -ModuleName tcs.azure Invoke-TelemetryCollection { } } It 'Records start and end telemetry' { $null = New-IntuneAppGroup -Name 'App1' -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 } It 'Records failed end telemetry when a group cannot be created' { Mock -ModuleName tcs.azure New-EntraGroup { throw 'Insufficient privileges' } $null = New-IntuneAppGroup -Name 'App1' -Confirm:$false -ErrorAction SilentlyContinue Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and $Failed } -Times 1 -Exactly } It 'Records failed end telemetry for a blank name' { $null = New-IntuneAppGroup -Name ' ' -Confirm:$false -ErrorAction SilentlyContinue Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and $Failed } -Times 1 -Exactly Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and -not $Failed } -Times 0 -Exactly } It 'Records failed end telemetry once with -ErrorAction Stop' { Mock -ModuleName tcs.azure New-EntraGroup { throw 'Insufficient privileges' } { New-IntuneAppGroup -Name 'App1' -Confirm:$false -ErrorAction Stop } | Should -Throw Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' } -Times 1 -Exactly Should -Invoke -ModuleName tcs.azure Invoke-TelemetryCollection -ParameterFilter { $Stage -eq 'End' -and $Failed } -Times 1 -Exactly } } Context 'Parameters' { It 'Requires a name' { (Get-Command -Name New-IntuneAppGroup).Parameters['Name'].Attributes.Where({ $_ -is [System.Management.Automation.ParameterAttribute] }).Mandatory | Should -BeTrue } It 'Supports -WhatIf and -Confirm' { $command = Get-Command -Name New-IntuneAppGroup $command.Parameters.Keys | Should -Contain 'WhatIf' $command.Parameters.Keys | Should -Contain 'Confirm' } } } Describe 'New-IntuneAppGroup without the Microsoft Entra module' { It 'Throws a helpful error when the Entra commands are missing' { Mock -ModuleName tcs.azure Get-Command { } -ParameterFilter { $Name -in 'Get-EntraGroup', 'New-EntraGroup' } { New-IntuneAppGroup -Name 'App1' -Confirm:$false } | Should -Throw '*Install-Module Microsoft.Entra.Groups*' } It 'Records failed end telemetry when the Entra commands are missing' { Mock -ModuleName tcs.azure Invoke-TelemetryCollection { } Mock -ModuleName tcs.azure Get-Command { } -ParameterFilter { $Name -in 'Get-EntraGroup', 'New-EntraGroup' } { New-IntuneAppGroup -Name 'App1' -Confirm:$false } | Should -Throw 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 $Failed } -Times 1 -Exactly } It 'Skips the connection check when Get-EntraContext is not available' { Mock -ModuleName tcs.azure Get-Command { } -ParameterFilter { $Name -eq 'Get-EntraContext' } Mock -ModuleName tcs.azure Get-EntraGroup { } Mock -ModuleName tcs.azure New-EntraGroup { [pscustomobject]@{ Id = '1' } } $result = New-IntuneAppGroup -Name 'App1' -Intent Available -Confirm:$false $result.Status | Should -Be 'Created' } } |