diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index cb12b92d8..46538bf49 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -5601,6 +5601,7 @@ "tags": [ "Tenant > Administration > Alerts" ], + "description": "Creates or updates a scripted CIPP alert, stored as a hidden scheduled task.\n\nA selection of two or more tenants or groups is stored verbatim and expanded on every run,\nso tenant group membership is always current.", "requestBody": { "required": true, "content": { @@ -5609,16 +5610,15 @@ "type": "object", "properties": { "excludedTenants": { - "type": "string" - }, - "RowKey": { - "type": "string" + "type": "string", + "description": "Tenants or tenant groups to skip even when they fall within the selection above. Optional." }, "tenantFilter": { "type": "array", "items": { "type": "string" - } + }, + "description": "The tenants, tenant groups or *All Tenants the alert applies to. At least one is required." } }, "required": [ @@ -45922,7 +45922,7 @@ "x-cipp-field-source": "storage" }, "ExecutedTime": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "storage,frontend" }, "Hidden": { "type": "boolean", @@ -54719,6 +54719,9 @@ "type": "string", "x-cipp-field-source": "storage" }, + "ExecutedTime": { + "x-cipp-field-source": "storage" + }, "Hidden": { "type": "boolean", "x-cipp-field-source": "storage" diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index cf72c1153..f216f857f 100644 --- a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -295,6 +295,13 @@ function Add-CIPPScheduledTask { } } + # Stored verbatim so the orchestrator expands groups at run time. The version marker tells + # it excludedTenants holds only the operator's picks, not a snapshot of unselected tenants. + if ($task.Tenants) { + $entity['Tenants'] = $task.Tenants -is [string] ? [string]$task.Tenants : [string]($task.Tenants | ConvertTo-Json -Compress -Depth 10) + $entity['TenantSelectionVersion'] = 2 + } + if ($task.Trigger) { $entity.Trigger = [string]($task.Trigger | ConvertTo-Json -Compress) $TriggerType = $task.Trigger.Type.value ?? $task.Trigger.Type diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 9a372b72e..069473961 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -119,17 +119,73 @@ function Start-UserTasksOrchestrator { FunctionName = 'ExecScheduledCommand' } - if ($task.Tenant -eq 'AllTenants') { - $ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ }) - if ($task.excludedTenantGroups) { - # Expand excluded tenant groups at runtime so membership changes are honored - $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($ExcludedGroups) { - $ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ }) + # Scope is resolved on every run so group membership stays current, as + # Test-CIPPAuditLogRules does for audit alerts. The stored selection is only trusted + # on a row the execution gates also read as multi-tenant, otherwise the fan-out here + # and Push-ExecScheduledCommand would disagree about the task's shape. + $UsesStoredSelection = $task.Tenants -and $task.Tenant -eq 'AllTenants' + if ($task.Tenants -and -not $UsesStoredSelection) { + Write-Information "Task $($task.Name): ignoring the stored selection, Tenant is '$($task.Tenant)' rather than AllTenants" + } + $Selection = if ($UsesStoredSelection) { + @($task.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue) + } elseif ($task.TenantGroup) { + @($task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue) + } + + $TargetTenants = $null + $ResolvedScope = $false + if ($Selection) { + try { + $Expanded = Expand-CIPPTenantGroups -TenantFilter $Selection + } catch { + # Must not fall through to the single-tenant path below: Tenant is the + # AllTenants sentinel for a multi-entry selection. Fail the task instead. + throw "Failed to expand tenant selection for task $($task.Name): $($_.Exception.Message)" + } + # Non-group entries pass through unexpanded, so the sentinel survives. + $TargetTenants = if ($Expanded.value -contains 'AllTenants') { + $TenantList + } else { + @($TenantList | Where-Object { $_.defaultDomainName -in $Expanded.value }) + } + $ResolvedScope = $true + } elseif ($task.Tenant -eq 'AllTenants') { + # An explicit *All Tenants pick, with no selection stored alongside it + $TargetTenants = $TenantList + $ResolvedScope = $true + } + + # Rows predating runtime expansion merged a snapshot of every unselected tenant into + # excludedTenants, indistinguishable from the operator's own picks, so it is ignored + # for those. A selection carrying the AllTenants sentinel never had a snapshot + # written, so its exclusions are the operator's and are kept. excludedTenantGroups + # was never part of the snapshot either and always applies. + $IsLegacySnapshot = $UsesStoredSelection -and -not $task.TenantSelectionVersion -and ($Selection.value -notcontains 'AllTenants') + $ExcludedTenants = [System.Collections.Generic.List[string]]::new() + if ($task.excludedTenants) { + $StoredExclusions = @($task.excludedTenants -split ',' | Where-Object { $_ }) + if ($IsLegacySnapshot) { + # Only report a snapshot that would actually have dropped a tenant in scope + # now, or every run of every legacy row logs the same no-op indefinitely. + $Reinstated = @($StoredExclusions | Where-Object { $_ -in $TargetTenants.defaultDomainName }) + if ($Reinstated.Count -gt 0) { + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): ignored $($Reinstated.Count) stale snapshot exclusions, tenant group membership is now resolved at runtime" -Sev 'Info' } + } else { + $ExcludedTenants.AddRange([string[]]$StoredExclusions) + } + } + if ($task.excludedTenantGroups) { + $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($ExcludedGroups) { + $ExcludedTenants.AddRange([string[]]@((Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ })) } - Write-Host "Excluded Tenants from this task: $ExcludedTenants" - $AllTenantCommands = foreach ($Tenant in $TenantList | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) { + } + + if ($ResolvedScope) { + Write-Information "Task $($task.Name): $(@($TargetTenants).Count) tenants in scope, $($ExcludedTenants.Count) excluded" + $FanOutCommands = foreach ($Tenant in $TargetTenants | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) { $NewParams = $task.Parameters.Clone() if ($HasTenantFilter) { # TenantFilter always carries the execution tenant context; it is stripped @@ -146,62 +202,27 @@ function Start-UserTasksOrchestrator { FunctionName = 'ExecScheduledCommand' } } - $Batch.AddRange(@($AllTenantCommands)) - } elseif ($task.TenantGroup) { - # Handle tenant groups - expand group to individual tenants - try { - $TenantGroupObject = $task.TenantGroup | ConvertFrom-Json - Write-Host "Expanding tenant group: $($TenantGroupObject.label) with ID: $($TenantGroupObject.value)" - - # Create a tenant filter object for expansion - $TenantFilterForExpansion = @([PSCustomObject]@{ - type = 'Group' - value = $TenantGroupObject.value - label = $TenantGroupObject.label - }) - - # Expand the tenant group to individual tenants - $ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $TenantFilterForExpansion - - $ExcludedTenants = @($task.excludedTenants -split ',' | Where-Object { $_ }) - if ($task.excludedTenantGroups) { - # Expand excluded tenant groups at runtime so membership changes are honored - $ExcludedGroups = $task.excludedTenantGroups | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($ExcludedGroups) { - $ExcludedTenants = @($ExcludedTenants + (Expand-CIPPTenantGroups -TenantFilter $ExcludedGroups).value | Where-Object { $_ }) - } - } - Write-Host "Excluded Tenants from this task: $ExcludedTenants" - - $GroupTenantCommands = foreach ($ExpandedTenant in $ExpandedTenants | Where-Object { $_.value -notin $ExcludedTenants }) { - $NewParams = $task.Parameters.Clone() - if ($HasTenantFilter) { - $NewParams.TenantFilter = $ExpandedTenant.value - $NewParams.$PrimaryTenantParam = $ExpandedTenant.value - } - # Clone TaskInfo to prevent shared object references - $TaskInfoClone = $task.PSObject.Copy() - [pscustomobject]@{ - Command = $task.Command - Parameters = $NewParams - TaskInfo = $TaskInfoClone - FunctionName = 'ExecScheduledCommand' - } - } - $Batch.AddRange(@($GroupTenantCommands)) - } catch { - Write-Host "Error expanding tenant group: $($_.Exception.Message)" - Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to expand tenant group for task $($task.Name): $($_.Exception.Message)" -sev Error - - # Fall back to treating as single tenant - if ($HasTenantFilter) { - $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant - $ScheduledCommand.Parameters[$PrimaryTenantParam] = $task.Tenant + if (@($FanOutCommands).Count -gt 0) { + $Batch.AddRange(@($FanOutCommands)) + } else { + # Every selected group resolved empty, or was deleted. Close the run out here: + # the row is already Pending, and with no batch item no orchestrator or post + # execution runs, so it would be reclaimed as stale every hour and a recurring + # task would never advance its schedule. + $NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime + $EmptyScopeEntity = @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + Results = 'No tenants in scope for this task.' + ExecutedTime = "$currentUnixTime" + TaskState = $NextRun -gt 0 ? 'Planned' : 'Completed' } - $Batch.Add($ScheduledCommand) + if ($NextRun -gt 0) { $EmptyScopeEntity.ScheduledTime = "$NextRun" } + $null = Update-AzDataTableEntity -Force @Table -Entity $EmptyScopeEntity + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Task $($task.Name): no tenants in scope, nothing to run" -Sev 'Info' } } else { - # Handle single tenant + # Single tenant if ($HasTenantFilter) { $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant $ScheduledCommand.Parameters[$PrimaryTenantParam] = $task.Tenant @@ -211,13 +232,19 @@ function Start-UserTasksOrchestrator { } catch { $errorMessage = $_.Exception.Message - $null = Update-AzDataTableEntity -Force @Table -Entity @{ + # Failed is terminal - the pickup filter only reads Planned and Failed - Planned - so + # a recurring task parked there never runs again. A transient failure here (a tenant + # or group table read, say) must not permanently stop it. + $NextRun = Get-CIPPScheduledTaskNextRun -Recurrence $task.Recurrence -ScheduledTime $task.ScheduledTime + $FailureEntity = @{ PartitionKey = $task.PartitionKey RowKey = $task.RowKey Results = "$errorMessage" ExecutedTime = "$currentUnixTime" - TaskState = 'Failed' + TaskState = $NextRun -gt 0 ? 'Failed - Planned' : 'Failed' } + if ($NextRun -gt 0) { $FailureEntity.ScheduledTime = "$NextRun" } + $null = Update-AzDataTableEntity -Force @Table -Entity $FailureEntity Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to execute task $($task.Name): $errorMessage" -sev Error } } diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 new file mode 100644 index 000000000..48fe2e0c4 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1 @@ -0,0 +1,32 @@ +function Get-CIPPScheduledTaskNextRun { + <# + .SYNOPSIS + Next run time for a scheduled task, in unix seconds, or 0 when it does not repeat. + .DESCRIPTION + Recurrence is stored as 30m, 1h, 1d and so on; a bare number is a day count, the shape older + tasks carry. A run further back than one interval is treated as starting now, so a task that + was disabled or stuck does not replay a backlog of missed runs. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][AllowNull()]$Recurrence, + [Parameter(Mandatory = $true)][AllowNull()]$ScheduledTime + ) + + $Value = [string]$Recurrence + if ($Value -match '^\d+$') { $Value = '{0}d' -f $Value } + + $SecondsToAdd = switch -Regex ($Value) { + '(\d+)m$' { [int64]$Matches[1] * 60 } + '(\d+)h$' { [int64]$Matches[1] * 3600 } + '(\d+)d$' { [int64]$Matches[1] * 86400 } + default { 0 } + } + if ($SecondsToAdd -le 0) { return 0 } + + $Now = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + $Last = [int64]($ScheduledTime ?? 0) + if ($Last -lt ($Now - $SecondsToAdd)) { $Last = $Now } + + return $Last + $SecondsToAdd +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 index 54ee33035..e2a610d0d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 @@ -27,7 +27,8 @@ function Invoke-ListScheduledItemDetails { # Retrieve the task information $TaskTable = Get-CIPPTable -TableName 'ScheduledTasks' - $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, PostExecutionResults, Tenant, TenantGroup, Hidden, Results, Timestamp, Trigger + $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "RowKey eq '$SafeRowKey' and PartitionKey eq 'ScheduledTask'" | Select-Object RowKey, Name, TaskState, Command, Parameters, Recurrence, ExecutedTime, ScheduledTime, PostExecution, PostExecutionResults, Tenant, TenantGroup, Tenants, TenantSelectionVersion, excludedTenants, excludedTenantGroups, Hidden, Results, Timestamp, Trigger + if (-not $Task) { return ([HttpResponseContext]@{ @@ -72,7 +73,21 @@ function Invoke-ListScheduledItemDetails { } catch {} # Handle tenant group display information (similar to Invoke-ListScheduledItems) - if ($Task.TenantGroup) { + if ($Task.Tenants) { + # Tenant stays 'AllTenants' for the execution gates, so report the real scope from Tenants. + try { + $TenantsParsed = $Task.Tenants | ConvertFrom-Json -Depth 10 -ErrorAction Stop + $Task.Tenant = @($TenantsParsed | ForEach-Object { + [PSCustomObject]@{ + label = $_.label ?? $_.value + value = $_.value + type = $_.type ?? 'Tenant' + } + }) + } catch { + Write-Warning "Failed to parse tenant selection for task $($Task.RowKey): $($_.Exception.Message)" + } + } elseif ($Task.TenantGroup) { try { $TenantGroupObject = $Task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue if ($TenantGroupObject) { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 index 7efff1a44..d04f7298e 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddScriptedAlert.ps1 @@ -4,60 +4,38 @@ function Invoke-AddScriptedAlert { Entrypoint .ROLE CIPP.Alert.ReadWrite + .DESCRIPTION + Creates or updates a scripted CIPP alert, stored as a hidden scheduled task. + + A selection of two or more tenants or groups is stored verbatim and expanded on every run, + so tenant group membership is always current. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $tenantsJsonForStorage = $null - + # The tenants, tenant groups or *All Tenants the alert applies to. At least one is required. if ($Request.Body.tenantFilter -is [array] -and @($Request.Body.tenantFilter).Count -eq 1) { $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value $Request.Body.tenantFilter[0] -Force } if ($Request.Body.tenantFilter -is [array] -and @($Request.Body.tenantFilter).Count -gt 1) { - try { - $originalSelection = @($Request.Body.tenantFilter) - $tenantsJsonForStorage = $originalSelection | ConvertTo-Json -Compress -Depth 10 - - $hasAllTenants = @($originalSelection | Where-Object { $_.value -eq 'AllTenants' }).Count -gt 0 - - if (-not $hasAllTenants) { - $ExpandedSelection = Expand-CIPPTenantGroups -TenantFilter $originalSelection - $targetDomains = @($ExpandedSelection | ForEach-Object { $_.value }) - - $AllTenantsList = Get-Tenants -IncludeErrors - $computedExcluded = @($AllTenantsList.defaultDomainName | Where-Object { $_ -notin $targetDomains }) - - $existingEntries = @() - if ($Request.Body.PSObject.Properties['excludedTenants'] -and $Request.Body.excludedTenants) { - $existingEntries = @($Request.Body.excludedTenants) - } - # Keep user-picked groups as typed objects so Add-CIPPScheduledTask stores them - # for runtime expansion instead of flattening them into the domain list - $excludedGroupEntries = @($existingEntries | Where-Object { $_.type -eq 'Group' }) - $existingExcluded = @($existingEntries | Where-Object { $_.type -ne 'Group' } | ForEach-Object { $_.value ?? $_ }) - $mergedExcluded = @($existingExcluded + $computedExcluded) | Where-Object { $_ } | Select-Object -Unique + $Request.Body | Add-Member -MemberType NoteProperty -Name 'Tenants' -Value @($Request.Body.tenantFilter) -Force - $excludedValue = @($mergedExcluded | ForEach-Object { - [PSCustomObject]@{ value = $_; label = $_ } - }) + $excludedGroupEntries - $Request.Body | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value $excludedValue -Force - } - - if (-not $Request.Body.PSObject.Properties['RowKey'] -or -not $Request.Body.RowKey) { - $Request.Body | Add-Member -MemberType NoteProperty -Name 'RowKey' -Value ((New-Guid).Guid) -Force - } - - $tenantFilterValue = [PSCustomObject]@{ + # Tenant stays 'AllTenants' - the execution gates gate on that literal; Tenants holds the real scope. + $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value ([PSCustomObject]@{ value = 'AllTenants' label = '*All Tenants' type = 'Tenant' - } - $Request.Body | Add-Member -MemberType NoteProperty -Name 'tenantFilter' -Value $tenantFilterValue -Force - } catch { - Write-Warning "Failed to process multi-tenant alert selection: $($_.Exception.Message)" - $tenantsJsonForStorage = $null - } + }) -Force + } + + # Tenants or tenant groups to skip even when they fall within the selection above. Optional. + if ($Request.Body.excludedTenants) { + # Add-CIPPScheduledTask drops entries with no value, so wrap bare domain strings. + $NormalizedExclusions = @(@($Request.Body.excludedTenants) | Where-Object { $_ } | ForEach-Object { + if ($_.value) { $_ } else { [PSCustomObject]@{ value = [string]$_; label = [string]$_; type = 'Tenant' } } + }) + $Request.Body | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value $NormalizedExclusions -Force } $ForwardRequest = @{ @@ -65,20 +43,6 @@ function Invoke-AddScriptedAlert { Body = $Request.Body Headers = $Request.Headers } - $Response = Invoke-AddScheduledItem -Request $ForwardRequest -TriggerMetadata $TriggerMetadata - - if ($tenantsJsonForStorage) { - try { - $Table = Get-CIPPTable -TableName 'ScheduledTasks' - $null = Update-AzDataTableEntity -Force @Table -Entity @{ - PartitionKey = 'ScheduledTask' - RowKey = [string]$Request.Body.RowKey - Tenants = [string]$tenantsJsonForStorage - } - } catch { - Write-Warning "Failed to persist multi-tenant selection for alert: $($_.Exception.Message)" - } - } - return $Response + return Invoke-AddScheduledItem -Request $ForwardRequest -TriggerMetadata $TriggerMetadata } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index 2b6691593..c00ca3409 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -119,7 +119,11 @@ function Invoke-ListAlertsQueue { type = $_.type ?? 'Tenant' } }) - $ExcludedTenants = @() + # A legacy row's excludedTenants is a snapshot of every unselected tenant, ignored at + # run time and hidden here. A versioned row's is the operator's own picks. + if (-not $Task.TenantSelectionVersion) { + $ExcludedTenants = @() + } } catch { Write-Warning "Failed to parse Tenants for alert task $($Task.RowKey): $($_.Exception.Message)" $TenantsForDisplay = @([PSCustomObject]@{ diff --git a/backend/Tests/Alerts/Invoke-AddScriptedAlert.Tests.ps1 b/backend/Tests/Alerts/Invoke-AddScriptedAlert.Tests.ps1 new file mode 100644 index 000000000..434f34cf9 --- /dev/null +++ b/backend/Tests/Alerts/Invoke-AddScriptedAlert.Tests.ps1 @@ -0,0 +1,125 @@ +# Regression tests for the save side of scripted-alert tenant scope. +# +# This endpoint used to expand groups and store the complement of the selection in excludedTenants, +# freezing membership at save time. It now stores the selection verbatim for +# Start-UserTasksOrchestrator to expand, so nothing here may touch tenant or group state. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-AddScriptedAlert.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddScriptedAlert.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Invoke-AddScheduledItem { param($Request, $TriggerMetadata) } + function Expand-CIPPTenantGroups { param($TenantFilter) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPTable { param($TableName) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + + . $FunctionPath + + function New-AlertRequest { + param($TenantFilter, $ExcludedTenants) + $Body = [pscustomobject]@{ + Name = 'Scripted alert fixture' + Command = [pscustomobject]@{ value = 'Get-CIPPAlertFixture' } + tenantFilter = $TenantFilter + } + if ($PSBoundParameters.ContainsKey('ExcludedTenants')) { + $Body | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value $ExcludedTenants + } + [pscustomobject]@{ Body = $Body; Headers = @{} } + } + + $script:TwoGroups = @( + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' } + [pscustomobject]@{ value = 'group-2'; label = 'Group 2'; type = 'Group' } + ) +} + +Describe 'Invoke-AddScriptedAlert tenant selection' { + BeforeEach { + $script:Forwarded = $null + Mock -CommandName Invoke-AddScheduledItem -MockWith { $script:Forwarded = $Request; 'Task added' } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'groups must not be expanded at save time' } + Mock -CommandName Get-Tenants -MockWith { throw 'the tenant list must not be read at save time' } + } + + It 'forwards the multi-group selection verbatim' { + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups) + + $Stored = @($script:Forwarded.Body.Tenants) + $Stored | Should -HaveCount 2 + $Stored[0].value | Should -Be 'group-1' + $Stored[0].type | Should -Be 'Group' + $Stored[1].value | Should -Be 'group-2' + } + + It 'keeps Tenant as the AllTenants literal the execution gates rely on' { + # Push-ExecScheduledCommand and Start-UserTasksOrchestrator both decide multi-tenant + # behaviour by comparing this column to 'AllTenants'. + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups) + + $script:Forwarded.Body.tenantFilter.value | Should -Be 'AllTenants' + } + + It 'never expands groups or reads the tenant list' { + # Both mocks throw; reaching either would fail the call and leave nothing forwarded. + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups) + + $script:Forwarded | Should -Not -BeNullOrEmpty + Should -Invoke -CommandName Expand-CIPPTenantGroups -Times 0 + Should -Invoke -CommandName Get-Tenants -Times 0 + } + + It 'writes no complement into excludedTenants' { + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups) + + # The property is only ever set from what the operator picked; with none picked it stays unset. + @($script:Forwarded.Body.excludedTenants) | Where-Object { $_ } | Should -HaveCount 0 + } + + It 'passes the operator exclusions through unchanged' { + $Excluded = @( + [pscustomobject]@{ value = 'b.onmicrosoft.com'; label = 'B'; type = 'Tenant' } + [pscustomobject]@{ value = 'group-excluded'; label = 'Excluded'; type = 'Group' } + ) + + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups -ExcludedTenants $Excluded) + + $Stored = @($script:Forwarded.Body.excludedTenants) + $Stored | Should -HaveCount 2 + $Stored.value | Should -Contain 'b.onmicrosoft.com' + ($Stored | Where-Object { $_.type -eq 'Group' }).value | Should -Be 'group-excluded' + } + + It 'normalizes bare domain strings an API caller may post as exclusions' { + # Add-CIPPScheduledTask drops entries without a .value, so plain strings must be wrapped. + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups -ExcludedTenants @('b.onmicrosoft.com')) + + $Stored = @($script:Forwarded.Body.excludedTenants) + $Stored | Should -HaveCount 1 + $Stored[0].value | Should -Be 'b.onmicrosoft.com' + $Stored[0].type | Should -Be 'Tenant' + } + + It 'stores no selection for a single-entry pick, leaving the existing single-tenant path' { + $Single = @([pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' }) + + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $Single) + + $script:Forwarded.Body.Tenants | Should -BeNullOrEmpty + $script:Forwarded.Body.tenantFilter.value | Should -Be 'group-1' + } + + It 'does not write to the table itself' { + # The selection rides along in the single Add-CIPPScheduledTask write. A second, separate + # write could fail after the task was created, leaving an alert scoped to every tenant. + Invoke-AddScriptedAlert -Request (New-AlertRequest -TenantFilter $script:TwoGroups) + + Should -Invoke -CommandName Update-AzDataTableEntity -Times 0 + } +} diff --git a/backend/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 b/backend/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 new file mode 100644 index 000000000..8a767e9ce --- /dev/null +++ b/backend/Tests/Scheduler/Add-CIPPScheduledTask.TenantSelection.Tests.ps1 @@ -0,0 +1,95 @@ +# Pins the storage contract for a multi-entry tenant selection on a scheduled task. +# +# The selection is stored verbatim for Start-UserTasksOrchestrator to expand, and +# TenantSelectionVersion marks excludedTenants as the operator's own picks rather than the snapshot +# older rows carry. It must land in the single task write - the selection used to be added by a +# second table call afterwards, so a failure there left an alert scoped to every tenant. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1' + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CippQueueMessage { param($Cmdlet, $Parameters) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Get-NormalizedError { param($Message) $Message } + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $tenantid, $LogData) } + function New-CIPPTaskDeltaQuery { param($Trigger, $TenantFilter, $PartitionKey) } + + . $FunctionPath + + function New-FakeCommand { + param([string]$Module = 'CIPPCore', [string[]]$ParamNames) + $params = @{} + foreach ($p in $ParamNames) { $params[$p] = [pscustomobject]@{ Name = $p } } + [pscustomobject]@{ Module = $Module; Parameters = $params } + } + + function New-SelectionTask { + param($Tenants) + $Task = [pscustomobject]@{ + Name = 'Scripted alert fixture' + Command = 'Get-CIPPAlertFixture' + TenantFilter = [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + Parameters = [pscustomobject]@{ Threshold = 5 } + } + if ($Tenants) { $Task | Add-Member -MemberType NoteProperty -Name 'Tenants' -Value $Tenants } + $Task + } +} + +Describe 'Add-CIPPScheduledTask tenant selection storage' { + BeforeEach { + $script:Persisted = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:Persisted.Add($Entity) } + Mock -CommandName Update-AzDataTableEntity -MockWith { } + Mock -CommandName Add-CippQueueMessage -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Command -MockWith { + New-FakeCommand -ParamNames @('TenantFilter', 'Threshold') + } + } + + It 'persists the selection and the version marker in the same entity as the task' { + $Selection = @( + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' } + [pscustomobject]@{ value = 'group-2'; label = 'Group 2'; type = 'Group' } + ) + + Add-CIPPScheduledTask -Task (New-SelectionTask -Tenants $Selection) + + $script:Persisted | Should -HaveCount 1 + $Entity = $script:Persisted[0] + $Entity.TenantSelectionVersion | Should -Be 2 + $Entity.Tenant | Should -Be 'AllTenants' + + $Stored = @($Entity.Tenants | ConvertFrom-Json) + $Stored | Should -HaveCount 2 + $Stored.value | Should -Contain 'group-1' + $Stored.value | Should -Contain 'group-2' + } + + It 'leaves an already-serialized selection alone so a restored backup is not double-encoded' { + $Json = '[{"value":"group-1","label":"Group 1","type":"Group"}]' + + Add-CIPPScheduledTask -Task (New-SelectionTask -Tenants $Json) + + $script:Persisted[0].Tenants | Should -Be $Json + } + + It 'writes no selection or marker for a single-tenant task' { + # The entity write is a replace, so omitting both clears them when an alert is edited down + # to one tenant. + Add-CIPPScheduledTask -Task (New-SelectionTask) + + $Entity = $script:Persisted[0] + $Entity.ContainsKey('Tenants') | Should -BeFalse + $Entity.ContainsKey('TenantSelectionVersion') | Should -BeFalse + } +} diff --git a/backend/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 b/backend/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 new file mode 100644 index 000000000..4e68e730c --- /dev/null +++ b/backend/Tests/Scheduler/Get-CIPPScheduledTaskNextRun.Tests.ps1 @@ -0,0 +1,41 @@ +# Recurrence parsing for scheduled tasks. The orchestrator uses this to close out a run that had no +# tenants in scope; 0 means the task does not repeat and should be completed instead of rescheduled. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPScheduledTaskNextRun.ps1') + + function Get-UnixNow { [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds } +} + +Describe 'Get-CIPPScheduledTaskNextRun' { + It 'adds the interval to the last scheduled time' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '1d' -ScheduledTime $Last | Should -Be ($Last + 86400) + } + + It 'parses minutes and hours' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '30m' -ScheduledTime $Last | Should -Be ($Last + 1800) + Get-CIPPScheduledTaskNextRun -Recurrence '4h' -ScheduledTime $Last | Should -Be ($Last + 14400) + } + + It 'treats a bare number as days, the shape older tasks carry' { + $Last = (Get-UnixNow) - 60 + Get-CIPPScheduledTaskNextRun -Recurrence '7' -ScheduledTime $Last | Should -Be ($Last + 604800) + } + + It 'returns 0 for a task that does not repeat' { + Get-CIPPScheduledTaskNextRun -Recurrence '0' -ScheduledTime 1 | Should -Be 0 + Get-CIPPScheduledTaskNextRun -Recurrence $null -ScheduledTime 1 | Should -Be 0 + Get-CIPPScheduledTaskNextRun -Recurrence 'never' -ScheduledTime 1 | Should -Be 0 + } + + It 'does not replay a backlog when the last run is far in the past' { + # A task stuck or disabled for a year must schedule one run from now, not catch up. + $Now = Get-UnixNow + $Next = Get-CIPPScheduledTaskNextRun -Recurrence '1d' -ScheduledTime 1 + $Next | Should -BeGreaterOrEqual ($Now + 86400) + $Next | Should -BeLessOrEqual ($Now + 86400 + 5) + } +} diff --git a/backend/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 b/backend/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 new file mode 100644 index 000000000..5766fa847 --- /dev/null +++ b/backend/Tests/Scheduler/Start-UserTasksOrchestrator.TenantGroups.Tests.ps1 @@ -0,0 +1,353 @@ +# Regression tests for scripted-alert tenant scope. +# +# Groups used to be expanded at save time, with the complement of the selection frozen into +# excludedTenants, so a tenant joining a targeted group afterwards never received the alert. Scope is +# now resolved here on every run from the verbatim Tenants selection. Rows written by the old code +# lack TenantSelectionVersion; their excludedTenants is that snapshot and is ignored, while +# excludedTenantGroups was never part of it and always applies. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Start-UserTasksOrchestrator.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Start-UserTasksOrchestrator.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPSchedulerBlockedCommands { @() } + function Expand-CIPPTenantGroups { param($TenantFilter) } + function New-CippQueueEntry { param($Name, $Reference, $TotalTasks) } + function Start-CIPPOrchestrator { param($InputObject) } + function Get-CIPPScheduledTaskNextRun { param($Recurrence, $ScheduledTime) } + # Parameter names bind case-insensitively, so one $Sev covers the SUT's -Sev and -sev calls. + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $tenantid, $LogData) } + + # A real function, so the SUT's Get-Command lookup resolves without mocking Pester's own + # Get-Command. It declares TenantFilter, which is what drives the per-tenant parameter stamping. + function Get-CIPPAlertFixture { param($TenantFilter, $Threshold) } + + . $FunctionPath + + # Four managed tenants; group-1 holds a + b, group-2 holds c, group-excluded holds c. + function New-TenantList { + @( + [pscustomobject]@{ defaultDomainName = 'a.onmicrosoft.com'; customerId = 'cust-a'; displayName = 'A' } + [pscustomobject]@{ defaultDomainName = 'b.onmicrosoft.com'; customerId = 'cust-b'; displayName = 'B' } + [pscustomobject]@{ defaultDomainName = 'c.onmicrosoft.com'; customerId = 'cust-c'; displayName = 'C' } + [pscustomobject]@{ defaultDomainName = 'd.onmicrosoft.com'; customerId = 'cust-d'; displayName = 'D' } + ) + } + + function New-TaskRow { + param([hashtable]$Overrides = @{}) + $Row = @{ + PartitionKey = 'ScheduledTask' + RowKey = 'task-1' + Name = 'Scripted alert fixture' + Command = 'Get-CIPPAlertFixture' + Parameters = '{}' + ScheduledTime = 1 + TaskState = 'Planned' + Recurrence = '0' + Tenant = 'AllTenants' + ETag = 'etag-1' + } + foreach ($Key in $Overrides.Keys) { $Row[$Key] = $Overrides[$Key] } + [pscustomobject]$Row + } + + # The tenant each fanned-out command was stamped with, in batch order. + function Get-ScopedTenants { + @($script:StartedBatches | ForEach-Object { $_.Parameters.TenantFilter }) + } + + # The two groups a multi-select alert stores verbatim. + $script:TwoGroupSelection = ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' } + [pscustomobject]@{ value = 'group-2'; label = 'Group 2'; type = 'Group' } + ) +} + +Describe 'Start-UserTasksOrchestrator tenant scope resolution' { + BeforeEach { + $script:StartedBatches = [System.Collections.Generic.List[object]]::new() + $script:LoggedMessages = [System.Collections.Generic.List[string]]::new() + $script:TaskUpdates = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Update-AzDataTableEntity -MockWith { $script:TaskUpdates.Add($Entity) } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 0 } + Mock -CommandName Get-CIPPSchedulerBlockedCommands -MockWith { @() } + Mock -CommandName New-CippQueueEntry -MockWith { [pscustomobject]@{ RowKey = 'queue-1' } } + Mock -CommandName Get-Tenants -MockWith { New-TenantList } + Mock -CommandName Write-LogMessage -MockWith { + $script:LoggedMessages.Add([string]$message) + } + Mock -CommandName Start-CIPPOrchestrator -MockWith { + foreach ($Item in @($InputObject.Batch)) { $script:StartedBatches.Add($Item) } + } + # Mirrors the real helper: group entries expand to their members, everything else - the + # AllTenants sentinel included - passes through untouched. + Mock -CommandName Expand-CIPPTenantGroups -MockWith { + foreach ($Entry in @($TenantFilter)) { + switch ($Entry.value) { + 'group-1' { + [pscustomobject]@{ value = 'a.onmicrosoft.com'; type = 'Tenant' } + [pscustomobject]@{ value = 'b.onmicrosoft.com'; type = 'Tenant' } + } + 'group-2' { [pscustomobject]@{ value = 'c.onmicrosoft.com'; type = 'Tenant' } } + 'group-excluded' { [pscustomobject]@{ value = 'c.onmicrosoft.com'; type = 'Tenant' } } + 'group-empty' { } + default { $Entry } + } + } + } + } + + It 'includes a group member that a legacy snapshot still lists as excluded' { + # The reported bug: b joined group-1 after the alert was saved, so it sits in the frozen + # complement. Without TenantSelectionVersion that column is a snapshot and must not apply. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com,d.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -Contain 'b.onmicrosoft.com' + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'c.onmicrosoft.com' + # d is in neither group, so it is out of scope on the selection alone. + $Scoped | Should -Not -Contain 'd.onmicrosoft.com' + } + + It 'logs only the snapshot exclusions that were actually in scope' { + # b is in group-1 and was being wrongly excluded; d is in neither group, so dropping it from + # the snapshot changes nothing and is not worth reporting. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com,d.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + ($script:LoggedMessages -join "`n") | Should -Match 'ignored 1 stale snapshot exclusions' + } + + It 'stays quiet when a legacy snapshot would not have changed the outcome' { + # Otherwise every legacy row logs the same no-op on every run, forever. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'd.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + ($script:LoggedMessages -join "`n") | Should -Not -Match 'stale snapshot exclusions' + } + + It 'applies excludedTenants on a versioned row' { + # Written by the current code, so the column holds only the operator's own picks. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + TenantSelectionVersion = 2 + excludedTenants = 'b.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -Not -Contain 'b.onmicrosoft.com' + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'c.onmicrosoft.com' + } + + It 'expands excludedTenantGroups on a legacy row, since it was never part of the snapshot' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = $script:TwoGroupSelection + excludedTenants = 'b.onmicrosoft.com' + excludedTenantGroups = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-excluded'; label = 'Excluded'; type = 'Group' })) + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + # c is excluded via the group; b is not, because the snapshot column is ignored. + $Scoped | Should -Not -Contain 'c.onmicrosoft.com' + $Scoped | Should -Contain 'b.onmicrosoft.com' + } + + It 'fans out to every tenant when the selection carries the AllTenants sentinel' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' })) + TenantSelectionVersion = 2 + } + } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 4 + } + + It 'still resolves a single stored group when no Tenants column is present' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenant = 'group-1' + TenantGroup = '{"value":"group-1","label":"Group 1","type":"Group"}' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 2 + $Scoped | Should -Contain 'a.onmicrosoft.com' + $Scoped | Should -Contain 'b.onmicrosoft.com' + } + + It 'keeps operator exclusions on a legacy selection that includes AllTenants' { + # The old save path skipped the complement when the selection carried the sentinel, so these + # exclusions are the operator's own and must survive despite the missing version marker. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'AllTenants'; label = '*All Tenants'; type = 'Tenant' } + [pscustomobject]@{ value = 'group-1'; label = 'Group 1'; type = 'Group' })) + excludedTenants = 'b.onmicrosoft.com' + } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 3 + $Scoped | Should -Not -Contain 'b.onmicrosoft.com' + } + + It 'fails the task rather than queuing an AllTenants run when expansion throws' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenants = $script:TwoGroupSelection; TenantSelectionVersion = 2 } + } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'tenant group store unavailable' } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 0 + $Failed = @($script:TaskUpdates | Where-Object { $_.TaskState -eq 'Failed' }) + $Failed | Should -HaveCount 1 + $Failed[0].Results | Should -Match 'Failed to expand tenant selection' + } + + It 'keeps a recurring task alive when expansion throws' { + # Failed is terminal, so parking a recurring task there on a transient table read would stop + # it permanently. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenants = $script:TwoGroupSelection; TenantSelectionVersion = 2; Recurrence = '1d' } + } + Mock -CommandName Expand-CIPPTenantGroups -MockWith { throw 'tenant group store unavailable' } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 1700000000 } + + Start-UserTasksOrchestrator + + $Failed = @($script:TaskUpdates | Where-Object { $_.TaskState -like 'Failed*' }) + $Failed | Should -HaveCount 1 + $Failed[0].TaskState | Should -Be 'Failed - Planned' + $Failed[0].ScheduledTime | Should -Be '1700000000' + } + + It 'ignores a stored selection on a row the execution gates read as single-tenant' { + # Tenant is not the AllTenants literal, so Push-ExecScheduledCommand would treat any fan-out + # here as a single-tenant run: no per-tenant results, and concurrent parent-row writes. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenant = 'a.onmicrosoft.com'; Tenants = $script:TwoGroupSelection } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 1 + $Scoped | Should -Contain 'a.onmicrosoft.com' + } + + It 'reschedules a recurring task whose groups all resolved empty' { + # Otherwise the row stays Pending, is reclaimed as stale every hour, and never advances. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-empty'; label = 'Empty'; type = 'Group' })) + TenantSelectionVersion = 2 + Recurrence = '1d' + } + } + Mock -CommandName Get-CIPPScheduledTaskNextRun -MockWith { 1700000000 } + + Start-UserTasksOrchestrator + + Get-ScopedTenants | Should -HaveCount 0 + $Closed = @($script:TaskUpdates | Where-Object { $_.Results -eq 'No tenants in scope for this task.' }) + $Closed | Should -HaveCount 1 + $Closed[0].TaskState | Should -Be 'Planned' + $Closed[0].ScheduledTime | Should -Be '1700000000' + } + + It 'completes a one-off task whose groups all resolved empty' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ + Tenants = (ConvertTo-Json -Compress -Depth 5 -InputObject @( + [pscustomobject]@{ value = 'group-empty'; label = 'Empty'; type = 'Group' })) + TenantSelectionVersion = 2 + } + } + + Start-UserTasksOrchestrator + + $Closed = @($script:TaskUpdates | Where-Object { $_.Results -eq 'No tenants in scope for this task.' }) + $Closed | Should -HaveCount 1 + $Closed[0].TaskState | Should -Be 'Completed' + $Closed[0].ContainsKey('ScheduledTime') | Should -BeFalse + } + + It 'leaves a plain single-tenant task alone' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ Tenant = 'a.onmicrosoft.com' } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 1 + $Scoped | Should -Contain 'a.onmicrosoft.com' + Should -Invoke -CommandName Expand-CIPPTenantGroups -Times 0 + } + + It 'fans out to an AllTenants task that stored no selection' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + New-TaskRow @{ excludedTenants = 'd.onmicrosoft.com' } + } + + Start-UserTasksOrchestrator + + $Scoped = Get-ScopedTenants + $Scoped | Should -HaveCount 3 + # No Tenants column means no snapshot, so the exclusion is the operator's and still applies. + $Scoped | Should -Not -Contain 'd.onmicrosoft.com' + } +} diff --git a/docs/user-documentation/tenant/administration/alert-configuration/alert.md b/docs/user-documentation/tenant/administration/alert-configuration/alert.md index 3335492be..7ca89deb1 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/alert.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/alert.md @@ -31,6 +31,10 @@ Both alert types share the same tenant scoping card. | Included Tenants for alert | The tenants, tenant groups or \*All Tenants the alert applies to. At least one entry is required. | | Excluded Tenants for alert | Optional. Tenants selected here are skipped even if they fall within the included tenants or group. | +{% hint style="info" %} +Tenant group membership is resolved each time the alert runs, for both alert types. A tenant added to or removed from a targeted group is picked up automatically, with no need to edit and re-save the alert. +{% endhint %} + ## Alert Criteria The criteria card changes depending on which alert type you selected.