tests/BusinessCentral.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecBusinessCentralEnvironment and Get-MsecBusinessCentralAccess. # # The traps here are all about a value that means "broad" or "unknown" rendering as "narrow" or # "none": # # AN EMPTY COMPANY MEANS EVERY COMPANY. A System-scope assignment carries company = "", so a # blank cell turns the broadest grant in the finance system into the one that looks like # nothing. Measured on one tenant: 157 enabled SUPER assignments, most of them company-blank. # # A DISABLED USER STILL HOLDS ASSIGNMENTS. Dropping them silently hides an account that need # only be re-enabled; counting them as live overstates exposure. They are excluded by default # AND the number hidden is reported. # # AN ENVIRONMENT THAT COULD NOT BE READ IS NOT AN EMPTY ONE. A failed enumeration must warn # and name the environment, or an unreviewed sandbox reads as a clean one. # # userPermissionSets RETURNS HTTP 400 ON SOME ENVIRONMENTS and data on others within the same # major version, so accessControls is used instead. A test pins that it is never called. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecBusinessCentralAccess' { BeforeEach { InModuleScope msec { Mock Get-MsecBusinessCentralToken -MockWith { [pscustomobject]@{ Resource = 'https://api.businesscentral.dynamics.com' Headers = @{ Authorization = 'Bearer x' } } } } } It 'renders a blank company as every company, not as nothing' { $rows = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production' }) } } elseif ($Uri -match '/api/v2\.0/companies$') { [pscustomobject]@{ value = @([pscustomobject]@{ id = 'c1'; name = 'Co' }) } } elseif ($Uri -match 'usersPermissions') { [pscustomobject]@{ value = @([pscustomobject]@{ userSecurityId = 'u1'; state = 'Enabled'; licenseType = 'Full User' authenticationEmail = 'a@contoso.com'; expiryDate = '0001-01-01T00:00:00Z' applicationId = '00000000-0000-0000-0000-000000000000' }) } } else { [pscustomobject]@{ value = @( [pscustomobject]@{ userSecurityId = 'u1'; roleId = 'SUPER'; roleName = 'all' company = ''; userName = 'A'; scope = 'System' userLicenseType = 'Full User'; appName = '' } [pscustomobject]@{ userSecurityId = 'u1'; roleId = 'D365 BASIC'; roleName = 'basic' company = 'Contoso Inc.'; userName = 'A'; scope = 'Tenant' userLicenseType = 'Full User'; appName = '' } ) } } } @(Get-MsecBusinessCentralAccess -WarningAction SilentlyContinue) } ($rows | Where-Object RoleId -eq 'SUPER').Company | Should -Be '(all companies)' ($rows | Where-Object RoleId -eq 'D365 BASIC').Company | Should -Be 'Contoso Inc.' # 0001-01-01 is the product's "never expires", not a date in the past. Comparing against # DateTime::MinValue does NOT catch it - parsing the Z-suffixed sentinel converts to # local time, so east of UTC it lands just above MinValue and reads as a real date. ($rows | Where-Object RoleId -eq 'SUPER').ExpiryDate | Should -BeNullOrEmpty } It 'excludes disabled users by default but reports how many were hidden' { $r = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production' }) } } elseif ($Uri -match '/api/v2\.0/companies$') { [pscustomobject]@{ value = @([pscustomobject]@{ id = 'c1' }) } } elseif ($Uri -match 'usersPermissions') { [pscustomobject]@{ value = @( [pscustomobject]@{ userSecurityId = 'live'; state = 'Enabled'; applicationId = '00000000-0000-0000-0000-000000000000' } [pscustomobject]@{ userSecurityId = 'dead'; state = 'Disabled'; applicationId = '00000000-0000-0000-0000-000000000000' } ) } } else { [pscustomobject]@{ value = @( [pscustomobject]@{ userSecurityId = 'live'; roleId = 'SUPER'; company = ''; userName = 'LIVE' } [pscustomobject]@{ userSecurityId = 'dead'; roleId = 'SUPER'; company = ''; userName = 'DEAD' } ) } } } $rows = @(Get-MsecBusinessCentralAccess -WarningVariable wv -WarningAction SilentlyContinue) $withDisabled = @(Get-MsecBusinessCentralAccess -IncludeDisabled -WarningAction SilentlyContinue) [pscustomobject]@{ Default = $rows; All = $withDisabled; Warnings = $wv } } @($r.Default).Count | Should -Be 1 $r.Default.UserName | Should -Be 'LIVE' @($r.All).Count | Should -Be 2 # Silently dropping them would hide an account that re-enabling restores. ($r.Warnings | Where-Object { "$_" -match '1 assignment' }) | Should -Not -BeNullOrEmpty } It 'marks an Entra application principal, which Entra alone cannot characterise' { $rows = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production' }) } } elseif ($Uri -match '/api/v2\.0/companies$') { [pscustomobject]@{ value = @([pscustomobject]@{ id = 'c1' }) } } elseif ($Uri -match 'usersPermissions') { [pscustomobject]@{ value = @( [pscustomobject]@{ userSecurityId = 'app'; state = 'Enabled'; applicationId = '11111111-1111-1111-1111-111111111111' } [pscustomobject]@{ userSecurityId = 'guy'; state = 'Enabled'; applicationId = '00000000-0000-0000-0000-000000000000' } ) } } else { [pscustomobject]@{ value = @( [pscustomobject]@{ userSecurityId = 'app'; roleId = 'SUPER'; company = ''; userName = 'MSOLSYNC' } [pscustomobject]@{ userSecurityId = 'guy'; roleId = 'SUPER'; company = ''; userName = 'PERSON' } ) } } } @(Get-MsecBusinessCentralAccess -ApplicationsOnly -WarningAction SilentlyContinue) } @($rows).Count | Should -Be 1 $rows.UserName | Should -Be 'MSOLSYNC' $rows.IsApplication | Should -BeTrue # The all-zero GUID is "not an application", not an application with a null id. $rows.ApplicationId | Should -Be '11111111-1111-1111-1111-111111111111' } It 'warns and skips an environment whose access could not be read, rather than returning it empty' { $r = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'Good'; type = 'Production' } [pscustomobject]@{ name = 'Bad'; type = 'Sandbox' }) } } elseif ($Uri -match '/Bad/') { throw 'boom' } elseif ($Uri -match '/api/v2\.0/companies$') { [pscustomobject]@{ value = @([pscustomobject]@{ id = 'c1' }) } } elseif ($Uri -match 'usersPermissions') { [pscustomobject]@{ value = @([pscustomobject]@{ userSecurityId = 'u'; state = 'Enabled'; applicationId = '00000000-0000-0000-0000-000000000000' }) } } else { [pscustomobject]@{ value = @([pscustomobject]@{ userSecurityId = 'u'; roleId = 'SUPER'; company = ''; userName = 'A' }) } } } $rows = @(Get-MsecBusinessCentralAccess -WarningVariable wv -WarningAction SilentlyContinue) [pscustomobject]@{ Rows = $rows; Warnings = $wv } } @($r.Rows).Count | Should -Be 1 # An unreviewed environment must be named, or it reads as a clean one. ($r.Warnings | Where-Object { "$_" -match "'Bad'" -and "$_" -match 'NOT included' }) | Should -Not -BeNullOrEmpty } It 'never calls userPermissionSets, which 400s on some environments and works on others' { $uris = InModuleScope msec { $script:Uris = [System.Collections.Generic.List[string]]::new() Mock Invoke-RestMethod -MockWith { $script:Uris.Add([string]$Uri) if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production' }) } } elseif ($Uri -match '/api/v2\.0/companies$') { [pscustomobject]@{ value = @([pscustomobject]@{ id = 'c1' }) } } elseif ($Uri -match 'usersPermissions') { [pscustomobject]@{ value = @() } } else { [pscustomobject]@{ value = @() } } } Get-MsecBusinessCentralAccess -WarningAction SilentlyContinue | Out-Null $script:Uris } ($uris -join ' ') | Should -Not -Match 'userPermissionSets' ($uris -join ' ') | Should -Match 'accessControls' } It 'names the environments that exist when an unknown one is asked for' { $w = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production' }) } } else { [pscustomobject]@{ value = @() } } } Get-MsecBusinessCentralAccess -Environment 'NoSuchEnv' -WarningVariable wv -WarningAction SilentlyContinue | Out-Null $wv } ($w | Where-Object { "$_" -match 'NoSuchEnv' -and "$_" -match 'Prod' }) | Should -Not -BeNullOrEmpty } } Describe 'Get-MsecBusinessCentralEnvironment' { BeforeEach { InModuleScope msec { Mock Get-MsecBusinessCentralToken -MockWith { [pscustomobject]@{ Resource = 'https://api.businesscentral.dynamics.com'; Headers = @{} } } } } It 'leaves a count null when it could not be taken, rather than reporting zero' { $rows = InModuleScope msec { Mock Invoke-RestMethod -MockWith { if ($Uri -match '/environments$') { [pscustomobject]@{ value = @([pscustomobject]@{ name = 'Prod'; type = 'Production'; countryCode = 'SE' }) } } else { throw 'denied' } } @(Get-MsecBusinessCentralEnvironment) } # An environment that refused enumeration must not render as an empty one - that is the # reading which turns an unreviewed sandbox into a harmless-looking row. $rows.Companies | Should -BeNullOrEmpty $rows.Users | Should -BeNullOrEmpty $rows.Name | Should -Be 'Prod' } It 'filters to non-production with -SandboxOnly' { $rows = InModuleScope msec { Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'Prod'; type = 'Production' } [pscustomobject]@{ name = 'Box'; type = 'Sandbox' }) } } @(Get-MsecBusinessCentralEnvironment -SandboxOnly -Quick) } @($rows).Count | Should -Be 1 $rows.Name | Should -Be 'Box' } } |