Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
868bebe
fix(bec): use configured PDF branding presets
MartinRain Oct 19, 2026
b25203c
fix(bec): use configured PDF branding presets
MartinRain Oct 19, 2026
acb2ff2
fix(bec): use configured PDF branding presets
MartinRain Oct 19, 2026
20ea9f0
test(bec): cover branding in evidence ZIP
MartinRain Oct 19, 2026
c2376f4
test(bec): verify branding preset reaches PDF renderer
MartinRain Oct 19, 2026
e75c88f
test(bec): verify variant-specific branding request
MartinRain Oct 19, 2026
12b4a12
test(bec): match PDF button accessible name
MartinRain Oct 19, 2026
2b68f78
test(bec): use consistent accessible PDF button selector
MartinRain Oct 19, 2026
a2777e2
fix(reports): apply configured branding preset in SharingReportButton
MartinRain Oct 19, 2026
e7a4cc6
fix(reports): apply configured branding preset in PermissionsReportBu…
MartinRain Oct 19, 2026
c802067
fix(reports): apply configured branding preset in MailFlowReportButton
MartinRain Oct 19, 2026
3352df7
fix(reports): apply configured branding preset in ShadowAIReportButton
MartinRain Oct 19, 2026
540adf3
fix(reports): apply configured branding preset in SharingReportPdf
MartinRain Oct 19, 2026
bd5d18c
fix(reports): apply configured branding preset in PermissionsReportPdf
MartinRain Oct 19, 2026
fe5e372
fix(reports): apply configured branding preset in MailFlowReportPdf
MartinRain Oct 19, 2026
76b7083
fix(reports): apply configured branding preset in ShadowAIReportPdf
MartinRain Oct 19, 2026
9f84e26
test(reports): cover per-report PDF branding defaults in UI
MartinRain Oct 19, 2026
358f288
test(reports): verify branding preset forwarding in PDF endpoints
MartinRain Oct 19, 2026
fed766b
test(bec): make log partition assertion respect rolling 60-day window
MartinRain Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,22 @@ function New-CIPPBecEvidencePackage {
Containment = $Run.Containment
}
$BecData | Add-Member -NotePropertyName 'Run' -NotePropertyValue $RunBlock -Force
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
# Evidence export has no frontend caller for preset selection.
$Settings = try { Get-CIPPBrandingSettings } catch { @{} }
$FullId = [string]$Settings.reportDefaults.bec
$SummaryId = [string]$Settings.reportDefaults.becSummary
$FullBranding = if ($FullId) { try { Get-CIPPBrandingPreset -Id $FullId | Select-Object -First 1 } catch { $null } }
$SummaryBranding = if ($SummaryId -eq $FullId) { $FullBranding } elseif ($SummaryId) { try { Get-CIPPBrandingPreset -Id $SummaryId | Select-Object -First 1 } catch { $null } }
if (-not $FullBranding) { $FullBranding = $Settings }
if (-not $SummaryBranding) { $SummaryBranding = $Settings }
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -Branding $FullBranding
$DisplayName = @($Run.DisplayName, $Run.UserPrincipalName, $Run.UserId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
$UserData = [pscustomobject]@{ displayName = $DisplayName; userPrincipalName = $Run.UserPrincipalName; id = $Run.UserId }
$Full = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName
$Files['report-full.pdf'] = ConvertTo-CippReportPdf -Blocks $Full.Blocks -Variables $Full.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
$Summary = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName -Variant summary
$Files['report-summary.pdf'] = ConvertTo-CippReportPdf -Blocks $Summary.Blocks -Variables $Summary.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
$Files['report-full.pdf'] = ConvertTo-CippReportPdf -Blocks $Full.Blocks -Variables $Full.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report' -Branding $FullBranding
$SummaryName = Get-CippReportTenantName -TenantFilter $TenantFilter -Branding $SummaryBranding
$Summary = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $SummaryName -Variant summary
$Files['report-summary.pdf'] = ConvertTo-CippReportPdf -Blocks $Summary.Blocks -Variables $Summary.Variables -TenantName $SummaryName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report' -Branding $SummaryBranding
} catch {
Write-Information "BEC evidence: server-side PDF render failed for $CaseId`: $($_.Exception.Message)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ function Invoke-ExecGetMailFlowReportPdf {
}
# Reporting window in days (1-90).
$Days = [Math]::Min([Math]::Max([int]($Request.Query.days ?? 14), 1), 90)
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
$BrandingPresetId = [string]($Request.Body.brandingPresetId ?? $Request.Query.brandingPresetId)
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId

$Window = @{
StartDate = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('s')
Expand Down Expand Up @@ -57,7 +58,7 @@ function Invoke-ExecGetMailFlowReportPdf {
topSpamRecipients = Get-TopList 'TopSpamRecipient'
}

$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Mail Flow Report'
$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Mail Flow Report' -BrandingPresetId $BrandingPresetId
$FileName = ("Mail_Flow_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function Invoke-ExecGetBecReportPdf {
'summary' { 'summary' }
default { 'full' }
}
$BrandingPresetId = [string]($Request.Query.brandingPresetId ?? $Request.Body.brandingPresetId)
if ([string]::IsNullOrWhiteSpace($TenantFilter) -or ([string]::IsNullOrWhiteSpace($CaseId) -and [string]::IsNullOrWhiteSpace($UserId))) {
return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter and either a caseId or a userId are required' })
}
Expand Down Expand Up @@ -65,7 +66,7 @@ function Invoke-ExecGetBecReportPdf {
}
$BecData | Add-Member -NotePropertyName 'Run' -NotePropertyValue $RunBlock -Force

$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId
# The investigated user labels the cover and footer; the run stored who it was for.
$DisplayName = @($Run.DisplayName, $Run.UserPrincipalName, $Request.Query.userDisplayName, $Request.Body.userDisplayName, $Run.UserId) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
Expand All @@ -75,7 +76,7 @@ function Invoke-ExecGetBecReportPdf {

$Report = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName -Variant $Variant

$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report' -BrandingPresetId $BrandingPresetId
$FileName = ("BEC_$(if ($Variant -eq 'summary') { 'Summary' } else { 'Report' })_$DisplayName" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ function Invoke-ExecGetPermissionsReportPdf {
if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
}
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
$BrandingPresetId = [string]($Request.Body.brandingPresetId ?? $Request.Query.brandingPresetId)
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId

# The same shaped data the Permissions page reads (from cached scan data).
$Raw = Get-CIPPSharePointPermissionsReport -TenantFilter $TenantFilter
Expand All @@ -31,7 +32,7 @@ function Invoke-ExecGetPermissionsReportPdf {
skippedSites = $Raw.skippedSites
}

$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Permissions Report'
$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Permissions Report' -BrandingPresetId $BrandingPresetId
$FileName = ("Permissions_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ function Invoke-ExecGetSharingReportPdf {
if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
}
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
$BrandingPresetId = [string]($Request.Body.brandingPresetId ?? $Request.Query.brandingPresetId)
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId

# The same shaped data the Sharing page reads (from the reporting cache; no live Graph enumeration).
$Raw = Get-CIPPSharePointSharingReport -TenantFilter $TenantFilter
Expand All @@ -32,7 +33,7 @@ function Invoke-ExecGetSharingReportPdf {
topLibraries = $Raw.topLibraries
}

$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Sharing Report'
$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Sharing Report' -BrandingPresetId $BrandingPresetId
$FileName = ("Sharing_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ function Invoke-ExecGetShadowAIReportPdf {
if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
}
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
$BrandingPresetId = [string]($Request.Body.brandingPresetId ?? $Request.Query.brandingPresetId)
$TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId

# Optional per-section toggles from the client's section panel (POST body). Absent -> full report.
$SectionConfig = @{}
Expand All @@ -39,7 +40,7 @@ function Invoke-ExecGetShadowAIReportPdf {
byRisk = $Raw.byRisk
}

$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Shadow AI Report'
$Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Shadow AI Report' -BrandingPresetId $BrandingPresetId
$FileName = ("Shadow_AI_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Expand Down
38 changes: 35 additions & 3 deletions backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ BeforeAll {
function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) }
# The reports are rendered server-side through the report builder + kit; stub them so the unit test
# never loads OfficeIMO - the package's job here is collation, not the kit's PDF bytes.
function Get-CippReportTenantName { param($TenantFilter) }
function Get-CippReportTenantName { param($TenantFilter, $Branding) }
function Build-CippBecReportTree { param($UserData, $BecData, $TenantName, $Variant) }
function ConvertTo-CippReportPdf { param($Blocks, $Variables, $TenantName, $TenantFilter, $ReportName, $Branding, $BrandingPresetId, $GeneratedOn, $PageSize, [switch]$Landscape) }
function Get-CIPPBrandingSettings { $script:BrandingSettings }
function Get-CIPPBrandingPreset { param($Id) $script:Presets[$Id] }
. (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1')

$script:Run = [pscustomobject]@{
Expand Down Expand Up @@ -57,7 +59,15 @@ Describe 'New-CIPPBecEvidencePackage' {
)
}
Mock Write-LogMessage { }
Mock Get-CippReportTenantName { 'Contoso' }
$script:BrandingSettings = [pscustomobject]@{ colour = '#F77F00'; reportDefaults = [pscustomobject]@{ bec = 'full'; becSummary = 'summary' } }
$script:Presets = @{
full = [pscustomobject]@{ colour = '#111111'; tenantLabel = 'alias' }
summary = [pscustomobject]@{ colour = '#222222'; tenantLabel = 'domain' }
}
Mock Get-CIPPBrandingSettings { $script:BrandingSettings }
Mock Get-CippReportTenantName {
if ($Branding.tenantLabel -eq 'domain') { 'contoso.com' } else { 'Contoso' }
}
Mock Build-CippBecReportTree { @{ Blocks = @(@{ type = 'blank' }); Variables = @{ coverlabel = 'Security Incident Report' } } }
Mock ConvertTo-CippReportPdf { $script:FakePdf }
}
Expand All @@ -83,6 +93,19 @@ Describe 'New-CIPPBecEvidencePackage' {
Should -Invoke Set-CIPPBecReport -Times 0 -Because 'an export leaves no record on the run'
}

It 'uses the distinct report presets for both evidence PDFs' {
$null = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001'
Should -Invoke Get-CIPPBrandingSettings -Times 1
Should -Invoke ConvertTo-CippReportPdf -Times 1 -ParameterFilter { $Branding.colour -eq '#111111' -and $TenantName -eq 'Contoso' }
Should -Invoke ConvertTo-CippReportPdf -Times 1 -ParameterFilter { $Branding.colour -eq '#222222' -and $TenantName -eq 'contoso.com' }
}

It 'falls back to global branding when the configured presets are unavailable' {
$script:Presets = @{}
$null = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001'
Should -Invoke ConvertTo-CippReportPdf -Times 2 -ParameterFilter { $Branding.colour -eq '#F77F00' }
}

It 'flattens arrays and nested objects into CSV cells' {
$Package = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001'
$Entries = Read-Zip -Bytes $Package.ZipBytes
Expand All @@ -106,7 +129,16 @@ Describe 'New-CIPPBecEvidencePackage' {
$Log = [System.Text.Encoding]::UTF8.GetString($Entries['logbook.json'])
$Log | Should -Not -Match 'Hunter2'
$Log | Should -Match '\[redacted\]'
Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { $Filter -like "BecCaseId eq 'BEC-20260820120000-ev0001'*" -and $Filter -like "*PartitionKey ge '20260819'*" }
# The log query starts one day before the run, but never earlier than 60 days ago.
# Derive the expected partition at runtime so this test does not expire as the fixture ages.
$ExpectedFrom = ([datetime]$script:Run.RequestedAt).ToUniversalTime().AddDays(-1)
$RetentionFloor = [datetime]::UtcNow.AddDays(-60)
if ($ExpectedFrom -lt $RetentionFloor) { $ExpectedFrom = $RetentionFloor }
$ExpectedPartition = $ExpectedFrom.ToString('yyyyMMdd')
Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter {
$Filter -like "BecCaseId eq 'BEC-20260820120000-ev0001'*" -and
$Filter -like "*PartitionKey ge '$ExpectedPartition'*"
}
}

It 'refuses an incomplete run' {
Expand Down
58 changes: 58 additions & 0 deletions backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# BEC PDF request branding follows the same preset-id contract as other server PDFs.
BeforeAll {
$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
if (-not ('HttpResponseContext' -as [type])) {
Add-Type -TypeDefinition 'public class HttpResponseContext { public object StatusCode; public object Body; public string ContentType; public object Headers; }'
}
if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) {
$null = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Add('HttpStatusCode', [System.Net.HttpStatusCode])
}
. (Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1')
function Get-CIPPBecReport {
param($TenantFilter, $CaseId, $UserId, [switch]$IncludeResults)
[pscustomobject]@{
CaseId = 'BEC-1'; Status = 'Completed'; UserId = 'u1'
DisplayName = 'User'; UserPrincipalName = 'user@contoso.com'
Results = [pscustomobject]@{ CaseId = 'BEC-1' }
}
}
function Get-CippReportTenantName { param($TenantFilter, $BrandingPresetId) 'Contoso' }
function Build-CippBecReportTree {
param($UserData, $BecData, $TenantName, $Variant)
@{ Blocks = @(@{ type = 'blank' }); Variables = @{} }
}
function ConvertTo-CippReportPdf {
param($Blocks, $Variables, $TenantName, $TenantFilter, $ReportName, $BrandingPresetId)
[byte[]]@(37, 80, 68, 70, 45)
}
function Write-LogMessage {}
function Get-CippException { param($Exception) @{ NormalizedError = [string]$Exception } }
function Invoke-BecPdfTest($Variant, $PresetId) {
Invoke-ExecGetBecReportPdf -Request @{
Query = @{ tenantFilter = 'contoso.com'; caseId = 'BEC-1'; variant = $Variant; brandingPresetId = $PresetId }
Body = @{}; Headers = @{}
} -TriggerMetadata @{ FunctionName = 'ExecGetBecReportPdf' }
}
}
Describe 'Invoke-ExecGetBecReportPdf branding preset' {
It 'passes the chosen full preset through to tenant naming and PDF rendering' {
Mock Get-CippReportTenantName { 'Contoso' }
Mock ConvertTo-CippReportPdf { [byte[]]@(37, 80, 68, 70, 45) }
$Response = Invoke-BecPdfTest full 'p-full'
$Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
Should -Invoke Get-CippReportTenantName -Times 1 -ParameterFilter { $BrandingPresetId -eq 'p-full' }
Should -Invoke ConvertTo-CippReportPdf -Times 1 -ParameterFilter { $BrandingPresetId -eq 'p-full' }
}
It 'passes the chosen summary preset through independently' {
Mock ConvertTo-CippReportPdf { [byte[]]@(37, 80, 68, 70, 45) }
$Response = Invoke-BecPdfTest summary 'p-summary'
$Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
Should -Invoke ConvertTo-CippReportPdf -Times 1 -ParameterFilter { $BrandingPresetId -eq 'p-summary' }
}
It 'uses global branding when no preset was selected' {
Mock ConvertTo-CippReportPdf { [byte[]]@(37, 80, 68, 70, 45) }
$Response = Invoke-BecPdfTest full ''
$Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
Should -Invoke ConvertTo-CippReportPdf -Times 1 -ParameterFilter { [string]::IsNullOrEmpty($BrandingPresetId) }
}
}
Loading
Loading