diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index d0318bbdde..f5cc448159 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -9,6 +9,12 @@ function Push-UpdatePermissionsQueue { $FailureMessage = $null $DomainRefreshRequired = $false + # Read by the finally block, so they must survive an early throw in the try. + $ConsentRow = $null + $ConsentAttempted = $false + $Attempts = 0 + $ResetSP = $false + try { if (!$Item.defaultDomainName) { $DomainRefreshRequired = $true @@ -20,10 +26,31 @@ function Push-UpdatePermissionsQueue { $Tenant = Get-Tenants -TenantFilter $Item.customerId -IncludeErrors - if ((!$CPVRows -or $env:ApplicationID -notin $CPVRows.applicationId) -and $Tenant.delegatedPrivilegeStatus -ne 'directTenant') { - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message 'A New tenant has been added, or a new CIPP-SAM Application is in use' -Sev 'Warning' -API 'NewTenant' + $ConsentRow = $CPVRows | Where-Object { $_.applicationId -eq $env:ApplicationID } | Select-Object -First 1 + + # The finally block writes a row even on failure, so existence alone does not prove + # consent. -eq 'Failed' so status-less legacy rows don't re-consent the estate on deploy. + $NeedsConsent = !$ConsentRow -or $ConsentRow.LastStatus -eq 'Failed' + + if ($NeedsConsent -and $Tenant.delegatedPrivilegeStatus -ne 'directTenant') { + # Only a reset can fix an entry that exists but is wrong ('Permission entry already + # exists' short-circuits a plain re-consent). Escalate on a known consent error or + # after a failed re-consent; at most one reset per week since it briefly drops access. + $ConsentAttempted = $true + $Attempts = if ($ConsentRow.ConsentAttempts) { [int]$ConsentRow.ConsentAttempts } else { 0 } + $KnownConsentError = [bool]($ConsentRow -and $ConsentRow.LastError -match 'AADSTS(65001|90094|500011)|Insufficient privileges|Authorization_RequestDenied') + $ResetAllowed = $true + if ($ConsentRow.LastResetUtc) { + try { $ResetAllowed = ([datetime]::UtcNow - [datetime]::Parse($ConsentRow.LastResetUtc)).TotalDays -ge 7 } catch { $ResetAllowed = $true } + } + $ResetSP = [bool]($ConsentRow -and $ResetAllowed -and ($KnownConsentError -or $Attempts -ge 1)) + + $ConsentReason = if (!$ConsentRow) { 'A New tenant has been added, or a new CIPP-SAM Application is in use' } + elseif ($ResetSP) { "The last permissions run failed and re-applying consent has not fixed it (attempt $($Attempts + 1)), resetting the service principal" } + else { 'The last permissions run failed, re-applying CPV consent' } + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message $ConsentReason -Sev 'Warning' -API 'NewTenant' Write-Information 'Adding CPV permissions' - Set-CIPPCPVConsent -Tenantfilter $Item.customerId + Set-CIPPCPVConsent -Tenantfilter $Item.customerId -ResetSP $ResetSP $DomainRefreshRequired = $true } Write-Information 'Updating permissions' @@ -83,6 +110,19 @@ function Push-UpdatePermissionsQueue { if ($FailureMessage) { $GraphRequest.LastError = "$FailureMessage" } + + # Failed re-consent counter drives the reset escalation; cleared on success. + if ($Status -eq 'Success') { + $GraphRequest.ConsentAttempts = '0' + } elseif ($ConsentAttempted) { + $GraphRequest.ConsentAttempts = "$($Attempts + 1)" + } elseif ($ConsentRow.ConsentAttempts) { + $GraphRequest.ConsentAttempts = "$($ConsentRow.ConsentAttempts)" + } + # The row is replaced, not merged - carry these forward or the weekly limit re-arms. + if ($ResetSP) { $GraphRequest.LastResetUtc = ([datetime]::UtcNow.ToString('o')) } + elseif ($ConsentRow.LastResetUtc) { $GraphRequest.LastResetUtc = "$($ConsentRow.LastResetUtc)" } + Add-CIPPAzDataTableEntity @CpvTable -Entity $GraphRequest -Force } catch { Write-Information "Failed to persist cpvtenants row for $($Item.displayName): $($_.Exception.Message)" diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 4b01cda92a..8391eaa00b 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -238,7 +238,31 @@ function Get-CippSamPermissions { } } - $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + # When the permission set last changed. Content hash, not mtime: git doesn't store mtimes, + # so every checkout/build restamped the manifest and re-queued the whole estate for CPV. + $ManifestContent = (Get-Content -Path $SamManifestFile.FullName -Raw) + (Get-Content -Path $AdditionalPermissionsFile.FullName -Raw) + $ManifestHash = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($ManifestContent))) + $HashRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'ManifestHash'" + + if ($HashRow.Hash -eq $ManifestHash -and $HashRow.FirstSeenUtc) { + $Timestamp = ([datetime]::Parse($HashRow.FirstSeenUtc)).ToUniversalTime() + } else { + # New permission set - advance the timestamp once and record it. + $Timestamp = [datetime]::UtcNow + try { + $null = Add-CIPPAzDataTableEntity @Table -Force -Entity @{ + PartitionKey = 'CIPP-SAM' + RowKey = 'ManifestHash' + Hash = $ManifestHash + FirstSeenUtc = $Timestamp.ToString('o') + } + } catch { + # Unpersisted, every call would look like first sight; mtime is at least stable. + Write-Information "Could not persist the SAM manifest hash: $($_.Exception.Message)" + $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + } + } + if ($SavedRow.Timestamp) { $SavedTimestamp = $SavedRow.Timestamp.DateTime.ToUniversalTime() if ($SavedTimestamp -gt $Timestamp) { diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 99845e0216..1dc4048459 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -169,14 +169,18 @@ function Test-CIPPAccessPermissions { $CPVRefreshList = [System.Collections.Generic.List[object]]::new() $CPVSuccess = $true foreach ($Tenant in $TenantList) { - $LastRefresh = ($CpvRefresh | Where-Object { $_.RowKey -eq $Tenant.customerId }).Timestamp.DateTime - if ($LastRefresh -lt $LastUpdate) { + $CpvRow = $CpvRefresh | Where-Object { $_.RowKey -eq $Tenant.customerId } + $LastRefresh = $CpvRow.Timestamp.DateTime + # Timestamp is rewritten even on failed runs, so freshness alone hides a broken tenant. + if ($LastRefresh -lt $LastUpdate -or $CpvRow.LastStatus -eq 'Failed') { $CPVSuccess = $false $CPVRefreshList.Add([PSCustomObject]@{ CustomerId = $Tenant.customerId DisplayName = $Tenant.displayName DefaultDomainName = $Tenant.DefaultDomainName LastRefresh = $LastRefresh + LastStatus = $CpvRow.LastStatus + LastError = $CpvRow.LastError }) } } diff --git a/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 b/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 new file mode 100644 index 0000000000..dc43563e44 --- /dev/null +++ b/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 @@ -0,0 +1,316 @@ +# The CPV consent gate: a failed cpvtenants row must re-consent, and the cases +# that must NOT re-consent (legacy rows, direct tenants, success) still don't. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-UpdatePermissionsQueue.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-UpdatePermissionsQueue.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) @{ Context = 'stub' } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors, [switch]$IncludeAll, [switch]$TriggerRefresh) } + function Set-CIPPCPVConsent { param($TenantFilter, $APIName, $Headers, [bool]$ResetSP) } + function Add-CIPPApplicationPermission { param($RequiredResourceAccess, $ApplicationId, $TenantFilter) } + function Add-CIPPDelegatedPermission { param($RequiredResourceAccess, $ApplicationId, $TenantFilter) } + function Set-CIPPSAMAdminRoles { param($TenantFilter) } + function Write-LogMessage { param($message, $tenant, $tenantId, $API, $Sev, $Headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + $script:CurrentAppId = '11111111-1111-1111-1111-111111111111' + $env:ApplicationID = $script:CurrentAppId + + $script:Item = [pscustomobject]@{ + customerId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + defaultDomainName = 'dev.example.com' + displayName = 'Example Dev' + } + + $script:ConsentError = "Could not get token: invalid_grant:AADSTS65001: The user or administrator has not consented to use the application with ID '$($script:CurrentAppId)' named 'CIPP-SAM'." +} + +Describe 'Push-UpdatePermissionsQueue CPV consent gate' { + BeforeEach { + $script:ConsentCalls = [System.Collections.Generic.List[object]]::new() + $script:WrittenRow = $null + + Mock -CommandName Set-CIPPCPVConsent -MockWith { + $script:ConsentCalls.Add([pscustomobject]@{ TenantFilter = $TenantFilter; ResetSP = [bool]$ResetSP }) + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:WrittenRow = $Entity } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Succeeded') } + Mock -CommandName Add-CIPPDelegatedPermission -MockWith { @('Succeeded') } + Mock -CommandName Set-CIPPSAMAdminRoles -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + displayName = $script:Item.displayName + delegatedPrivilegeStatus = 'granularDelegatedAdminPrivileges' + } + } + } + + Context 'when no consent record exists' { + It 'consents, without resetting the service principal' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + } + + Context 'when the last run succeeded' { + It 'does not re-consent' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Success' }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'when the last run failed because consent is missing' { + It 're-consents instead of skipping the step forever' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + } + + It 'resets the service principal, because a plain re-consent short-circuits on an existing entry' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'when the last run failed for an unrelated reason' { + It 're-consents but does not reset the service principal on the first attempt' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'Set-CIPPSAMAdminRoles: the remote server returned an error (503)' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + + It 'escalates to a reset once a plain re-consent has already been tried' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'something nobody wrote a pattern for' + ConsentAttempts = '1' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'when consent exists but its scopes are insufficient' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'Failed to grant 9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30 to 00000003-0000-0000-c000-000000000000: Insufficient privileges to complete the operation.' + }) + } + } + + It 'resets the service principal rather than re-applying the same consent' { + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'reset rate limiting' { + It 'does not reset again within a week of the last one' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '3' + LastResetUtc = ([datetime]::UtcNow.AddDays(-2).ToString('o')) + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + + It 'resets again once the week has elapsed' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '3' + LastResetUtc = ([datetime]::UtcNow.AddDays(-8).ToString('o')) + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'the attempt counter' { + It 'increments while the tenant keeps failing' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'unrecognised' + ConsentAttempts = '2' + }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Failed to grant something') } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.ConsentAttempts | Should -Be '3' + } + + It 'clears on success, so a recovered tenant starts from zero' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '4' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.ConsentAttempts | Should -Be '0' + } + + It 'does not count an attempt for a direct tenant, which never consents' { + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + delegatedPrivilegeStatus = 'directTenant' + } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Failed'; LastError = $script:ConsentError }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Failed to grant something') } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + $script:WrittenRow.ConsentAttempts | Should -BeNullOrEmpty + } + } + + Context 'when the record predates the LastStatus field' { + It 'does not re-consent, so deploying this does not re-consent the whole estate' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'when the consent record names a different application' { + It 'consents for the new application' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = '99999999-9999-9999-9999-999999999999'; LastStatus = 'Success' }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + } + } + + Context 'for a direct tenant' { + It 'never consents, CPV does not apply' { + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + delegatedPrivilegeStatus = 'directTenant' + } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Failed'; LastError = $script:ConsentError }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'the record it leaves behind' { + It 'records the failure status that the gate now reads' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Success' }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { throw $script:ConsentError } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.LastStatus | Should -Be 'Failed' + $script:WrittenRow.LastError | Should -Match 'AADSTS65001' + } + } +} diff --git a/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 b/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 new file mode 100644 index 0000000000..eb1935fe8c --- /dev/null +++ b/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 @@ -0,0 +1,100 @@ +# The SAM manifest timestamp: a rebuild with unchanged content must not move it +# (mtime restamps re-queued the whole estate for CPV); a real change must. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CippSamPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CippSamPermissions.ps1 under Modules/' } + + function Get-CippTable { param($tablename) @{ Context = 'stub' } } + function Get-CippAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($Uri, $tenantid, $NoAuthCheck, $AsApp) } + function New-GraphBulkRequest { param($tenantid, $Requests, $NoAuthCheck, $asapp) } + function Write-LogMessage { param($message, $tenant, $API, $sev, $Headers, $LogData) } + + . $FunctionPath + + $script:ConfigRoot = Join-Path ([IO.Path]::GetTempPath()) ("samman-" + [guid]::NewGuid()) + $null = New-Item -ItemType Directory -Path (Join-Path $script:ConfigRoot 'Config') -Force + $script:ManifestPath = Join-Path $script:ConfigRoot 'Config/SAMManifest.json' + $script:AdditionalPath = Join-Path $script:ConfigRoot 'Config/AdditionalPermissions.json' + $env:CIPPRootPath = $script:ConfigRoot + $env:TenantID = '00000000-0000-0000-0000-000000000001' + + function Set-Manifest { + param([string]$Scope = 'Directory.Read.All') + @{ requiredResourceAccess = @(@{ resourceAppId = '00000003-0000-0000-c000-000000000000'; resourceAccess = @(@{ id = '11111111-1111-1111-1111-111111111111'; type = 'Scope'; value = $Scope }) }) } | + ConvertTo-Json -Depth 10 | Set-Content -Path $script:ManifestPath + '[]' | Set-Content -Path $script:AdditionalPath + } + Set-Manifest +} + +AfterAll { + Remove-Item -Path $script:ConfigRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-CippSamPermissions manifest timestamp' { + BeforeEach { + $script:HashRow = $null + $script:Written = [System.Collections.Generic.List[object]]::new() + # Clear the 5-minute -NoDiff memo between calls. + $script:CippSamPermissionsCache = $null + $script:CippSamPermissionsCacheTime = $null + + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + if ($Filter -match 'ManifestHash') { return $script:HashRow } + return $null # no saved extra permissions + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + $script:Written.Add($Entity) + $script:HashRow = [pscustomobject]$Entity + } + } + + It 'records the hash the first time it sees a permission set' { + $null = Get-CippSamPermissions -NoDiff + + $script:Written.Count | Should -Be 1 + $script:Written[0].RowKey | Should -Be 'ManifestHash' + $script:Written[0].Hash | Should -Not -BeNullOrEmpty + } + + It 'does not move the timestamp when only the file mtime changes' { + # Exactly what a checkout or container rebuild does: same bytes, new mtime. + $first = (Get-CippSamPermissions -NoDiff).Timestamp + (Get-Item $script:ManifestPath).LastWriteTime = [datetime]::Now.AddDays(1) + $script:CippSamPermissionsCache = $null; $script:CippSamPermissionsCacheTime = $null + $second = (Get-CippSamPermissions -NoDiff).Timestamp + + $second | Should -Be $first + $script:Written.Count | Should -Be 1 # nothing re-recorded + } + + It 'moves the timestamp when the permission set actually changes' { + $first = (Get-CippSamPermissions -NoDiff).Timestamp + Start-Sleep -Milliseconds 1100 # the stamp has second resolution + Set-Manifest -Scope 'Directory.ReadWrite.All' + $script:CippSamPermissionsCache = $null; $script:CippSamPermissionsCacheTime = $null + $second = (Get-CippSamPermissions -NoDiff).Timestamp + + $second | Should -BeGreaterThan $first + $script:Written.Count | Should -Be 2 + } + + It 'falls back to the mtime if the hash cannot be persisted' { + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { throw 'table unavailable' } + $mtime = [datetime]::Now.AddDays(-3) + (Get-Item $script:ManifestPath).LastWriteTime = $mtime + + $result = Get-CippSamPermissions -NoDiff + + ([datetime]$result.Timestamp).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | + Should -Be $mtime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } +}