Tests/Convert-MsecEntraSid.Tests.ps1
|
#Requires -Module Pester # # Tests for Convert-MsecEntraSid. The arithmetic is the whole point of the command, # so it is pinned against a known SID/objectId pair and round-tripped in both # directions. The other half of the coverage is the boundary: an on-premises # S-1-5-21 SID has no objectId in it, and must be refused rather than turned into # a plausible-looking but meaningless GUID. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop # Verified pair: little-endian uint32 packing of the objectId's 16 bytes. $script:KnownSid = 'S-1-12-1-2640853384-1293864314-2707107988-2394433369' $script:KnownObjectId = '9d683988-cd7a-4d1e-9430-5ba15927b88e' $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Convert-MsecEntraSid' { Context 'offline conversion' { It 'converts a known Entra SID to its objectId' { $row = Convert-MsecEntraSid -Sid $script:KnownSid $row.ObjectId | Should -Be ([guid] $script:KnownObjectId) $row.Sid | Should -Be $script:KnownSid $row.Direction | Should -Be 'SidToObjectId' $row.ObjectId | Should -BeOfType [guid] $row.PSObject.TypeNames | Should -Contain 'MsecEntraSid' } It 'converts an objectId back to the same SID' { $row = Convert-MsecEntraSid -ObjectId $script:KnownObjectId $row.Sid | Should -Be $script:KnownSid $row.ObjectId | Should -Be ([guid] $script:KnownObjectId) $row.Direction | Should -Be 'ObjectIdToSid' } It 'round-trips an arbitrary GUID byte-exactly' { # A fixed GUID with a high bit set in every one of the four groups, so a # signed/unsigned or endianness slip cannot pass unnoticed. $guid = [guid] 'ffffffff-8000-8001-80ff-ffffffffffff' $sid = (Convert-MsecEntraSid -ObjectId $guid).Sid (Convert-MsecEntraSid -Sid $sid).ObjectId | Should -Be $guid } It 'accepts several SIDs from the pipeline and preserves order' { $second = (Convert-MsecEntraSid -ObjectId '00000000-0000-0000-0000-000000000001').Sid $rows = @($script:KnownSid, $second) | Convert-MsecEntraSid $rows.Count | Should -Be 2 $rows[0].ObjectId | Should -Be ([guid] $script:KnownObjectId) $rows[1].ObjectId | Should -Be ([guid] '00000000-0000-0000-0000-000000000001') } It 'accepts SIDs by property name, so rows from another command pipe in' { $rows = [pscustomobject]@{ Sid = $script:KnownSid } | Convert-MsecEntraSid $rows.ObjectId | Should -Be ([guid] $script:KnownObjectId) } It 'tolerates surrounding whitespace from a text file' { $row = Convert-MsecEntraSid -Sid " $script:KnownSid`t" $row.Sid | Should -Be $script:KnownSid $row.ObjectId | Should -Be ([guid] $script:KnownObjectId) } It 'does not need a session when -Resolve is not used' { InModuleScope Msec { $script:MsecSession = $null } { Convert-MsecEntraSid -Sid $script:KnownSid } | Should -Not -Throw } } Context 'rejecting SIDs that carry no objectId' { It 'refuses an on-premises S-1-5-21 SID and says why' { { Convert-MsecEntraSid -Sid 'S-1-5-21-1004336348-1177238915-682003330-512' } | Should -Throw -ExpectedMessage '*onPremisesSecurityIdentifier*' } It 'refuses a well-known SID' { { Convert-MsecEntraSid -Sid 'S-1-5-32-544' } | Should -Throw -ExpectedMessage '*not an Entra ID SID*' } It 'refuses an S-1-12-1 SID with the wrong number of sub-authorities' { { Convert-MsecEntraSid -Sid 'S-1-12-1-2640853384-1293864314-2707107988' } | Should -Throw -ExpectedMessage '*S-1-12-1-<a>-<b>-<c>-<d>*' } It 'refuses a sub-authority that does not fit in 32 bits' { { Convert-MsecEntraSid -Sid 'S-1-12-1-4294967296-1293864314-2707107988-2394433369' } | Should -Throw -ExpectedMessage '*exceeds the 32-bit maximum*' } It 'refuses text that is not a SID at all' { { Convert-MsecEntraSid -Sid 'user@contoso.com' } | Should -Throw -ExpectedMessage '*not an Entra ID SID*' } } Context '-Resolve' { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant' ClientId = 'client' KeyVaultName = 'kv-test' KeyName = 'msec-app' ThumbprintBytes = $Thumb Tokens = @{} } } } It 'adds the display name, UPN and object type from Graph' { $row = InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/9d683988' } -MockWith { [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user' id = '9d683988-cd7a-4d1e-9430-5ba15927b88e' displayName = 'Ada Lovelace' userPrincipalName = 'ada@contoso.com' } } Convert-MsecEntraSid -Sid $Sid -Resolve } $row.DisplayName | Should -Be 'Ada Lovelace' $row.UserPrincipalName | Should -Be 'ada@contoso.com' # The '#microsoft.graph.' prefix is stripped so the value reads as a type. $row.ObjectType | Should -Be 'user' $row.ObjectId | Should -Be ([guid] $script:KnownObjectId) # Resolved rows get their own format view in front of the plain one. $row.PSObject.TypeNames[0] | Should -Be 'MsecEntraSidResolved' $row.PSObject.TypeNames | Should -Contain 'MsecEntraSid' } It 'resolves a group, which has no UPN' { $row = InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/' } -MockWith { [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group' displayName = 'sg-admins' } } Convert-MsecEntraSid -Sid $Sid -Resolve } $row.ObjectType | Should -Be 'group' $row.DisplayName | Should -Be 'sg-admins' $row.UserPrincipalName | Should -BeNullOrEmpty } It 'resolves a directory role and keeps roleTemplateId reachable via Raw' { # The common case for a SID scraped off an Entra-joined device: local admin # is granted to the ROLE, so the SID is a directoryRole, not a person. The # per-tenant instance id is useless for joining across tenants, so the # stable roleTemplateId must survive into the row. $row = InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/' } -MockWith { [pscustomobject]@{ '@odata.type' = '#microsoft.graph.directoryRole' id = '9d683988-cd7a-4d1e-9430-5ba15927b88e' displayName = 'Global Administrator' roleTemplateId = '62e90394-69f5-4237-9190-012177145e10' } } Convert-MsecEntraSid -Sid $Sid -Resolve } $row.ObjectType | Should -Be 'directoryRole' $row.DisplayName | Should -Be 'Global Administrator' $row.UserPrincipalName | Should -BeNullOrEmpty $row.Raw.roleTemplateId | Should -Be '62e90394-69f5-4237-9190-012177145e10' } It 'marks a deleted object NotFound instead of failing the batch' { $rows = InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } # First SID resolves, second is gone - the run must still produce both # rows. Pester tries the most recently defined mock first, so the catch-all # 404 goes in before the specific object that does exist. Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/' } -MockWith { throw 'Response status code does not indicate success: 404 (Not Found).' } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/9d683988' } -MockWith { [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; displayName = 'Ada Lovelace' } } $gone = (Convert-MsecEntraSid -ObjectId '00000000-0000-0000-0000-000000000001').Sid @($Sid, $gone) | Convert-MsecEntraSid -Resolve } $rows.Count | Should -Be 2 $rows[0].ObjectType | Should -Be 'user' $rows[1].ObjectType | Should -Be 'NotFound' $rows[1].DisplayName | Should -BeNullOrEmpty } It 'rewrites a 403 to mention the missing Directory.Read.All permission' { InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/directoryObjects/' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Convert-MsecEntraSid -Sid $Sid -Resolve } | Should -Throw -ExpectedMessage '*Directory.Read.All*' } } It 'requires a session' { InModuleScope Msec -Parameters @{ Sid = $script:KnownSid } { param($Sid) $script:MsecSession = $null { Convert-MsecEntraSid -Sid $Sid -Resolve } | Should -Throw -ExpectedMessage '*Connect-Msec*' } } } Context 'module surface' { It 'is exported by the module' { (Get-Command Convert-MsecEntraSid -Module Msec) | Should -Not -BeNullOrEmpty } It 'is listed in FunctionsToExport' { $manifest = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..' 'Msec.psd1') $manifest.FunctionsToExport | Should -Contain 'Convert-MsecEntraSid' } } } |