Tests/Get-MsecIntuneConfigurationProfile.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecIntuneConfigurationProfile. The function merges Settings Catalog and # classic Templates into a unified shape, derives Platform from @odata.type, gets # AssignmentCount via $expand, and optionally adds per-policy status counts. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecIntuneConfigurationProfile' { 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 'merges Settings Catalog policies and classic device configurations into a unified shape' { $rows = InModuleScope msec { 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 '/beta/deviceManagement/configurationPolicies\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'sc-1'; name = 'Win10 Hardening'; description = 'Catalog policy' platforms = 'windows10' createdDateTime = '2026-04-01T00:00:00Z'; lastModifiedDateTime = '2026-05-01T00:00:00Z' assignments = @(@{ target = @{ groupId = 'g-1' } }; @{ target = @{ groupId = 'g-2' } }) } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'tpl-1'; displayName = 'Legacy iOS Restrictions'; description = 'Classic template' '@odata.type' = '#microsoft.graph.iosGeneralDeviceConfiguration' createdDateTime = '2025-12-01T00:00:00Z'; lastModifiedDateTime = '2026-02-01T00:00:00Z' assignments = @() } [pscustomobject]@{ # Regression: windows10* used to also match windows* without 'break' - # producing Platform = @('windows10','windows') instead of a single value. id = 'tpl-2'; displayName = 'Win10 Custom'; description = 'Classic template' '@odata.type' = '#microsoft.graph.windows10CustomConfiguration' createdDateTime = '2024-05-20T00:00:00Z'; lastModifiedDateTime = '2024-06-01T00:00:00Z' assignments = @(@{ target = @{ groupId = 'g-3' } }) } ) } } Get-MsecIntuneConfigurationProfile -NoGroupNameLookup } $rows.Count | Should -Be 3 $sc = $rows | Where-Object Source -eq 'SettingsCatalog' $sc.Id | Should -Be 'sc-1' $sc.DisplayName | Should -Be 'Win10 Hardening' # comes from .name, not .displayName $sc.Platform | Should -Be 'windows10' $sc.Type | Should -Be 'Settings Catalog' $ios = $rows | Where-Object Id -eq 'tpl-1' $ios.DisplayName | Should -Be 'Legacy iOS Restrictions' $ios.Platform | Should -Be 'iOS' # derived from @odata.type prefix $ios.Type | Should -Be 'iosGeneralDeviceConfiguration' # Regression: Platform is a single string, not an array of wildcard matches. $win = $rows | Where-Object Id -eq 'tpl-2' $win.Platform | Should -Be 'windows10' $win.Platform | Should -BeOfType [string] # AssignmentCount comes from $expand=assignments (no extra round-trip). $sc.AssignmentCount | Should -Be 2 ($rows | Where-Object Id -eq 'tpl-1').AssignmentCount | Should -Be 0 $win.AssignmentCount | Should -Be 1 # Status column only appears with -IncludeStatus - the rows here should not have it. $sc.PSObject.Properties.Name | Should -Not -Contain 'Status' $win.PSObject.Properties.Name | Should -Not -Contain 'Status' # Raw - every row carries the full Graph object verbatim. Lets the consumer # access fields we don't flatten (createdDateTime is just an obvious one). $sc.Raw | Should -Not -BeNullOrEmpty $sc.Raw.id | Should -Be 'sc-1' $sc.Raw.name | Should -Be 'Win10 Hardening' $win.Raw | Should -Not -BeNullOrEmpty $win.Raw.id | Should -Be 'tpl-2' $win.Raw.'@odata.type' | Should -Match 'windows10CustomConfiguration' # PSTypeName tag - so the .psm1's Update-TypeData picks the right # DefaultDisplayPropertySet (hides Raw from default Format-Table). $sc.PSObject.TypeNames | Should -Contain 'MsecIntuneConfigurationProfile' $win.PSObject.TypeNames | Should -Contain 'MsecIntuneConfigurationProfile' } It '-IncludeSettings fetches per-policy settings for SC and merges them into Raw' { $rows = InModuleScope msec { 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 } } # SC list endpoint returns one policy Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/deviceManagement/configurationPolicies\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'sc-1'; name = 'Win10 Hardening'; platforms = 'windows10' assignments = @() } ) } } # Per-policy settings endpoint - this is the extra call -IncludeSettings makes Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/configurationPolicies/sc-1/settings' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = '0'; settingInstance = @{ settingDefinitionId = 'firewall_state' } } [pscustomobject]@{ id = '1'; settingInstance = @{ settingDefinitionId = 'bitlocker_required' } } ) } } # No Templates in this test Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations' } -MockWith { [pscustomobject]@{ value = @() } } Get-MsecIntuneConfigurationProfile -Source SettingsCatalog -IncludeSettings -NoGroupNameLookup } $rows.Count | Should -Be 1 $rows[0].Raw.settings | Should -Not -BeNullOrEmpty $rows[0].Raw.settings.Count | Should -Be 2 $rows[0].Raw.settings[0].settingInstance.settingDefinitionId | Should -Be 'firewall_state' } It '-IncludeStatus adds check-in counts from the right endpoint per Source' { $rows = InModuleScope msec { 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 } } # The list-all endpoint with $expand=assignments Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/deviceManagement/configurationPolicies\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'sc-1'; name = 'SC Test'; platforms = 'windows10' assignments = @(@{ target = @{ groupId = 'g-1' } }) } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'tpl-1'; displayName = 'Tpl Test' '@odata.type' = '#microsoft.graph.windows10GeneralConfiguration' assignments = @(@{ target = @{ groupId = 'g-1' } }) } ) } } # Templates status: single aggregated object from /deviceStatusOverview Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/deviceConfigurations/tpl-1/deviceStatusOverview' } -MockWith { [pscustomobject]@{ successCount = 10; errorCount = 0; conflictCount = 0 notApplicableCount = 2; pendingCount = 0 } } # Settings Catalog status: pre-fetched via the Reports API. Mock the helper # directly to avoid mocking the full exportJob + poll + download flow here. Mock Get-MsecSettingsCatalogStatusReport -MockWith { @{ 'sc-1' = [pscustomobject]@{ SuccessCount = 3; ErrorCount = 1; ConflictCount = 0 NotApplicableCount = 1; PendingCount = 0 } } } Get-MsecIntuneConfigurationProfile -IncludeStatus -NoGroupNameLookup } # Templates: real counts from deviceStatusOverview $tpl = $rows | Where-Object Source -eq 'Templates' $tpl.SuccessCount | Should -Be 10 $tpl.ErrorCount | Should -Be 0 $tpl.SuccessPercent | Should -Be 100.0 # Settings Catalog: counts came from the (mocked) Reports API helper. $sc = $rows | Where-Object Source -eq 'SettingsCatalog' $sc.SuccessCount | Should -Be 3 $sc.ErrorCount | Should -Be 1 # Applicable = 3 success + 1 error + 0 conflict + 0 pending = 4 -> 75.0 $sc.SuccessPercent | Should -Be 75.0 # Status rollup with -IncludeStatus. $tpl.Status | Should -Be 'Healthy' # 100% success, no errors/conflicts $sc.Status | Should -Be 'Degraded' # 75% with an error } It 'NotDeployed policies get zero counts (not nulls) on every status field' { $rows = InModuleScope msec { 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 '/beta/deviceManagement/configurationPolicies\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'sc-unassigned'; name = 'Unassigned SC policy'; platforms = 'windows10' assignments = @() # AssignmentCount = 0 -> NotDeployed } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations\?' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'tpl-unassigned'; displayName = 'Unassigned Template' '@odata.type' = '#microsoft.graph.windows10GeneralConfiguration' assignments = @() } ) } } Mock Get-MsecSettingsCatalogStatusReport -MockWith { @{} } Get-MsecIntuneConfigurationProfile -IncludeStatus -NoGroupNameLookup } foreach ($r in $rows) { $r.Status | Should -Be 'NotDeployed' $r.SuccessCount | Should -Be 0 $r.ErrorCount | Should -Be 0 $r.ConflictCount | Should -Be 0 $r.NotApplicableCount | Should -Be 0 $r.PendingCount | Should -Be 0 $r.SuccessPercent | Should -Be 0 } } It 'Status is NotReporting when assigned but no status data, and all counts are zero (consistent with NotDeployed)' { $rows = InModuleScope msec { 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 '/beta/deviceManagement/configurationPolicies\?' } -MockWith { [pscustomobject]@{ value = @( # Assigned, but the report (mocked below) does not include this PolicyId # - same shape as your Autopilot device prep / empty-group policies. [pscustomobject]@{ id = 'sc-orphan'; name = 'Empty group policy'; platforms = 'windows10' assignments = @(@{ target = @{ groupId = 'g-1' } }) } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations\?' } -MockWith { [pscustomobject]@{ value = @() } } Mock Get-MsecSettingsCatalogStatusReport -MockWith { @{} } # empty report Get-MsecIntuneConfigurationProfile -IncludeStatus -NoGroupNameLookup } $r = $rows[0] $r.AssignmentCount | Should -Be 1 $r.Status | Should -Be 'NotReporting' # NotReporting now zeros every status field (consistent with NotDeployed) so the # table doesn't mix blanks and integers per row. $r.SuccessCount | Should -Be 0 $r.ErrorCount | Should -Be 0 $r.ConflictCount | Should -Be 0 $r.NotApplicableCount | Should -Be 0 $r.PendingCount | Should -Be 0 $r.SuccessPercent | Should -Be 0 } Context 'assignment targets' { BeforeAll { # One policy per interesting target shape. Note sc-excl: tenant-wide with a # carve-out, which AssignmentCount alone reports identically to sc-two-groups. $script:ProfileMockText = @' 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 '/beta/deviceManagement/configurationPolicies\?' } -MockWith { $t = { param($Type, $GroupId, $FilterType) $o = @{ '@odata.type' = "#microsoft.graph.$Type" } if ($GroupId) { $o['groupId'] = $GroupId } if ($FilterType) { $o['deviceAndAppManagementAssignmentFilterType'] = $FilterType $o['deviceAndAppManagementAssignmentFilterId'] = 'flt-1' } [pscustomobject]@{ target = [pscustomobject]$o } } [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'sc-allusers'; name = 'Baseline'; platforms = 'windows10' assignments = @((& $t 'allLicensedUsersAssignmentTarget')) } [pscustomobject]@{ id = 'sc-excl'; name = 'Everyone But VIPs'; platforms = 'windows10' assignments = @((& $t 'allLicensedUsersAssignmentTarget'), (& $t 'exclusionGroupAssignmentTarget' 'g-vip')) } [pscustomobject]@{ id = 'sc-two-groups'; name = 'Two Rings'; platforms = 'windows10' assignments = @((& $t 'groupAssignmentTarget' 'g-ring1'), (& $t 'groupAssignmentTarget' 'g-ring2')) } [pscustomobject]@{ id = 'sc-filtered'; name = 'Filtered Devices'; platforms = 'windows10' assignments = @((& $t 'allDevicesAssignmentTarget' $null 'include')) } [pscustomobject]@{ id = 'sc-none'; name = 'Orphan'; platforms = 'windows10' assignments = @() } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/deviceManagement/deviceConfigurations\?' } -MockWith { [pscustomobject]@{ value = @() } } '@ } It 'reports the target type with no group call at all under -NoGroupNameLookup' { $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) # The switch must mean PROVABLY no per-group calls, not merely fewer. Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { throw 'no group lookup may happen under -NoGroupNameLookup' } @(Get-MsecIntuneConfigurationProfile -Source SettingsCatalog -NoGroupNameLookup) } ($out | Where-Object Id -eq 'sc-allusers').AssignmentType | Should -Be @('AllUsers') ($out | Where-Object Id -eq 'sc-filtered').AssignmentType | Should -Be @('AllDevices') ($out | Where-Object Id -eq 'sc-filtered').HasAssignmentFilter | Should -BeTrue # An unassigned policy gets empty arrays, not $null, so -contains and .Count # work at the call site without a null check first. ($out | Where-Object Id -eq 'sc-none').AssignmentType | Should -BeNullOrEmpty @(($out | Where-Object Id -eq 'sc-none').AssignmentGroup).Count | Should -Be 0 # Ids stand in for names because the lookup was skipped. ($out | Where-Object Id -eq 'sc-two-groups').AssignmentType | Should -Be @('Group') ($out | Where-Object Id -eq 'sc-two-groups').AssignmentGroup | Should -Be @('g-ring1', 'g-ring2') ($out | Where-Object Id -eq 'sc-excl').AssignmentType | Should -Be @('AllUsers', 'ExclusionGroup') ($out | Where-Object Id -eq 'sc-excl').AssignmentExcludedGroup | Should -Be @('g-vip') # The carve-out must not leak into the INCLUDED groups column. @(($out | Where-Object Id -eq 'sc-excl').AssignmentGroup).Count | Should -Be 0 } It 'distinguishes an exclusion from a second group, which AssignmentCount cannot' { $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) @(Get-MsecIntuneConfigurationProfile -Source SettingsCatalog -NoGroupNameLookup) } $excl = $out | Where-Object Id -eq 'sc-excl' $two = $out | Where-Object Id -eq 'sc-two-groups' # Identical counts... $excl.AssignmentCount | Should -Be 2 $two.AssignmentCount | Should -Be 2 # ...entirely different deployments. $excl.AssignmentType | Should -Be @('AllUsers', 'ExclusionGroup') $two.AssignmentType | Should -Be @('Group') $excl.AssignmentExcludedGroup | Should -Be @('g-vip') @($two.AssignmentExcludedGroup).Count | Should -Be 0 @($excl.AssignmentDetail | Where-Object IsExclusion).Count | Should -Be 1 @($two.AssignmentDetail | Where-Object IsExclusion).Count | Should -Be 0 } It 'resolves group names by default, one call per distinct group' { $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) $script:GroupCalls = [System.Collections.Generic.List[string]]::new() Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { $id = ([regex]::Match([string]$Uri, '/groups/(?<id>[^?]+)')).Groups['id'].Value $script:GroupCalls.Add($id) [pscustomobject]@{ id = $id; displayName = "name-of-$id" } } [pscustomobject]@{ Rows = @(Get-MsecIntuneConfigurationProfile -Source SettingsCatalog) Calls = @($script:GroupCalls) } } ($out.Rows | Where-Object Id -eq 'sc-two-groups').AssignmentGroup | Should -Be @('name-of-g-ring1', 'name-of-g-ring2') ($out.Rows | Where-Object Id -eq 'sc-excl').AssignmentExcludedGroup | Should -Be @('name-of-g-vip') ($out.Rows | Where-Object Id -eq 'sc-two-groups').AssignmentDetail.GroupName | Should -Be @('name-of-g-ring1', 'name-of-g-ring2') # Same names, reached the other way - the column is derived from the detail. # Three distinct groups across the policies, three lookups - no duplicates. @($out.Calls).Count | Should -Be 3 @($out.Calls | Sort-Object -Unique).Count | Should -Be 3 } It 'labels a deleted group instead of leaving it blank' { # A policy assigned only to a group that no longer exists is deployed to # nobody. That is a finding, so it has to be legible rather than empty. $row = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { throw 'Response status code does not indicate success: 404 (Not Found).' } @(Get-MsecIntuneConfigurationProfile -Source SettingsCatalog) | Where-Object Id -eq 'sc-excl' } $row.AssignmentExcludedGroup | Should -Be @('<deleted group g-vip>') } It 'warns once and falls back to ids when Group.Read.All is missing' { $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) $script:GroupCalls = [System.Collections.Generic.List[string]]::new() Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { $script:GroupCalls.Add('call') throw 'Response status code does not indicate success: 403 (Forbidden).' } $rows = Get-MsecIntuneConfigurationProfile -Source SettingsCatalog ` -WarningVariable w -WarningAction SilentlyContinue [pscustomobject]@{ Rows = @($rows); Warnings = @($w); Calls = @($script:GroupCalls) } } ($out.Warnings -join "`n") | Should -Match 'Group\.Read\.All' # One warning for the run, not one per assigned group. @($out.Warnings).Count | Should -Be 1 # And it stops asking after the first refusal. @($out.Calls).Count | Should -Be 1 # The target TYPE is unaffected by a failed name lookup. ($out.Rows | Where-Object Id -eq 'sc-allusers').AssignmentType | Should -Be @('AllUsers') ($out.Rows | Where-Object Id -eq 'sc-excl').AssignmentType | Should -Be @('AllUsers', 'ExclusionGroup') ($out.Rows | Where-Object Id -eq 'sc-excl').AssignmentExcludedGroup | Should -Be @('g-vip') } It 'gives up after one auth failure instead of warning per group' { # A 401 is not about this group - it will be true of the next one too. Warning # per group would bury the output of a tenant with hundreds of profiles. $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) $script:GroupCalls = [System.Collections.Generic.List[string]]::new() Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { $script:GroupCalls.Add('call') throw 'Response status code does not indicate success: 401 (Unauthorized).' } $rows = Get-MsecIntuneConfigurationProfile -Source SettingsCatalog ` -WarningVariable w -WarningAction SilentlyContinue [pscustomobject]@{ Rows = @($rows); Warnings = @($w); Calls = @($script:GroupCalls) } } @($out.Warnings).Count | Should -Be 1 @($out.Calls).Count | Should -Be 1 # A 401 must NOT be described as a missing permission - the fixes are opposite. ($out.Warnings -join "`n") | Should -Match 'Connect-Msec' ($out.Warnings -join "`n") | Should -Not -Match 'Group\.Read\.All' # Target types survive a failed name lookup untouched. ($out.Rows | Where-Object Id -eq 'sc-excl').AssignmentType | Should -Be @('AllUsers', 'ExclusionGroup') } It 'warns per group for a failure that really is per group' { # A transient failure on one group says nothing about the next, so it keeps # going - and each warning names a different id rather than repeating. $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/v1.0/groups/' } -MockWith { throw 'Response status code does not indicate success: 500 (Internal Server Error).' } $rows = Get-MsecIntuneConfigurationProfile -Source SettingsCatalog ` -WarningVariable w -WarningAction SilentlyContinue [pscustomobject]@{ Rows = @($rows); Warnings = @($w) } } # Three distinct groups across the fixture, so three warnings - bounded by # distinct groups, not by policy count. @($out.Warnings).Count | Should -Be 3 ($out.Warnings -join "`n") | Should -Match 'g-vip' ($out.Warnings -join "`n") | Should -Match 'g-ring1' # Ids stand in for the names that could not be read. ($out.Rows | Where-Object Id -eq 'sc-excl').AssignmentExcludedGroup | Should -Be @('g-vip') } It 'keeps AssignmentCount as the raw number of assignments' { # AssignmentType is DISTINCT, so it collapses three groups into one entry. # AssignmentDetail must still account for every assignment, or the columns # and the count would disagree. $out = InModuleScope msec -Parameters @{ MockText = $script:ProfileMockText } { param($MockText) & ([scriptblock]::Create($MockText)) @(Get-MsecIntuneConfigurationProfile -Source SettingsCatalog -NoGroupNameLookup) } foreach ($row in $out) { @($row.AssignmentDetail).Count | Should -Be $row.AssignmentCount ` -Because "AssignmentDetail must account for every assignment on $($row.Id)" } } } } |