From 868bebedb0112eef38eee056b76e77473a3d8b9b Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:59:07 -0400
Subject: [PATCH 01/19] fix(bec): use configured PDF branding presets
---
frontend/src/components/BECRemediationReportButton.jsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/frontend/src/components/BECRemediationReportButton.jsx b/frontend/src/components/BECRemediationReportButton.jsx
index b9f1be3243..d62250cbc8 100644
--- a/frontend/src/components/BECRemediationReportButton.jsx
+++ b/frontend/src/components/BECRemediationReportButton.jsx
@@ -15,6 +15,7 @@ import {
IconButton,
} from '@mui/material'
import { useSettings } from '../hooks/use-settings'
+import { useBrandingSettings } from './CippPdf/useBrandingSettings'
import { ServerPdfPane, useServerPdf } from './CippPdf/useServerPdf'
// The report PDF is rendered server-side (ExecGetBecReportPdf) by the shared CIPPSharp component kit,
@@ -26,6 +27,7 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) =>
// 'full' = the complete report; 'summary' = the executive pages only, for a C-suite reader.
const [variant, setVariant] = useState('full')
const tenantFilter = useSettings().currentTenant
+ const branding = useBrandingSettings()
// Only offer the report once the BEC analysis has completed (its result is what the server reads).
const hasData = userData && becData && !becData.Waiting
@@ -37,6 +39,7 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) =>
userName: userData?.userPrincipalName ?? '',
userDisplayName: userData?.displayName ?? '',
variant,
+ brandingPresetId: branding?.reportDefaults?.[variant === 'summary' ? 'becSummary' : 'bec'] ?? '',
})
const pdf = useServerPdf({ url: `/api/ExecGetBecReportPdf?${params}`, enabled: dialogOpen })
const handleOpenDialog = () => setDialogOpen(true)
From b25203c3c8937e344dbc81bf7f4590ff9c4b2987 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:59:09 -0400
Subject: [PATCH 02/19] fix(bec): use configured PDF branding presets
---
.../Administration/Users/Invoke-ExecGetBecReportPdf.ps1 | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1
index eb531b22e8..0666a0cc02 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1
@@ -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' })
}
@@ -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
@@ -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
From acb2ff291590ea7fea87093cde26d539495ecfce Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:59:13 -0400
Subject: [PATCH 03/19] fix(bec): use configured PDF branding presets
---
.../Public/BEC/New-CIPPBecEvidencePackage.ps1 | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
index 76f7fe6a58..b786513c72 100644
--- a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
+++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
@@ -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)"
}
From 20ea9f098860186311d8bf8dbb3aa4ec4e1f36ac Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:59:28 -0400
Subject: [PATCH 04/19] test(bec): cover branding in evidence ZIP
---
.../New-CIPPBecEvidencePackage.Tests.ps1 | 27 +++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
index a02043172e..62d14c142f 100644
--- a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
+++ b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
@@ -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]@{
@@ -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 }
}
@@ -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
From c2376f4a5b31abbc56385ce7a4d52e5d4f206dc4 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:59:45 -0400
Subject: [PATCH 05/19] test(bec): verify branding preset reaches PDF renderer
---
.../Invoke-ExecGetBecReportPdf.Tests.ps1 | 58 +++++++++++++++++++
1 file changed, 58 insertions(+)
create mode 100644 backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1
diff --git a/backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1 b/backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1
new file mode 100644
index 0000000000..0f0542eb5b
--- /dev/null
+++ b/backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1
@@ -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) }
+ }
+}
From e75c88fc5a363ff3d8abb4c2e5bbbaed68d02e67 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:00:00 -0400
Subject: [PATCH 06/19] test(bec): verify variant-specific branding request
---
.../BECRemediationReportButton.test.jsx | 69 +++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 frontend/tests/components/BECRemediationReportButton.test.jsx
diff --git a/frontend/tests/components/BECRemediationReportButton.test.jsx b/frontend/tests/components/BECRemediationReportButton.test.jsx
new file mode 100644
index 0000000000..ff201462a1
--- /dev/null
+++ b/frontend/tests/components/BECRemediationReportButton.test.jsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders, settingsWith } from '../test-utils'
+import { api, getResult } from '../mocks/api-call'
+import { BECRemediationReportButton } from '../../src/components/BECRemediationReportButton'
+
+vi.mock('../../src/api/ApiCall', async () =>
+ (await import('../mocks/api-call')).apiCallMock()
+)
+
+const TENANT = 'contoso.onmicrosoft.com'
+const branding = getResult({
+ data: { reportDefaults: { bec: 'preset-full', becSummary: 'preset-summary' } },
+})
+let fetchMock
+
+beforeEach(() => {
+ api.get = () => branding
+ fetchMock = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ blob: () => Promise.resolve(new Blob(['%PDF-'], { type: 'application/pdf' })),
+ })
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ URL.createObjectURL = vi.fn(() => 'blob:bec-report')
+ URL.revokeObjectURL = vi.fn()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+const renderButton = () =>
+ renderWithProviders(
+ ,
+ { settings: settingsWith({ currentTenant: TENANT }) }
+ )
+
+const lastParams = () => new URL(fetchMock.mock.calls.at(-1)[0], 'https://example.com').searchParams
+
+describe('BECRemediationReportButton branding defaults', () => {
+ it('sends the full preset then the summary preset when the variant changes', async () => {
+ const user = userEvent.setup()
+ renderButton()
+ await user.click(screen.getByRole('button', { name: 'Generate PDF Report' }))
+ const dialog = await screen.findByRole('dialog')
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+ expect(lastParams().get('brandingPresetId')).toBe('preset-full')
+
+ await user.click(screen.getByRole('button', { name: 'C-suite summary' }))
+ await waitFor(() => expect(lastParams().get('brandingPresetId')).toBe('preset-summary'))
+ expect(lastParams().get('variant')).toBe('summary')
+ })
+
+ it('falls back to default branding when BEC defaults are unset', async () => {
+ api.get = () => getResult({ data: { reportDefaults: {} } })
+ renderButton()
+ await userEvent.click(screen.getByRole('button', { name: 'Generate PDF Report' }))
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+ expect(lastParams().get('brandingPresetId')).toBe('')
+ })
+})
From 12b4a12c7587002ce9bac17bd99d6fdabc718aeb Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:00:21 -0400
Subject: [PATCH 07/19] test(bec): match PDF button accessible name
---
frontend/tests/components/BECRemediationReportButton.test.jsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/tests/components/BECRemediationReportButton.test.jsx b/frontend/tests/components/BECRemediationReportButton.test.jsx
index ff201462a1..e94562ea83 100644
--- a/frontend/tests/components/BECRemediationReportButton.test.jsx
+++ b/frontend/tests/components/BECRemediationReportButton.test.jsx
@@ -49,8 +49,8 @@ describe('BECRemediationReportButton branding defaults', () => {
it('sends the full preset then the summary preset when the variant changes', async () => {
const user = userEvent.setup()
renderButton()
- await user.click(screen.getByRole('button', { name: 'Generate PDF Report' }))
- const dialog = await screen.findByRole('dialog')
+ await user.click(screen.getByRole('button', { name: /Generate (BEC Remediation Report PDF|PDF Report)/i }))
+ await screen.findByRole('dialog')
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
expect(lastParams().get('brandingPresetId')).toBe('preset-full')
From 2b68f78b27496cfe6062c758d94040d143cfd597 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:09:53 -0400
Subject: [PATCH 08/19] test(bec): use consistent accessible PDF button
selector
---
frontend/tests/components/BECRemediationReportButton.test.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/tests/components/BECRemediationReportButton.test.jsx b/frontend/tests/components/BECRemediationReportButton.test.jsx
index e94562ea83..88fe1dfa73 100644
--- a/frontend/tests/components/BECRemediationReportButton.test.jsx
+++ b/frontend/tests/components/BECRemediationReportButton.test.jsx
@@ -62,7 +62,7 @@ describe('BECRemediationReportButton branding defaults', () => {
it('falls back to default branding when BEC defaults are unset', async () => {
api.get = () => getResult({ data: { reportDefaults: {} } })
renderButton()
- await userEvent.click(screen.getByRole('button', { name: 'Generate PDF Report' }))
+ await userEvent.click(screen.getByRole('button', { name: /Generate (BEC Remediation Report PDF|PDF Report)/i }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
expect(lastParams().get('brandingPresetId')).toBe('')
})
From a2777e248ca8c035f4cde72f846a95ac32d15fb3 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:18 -0400
Subject: [PATCH 09/19] fix(reports): apply configured branding preset in
SharingReportButton
---
frontend/src/components/CippPdf/SharingReportButton.jsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/CippPdf/SharingReportButton.jsx b/frontend/src/components/CippPdf/SharingReportButton.jsx
index e96298e521..81628642d9 100644
--- a/frontend/src/components/CippPdf/SharingReportButton.jsx
+++ b/frontend/src/components/CippPdf/SharingReportButton.jsx
@@ -13,15 +13,17 @@ import {
} from '@mui/material'
import { useSettings } from '../../hooks/use-settings'
import { ServerPdfPane, useServerPdf } from './useServerPdf'
+import { useBrandingSettings } from './useBrandingSettings'
// The report PDF is rendered server-side (ExecGetSharingReportPdf) by the shared CIPPSharp component
// kit; the button fetches the finished PDF as a blob for preview and download.
export const SharingReportButton = ({ sharingData, tenantName }) => {
const [dialogOpen, setDialogOpen] = useState(false)
const tenantFilter = useSettings().currentTenant
+ const branding = useBrandingSettings()
const hasData = !!sharingData?.summary
const pdf = useServerPdf({
- url: `/api/ExecGetSharingReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}`,
+ url: `/api/ExecGetSharingReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}&brandingPresetId=${encodeURIComponent(branding?.reportDefaults?.sharing ?? '')}`,
enabled: dialogOpen,
})
const handleOpen = () => setDialogOpen(true)
From e7a4cc6fde277c3d9c5abb33f23e2b2aa89c0233 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:20 -0400
Subject: [PATCH 10/19] fix(reports): apply configured branding preset in
PermissionsReportButton
---
frontend/src/components/CippPdf/PermissionsReportButton.jsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/CippPdf/PermissionsReportButton.jsx b/frontend/src/components/CippPdf/PermissionsReportButton.jsx
index 560fd92c58..d37eab296f 100644
--- a/frontend/src/components/CippPdf/PermissionsReportButton.jsx
+++ b/frontend/src/components/CippPdf/PermissionsReportButton.jsx
@@ -13,15 +13,17 @@ import {
} from '@mui/material'
import { useSettings } from '../../hooks/use-settings'
import { ServerPdfPane, useServerPdf } from './useServerPdf'
+import { useBrandingSettings } from './useBrandingSettings'
// The report PDF is rendered server-side (ExecGetPermissionsReportPdf) by the shared CIPPSharp
// component kit; the button fetches the finished PDF as a blob for preview and download.
export const PermissionsReportButton = ({ permissionsData, tenantName }) => {
const [dialogOpen, setDialogOpen] = useState(false)
const tenantFilter = useSettings().currentTenant
+ const branding = useBrandingSettings()
const hasData = !!permissionsData?.summary
const pdf = useServerPdf({
- url: `/api/ExecGetPermissionsReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}`,
+ url: `/api/ExecGetPermissionsReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}&brandingPresetId=${encodeURIComponent(branding?.reportDefaults?.permissions ?? '')}`,
enabled: dialogOpen,
})
const handleOpen = () => setDialogOpen(true)
From c80206748f32dd8fe625891f2b2bfd1558ee5cae Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:22 -0400
Subject: [PATCH 11/19] fix(reports): apply configured branding preset in
MailFlowReportButton
---
frontend/src/components/CippPdf/MailFlowReportButton.jsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/CippPdf/MailFlowReportButton.jsx b/frontend/src/components/CippPdf/MailFlowReportButton.jsx
index 065057ed48..7cea54f28c 100644
--- a/frontend/src/components/CippPdf/MailFlowReportButton.jsx
+++ b/frontend/src/components/CippPdf/MailFlowReportButton.jsx
@@ -13,6 +13,7 @@ import {
} from '@mui/material'
import { useSettings } from '../../hooks/use-settings'
import { ServerPdfPane, useServerPdf } from './useServerPdf'
+import { useBrandingSettings } from './useBrandingSettings'
// The report PDF is rendered server-side (ExecGetMailFlowReportPdf) by the shared CIPPSharp component
// kit, which re-gathers and aggregates the mail flow data; the button fetches the finished PDF as a
@@ -20,10 +21,11 @@ import { ServerPdfPane, useServerPdf } from './useServerPdf'
export const MailFlowReportButton = ({ mailFlowData, tenantName, disabled = false }) => {
const [dialogOpen, setDialogOpen] = useState(false)
const tenantFilter = useSettings().currentTenant
+ const branding = useBrandingSettings()
const days = Number(mailFlowData?.days) || 14
const hasData = Object.keys(mailFlowData?.totals ?? {}).length > 0
const pdf = useServerPdf({
- url: `/api/ExecGetMailFlowReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}&days=${days}`,
+ url: `/api/ExecGetMailFlowReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}&days=${days}&brandingPresetId=${encodeURIComponent(branding?.reportDefaults?.mailFlow ?? '')}`,
enabled: dialogOpen,
})
const handleOpen = () => setDialogOpen(true)
From 3352df75e5228c231592d1b0c0fe15624201dea9 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:25 -0400
Subject: [PATCH 12/19] fix(reports): apply configured branding preset in
ShadowAIReportButton
---
frontend/src/components/ShadowAIReportButton.jsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/ShadowAIReportButton.jsx b/frontend/src/components/ShadowAIReportButton.jsx
index 66c76515d2..9bcb35650b 100644
--- a/frontend/src/components/ShadowAIReportButton.jsx
+++ b/frontend/src/components/ShadowAIReportButton.jsx
@@ -18,6 +18,7 @@ import {
import { CippOffCanvas } from './CippComponents/CippOffCanvas'
import { useSettings } from '../hooks/use-settings'
import { ServerPdfPane, useServerPdf } from './CippPdf/useServerPdf'
+import { useBrandingSettings } from './CippPdf/useBrandingSettings'
const sectionOptions = [
{
@@ -69,6 +70,7 @@ const sectionOptions = [
export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
const tenantFilter = useSettings().currentTenant
+ const branding = useBrandingSettings()
const [previewOpen, setPreviewOpen] = useState(false)
// Below md the 320px config rail would leave the preview about 70px wide, so it moves into
// a drawer and the preview takes the whole dialog. Same treatment as the executive report.
@@ -100,7 +102,7 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
// re-rendered while the dialog is open whenever the selected sections change.
const pdf = useServerPdf({
url: '/api/ExecGetShadowAIReportPdf',
- body: { tenantFilter, sectionConfig },
+ body: { tenantFilter, sectionConfig, brandingPresetId: branding?.reportDefaults?.shadowAI ?? '' },
enabled: previewOpen,
})
From 540adf3f0062b90e7395f2c14987c8b415d93c9c Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:27 -0400
Subject: [PATCH 13/19] fix(reports): apply configured branding preset in
SharingReportPdf
---
.../Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1 | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1
index 9d1f97d19a..6819a5d9dc 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1
@@ -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
@@ -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
From bd5d18caea1ce1b6fbeb393c760a2078e1af5bf9 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:29 -0400
Subject: [PATCH 14/19] fix(reports): apply configured branding preset in
PermissionsReportPdf
---
.../Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1 | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1
index c9428f34d9..9d7f5a4f41 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1
@@ -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
@@ -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
From fe5e372ae56ef29b558b1a09e719e0788dfcaccc Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:31 -0400
Subject: [PATCH 15/19] fix(reports): apply configured branding preset in
MailFlowReportPdf
---
.../Reports/Invoke-ExecGetMailFlowReportPdf.ps1 | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1
index 20001143e8..41e6bc401e 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1
@@ -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')
@@ -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
From 76b70837d7ae2f0c2923feb33b9ad503f46441df Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:47:34 -0400
Subject: [PATCH 16/19] fix(reports): apply configured branding preset in
ShadowAIReportPdf
---
.../Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1 | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1
index 1d222cde04..014b1dd0c8 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1
@@ -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 = @{}
@@ -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
From 9f84e2691d168ee8c9daf5451a2f9768601d2363 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:48:20 -0400
Subject: [PATCH 17/19] test(reports): cover per-report PDF branding defaults
in UI
---
.../PdfReportBrandingDefaults.test.jsx | 109 ++++++++++++++++++
1 file changed, 109 insertions(+)
create mode 100644 frontend/tests/components/PdfReportBrandingDefaults.test.jsx
diff --git a/frontend/tests/components/PdfReportBrandingDefaults.test.jsx b/frontend/tests/components/PdfReportBrandingDefaults.test.jsx
new file mode 100644
index 0000000000..4e843b7f89
--- /dev/null
+++ b/frontend/tests/components/PdfReportBrandingDefaults.test.jsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders, settingsWith } from '../test-utils'
+import { api, getResult } from '../mocks/api-call'
+import { SharingReportButton } from '../../src/components/CippPdf/SharingReportButton'
+import { PermissionsReportButton } from '../../src/components/CippPdf/PermissionsReportButton'
+import { MailFlowReportButton } from '../../src/components/CippPdf/MailFlowReportButton'
+import { ShadowAIReportButton } from '../../src/components/ShadowAIReportButton'
+
+vi.mock('../../src/api/ApiCall', async () =>
+ (await import('../mocks/api-call')).apiCallMock()
+)
+
+const TENANT = 'contoso.onmicrosoft.com'
+const defaults = {
+ sharing: 'preset-sharing',
+ permissions: 'preset-permissions',
+ mailFlow: 'preset-mail',
+ shadowAI: 'preset-ai',
+}
+let fetchMock
+
+beforeEach(() => {
+ api.get = (opts) =>
+ opts.url === '/api/ListBrandingSettings'
+ ? getResult({ data: { reportDefaults: defaults } })
+ : getResult()
+ fetchMock = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ blob: () => Promise.resolve(new Blob(['%PDF-'], { type: 'application/pdf' })),
+ })
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ URL.createObjectURL = vi.fn(() => 'blob:report')
+ URL.revokeObjectURL = vi.fn()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+const reports = [
+ {
+ name: 'Sharing',
+ component: ,
+ endpoint: 'ExecGetSharingReportPdf',
+ preset: defaults.sharing,
+ button: /export report/i,
+ },
+ {
+ name: 'Permissions',
+ component: ,
+ endpoint: 'ExecGetPermissionsReportPdf',
+ preset: defaults.permissions,
+ button: /export report/i,
+ },
+ {
+ name: 'Mail Flow',
+ component: ,
+ endpoint: 'ExecGetMailFlowReportPdf',
+ preset: defaults.mailFlow,
+ button: /export report/i,
+ },
+ {
+ name: 'Shadow AI',
+ component: ,
+ endpoint: 'ExecGetShadowAIReportPdf',
+ preset: defaults.shadowAI,
+ button: /executive shadow ai report/i,
+ },
+]
+
+describe('Per-report PDF branding defaults', () => {
+ it.each(reports)('$name forwards its configured preset to the PDF endpoint', async ({ component, endpoint, preset, button }) => {
+ renderWithProviders(component, { settings: settingsWith({ currentTenant: TENANT }) })
+ await userEvent.click(screen.getByRole('button', { name: button }))
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+
+ const [url, init] = fetchMock.mock.calls.at(-1)
+ expect(url).toContain(endpoint)
+ if (endpoint === 'ExecGetShadowAIReportPdf') {
+ expect(JSON.parse(init.body)).toMatchObject({
+ tenantFilter: TENANT,
+ brandingPresetId: preset,
+ })
+ } else {
+ const params = new URL(url, 'https://example.com').searchParams
+ expect(params.get('tenantFilter')).toBe(TENANT)
+ expect(params.get('brandingPresetId')).toBe(preset)
+ }
+ })
+
+ it.each(reports)('$name falls back to global branding when no preset is assigned', async ({ component, endpoint, button }) => {
+ api.get = () => getResult({ data: { reportDefaults: {} } })
+ renderWithProviders(component, { settings: settingsWith({ currentTenant: TENANT }) })
+ await userEvent.click(screen.getByRole('button', { name: button }))
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+
+ const [url, init] = fetchMock.mock.calls.at(-1)
+ if (endpoint === 'ExecGetShadowAIReportPdf') {
+ expect(JSON.parse(init.body).brandingPresetId).toBe('')
+ } else {
+ expect(new URL(url, 'https://example.com').searchParams.get('brandingPresetId')).toBe('')
+ }
+ })
+})
From 358f288155888ba5f2e58741239a12227c5b9881 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:48:45 -0400
Subject: [PATCH 18/19] test(reports): verify branding preset forwarding in PDF
endpoints
---
.../Invoke-ExecPdfBrandingDefaults.Tests.ps1 | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1
diff --git a/backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1 b/backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1
new file mode 100644
index 0000000000..5c78eb7bdd
--- /dev/null
+++ b/backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1
@@ -0,0 +1,85 @@
+# Report endpoints must pass the report-specific branding preset through to both tenant naming and PDF rendering.
+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])
+ }
+
+ foreach ($File in @(
+ 'Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1'
+ 'Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1'
+ 'Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1'
+ 'Tenant/AI/Invoke-ExecGetShadowAIReportPdf.ps1'
+ )) {
+ . (Join-Path $RepoRoot "Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/$File")
+ }
+
+ function Get-CippReportTenantName { param($TenantFilter, $BrandingPresetId) 'Contoso' }
+ function ConvertTo-CippReportPdf {
+ param($Blocks, $Variables, $TenantName, $TenantFilter, $ReportName, $BrandingPresetId)
+ [byte[]]@(37, 80, 68, 70, 45)
+ }
+ function Get-CIPPSharePointSharingReport {
+ param($TenantFilter)
+ @{ summary = @{}; links = @(); topRecipients = @(); topLibraries = @() }
+ }
+ function Get-CIPPSharePointPermissionsReport {
+ param($TenantFilter)
+ @{ summary = @{}; assignments = @(); skippedSites = @() }
+ }
+ function Get-CIPPShadowAIReport {
+ param($TenantFilter)
+ @{ summary = @{}; detectedApps = @(); consentedApps = @(); topTools = @(); byRisk = @() }
+ }
+ function Build-CippSharingReportTree { param($Data) @{ Blocks = @(@{ type = 'blank' }); Variables = @{} } }
+ function Build-CippPermissionsReportTree { param($Data) @{ Blocks = @(@{ type = 'blank' }); Variables = @{} } }
+ function Build-CippMailFlowReportTree { param($Data) @{ Blocks = @(@{ type = 'blank' }); Variables = @{} } }
+ function Build-CippShadowAIReportTree { param($Data, $SectionConfig) @{ Blocks = @(@{ type = 'blank' }); Variables = @{} } }
+ function New-ExoRequest { param($TenantId, $Cmdlet, $CmdParams) @() }
+ function Get-CippException { param($Exception) @{ NormalizedError = [string]$Exception } }
+ function Write-LogMessage {}
+}
+
+Describe 'Report PDF branding preset forwarding' {
+ It ' passes branding to the tenant label and PDF renderer' -ForEach @(
+ @{ Report = 'Sharing'; Endpoint = 'Invoke-ExecGetSharingReportPdf'; Preset = 'sharing-preset'; Post = $false }
+ @{ Report = 'Permissions'; Endpoint = 'Invoke-ExecGetPermissionsReportPdf'; Preset = 'permissions-preset'; Post = $false }
+ @{ Report = 'Mail Flow'; Endpoint = 'Invoke-ExecGetMailFlowReportPdf'; Preset = 'mail-preset'; Post = $false }
+ @{ Report = 'Shadow AI'; Endpoint = 'Invoke-ExecGetShadowAIReportPdf'; Preset = 'ai-preset'; Post = $true }
+ ) {
+ Mock Get-CippReportTenantName { 'Contoso' }
+ Mock ConvertTo-CippReportPdf { [byte[]]@(37, 80, 68, 70, 45) }
+ $Query = @{ tenantFilter = 'contoso.onmicrosoft.com' }
+ $Body = @{}
+ if ($Post) {
+ $Body = @{ tenantFilter = 'contoso.onmicrosoft.com'; brandingPresetId = $Preset; sectionConfig = @{} }
+ } else {
+ $Query.brandingPresetId = $Preset
+ }
+ $Response = & $Endpoint -Request @{ Query = $Query; Body = $Body; Headers = @{} } -TriggerMetadata @{ FunctionName = $Endpoint }
+
+ $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
+ Should -Invoke Get-CippReportTenantName -Times 1 -Exactly -ParameterFilter { $BrandingPresetId -eq $Preset }
+ Should -Invoke ConvertTo-CippReportPdf -Times 1 -Exactly -ParameterFilter { $BrandingPresetId -eq $Preset }
+ }
+
+ It ' uses global branding when no preset is selected' -ForEach @(
+ @{ Report = 'Sharing'; Endpoint = 'Invoke-ExecGetSharingReportPdf' }
+ @{ Report = 'Permissions'; Endpoint = 'Invoke-ExecGetPermissionsReportPdf' }
+ @{ Report = 'Mail Flow'; Endpoint = 'Invoke-ExecGetMailFlowReportPdf' }
+ @{ Report = 'Shadow AI'; Endpoint = 'Invoke-ExecGetShadowAIReportPdf' }
+ ) {
+ Mock Get-CippReportTenantName { 'Contoso' }
+ Mock ConvertTo-CippReportPdf { [byte[]]@(37, 80, 68, 70, 45) }
+ $Response = & $Endpoint -Request @{
+ Query = @{ tenantFilter = 'contoso.onmicrosoft.com' }
+ Body = @{}; Headers = @{}
+ } -TriggerMetadata @{ FunctionName = $Endpoint }
+
+ $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
+ Should -Invoke ConvertTo-CippReportPdf -Times 1 -Exactly -ParameterFilter { [string]::IsNullOrEmpty($BrandingPresetId) }
+ }
+}
From fed766b3eab040dd576dbf8d657a00d0a8199090 Mon Sep 17 00:00:00 2001
From: Martin Rainville <35352705+MartinRain@users.noreply.github.com>
Date: Mon, 19 Oct 2026 08:09:40 -0400
Subject: [PATCH 19/19] test(bec): make log partition assertion respect rolling
60-day window
---
.../Private/New-CIPPBecEvidencePackage.Tests.ps1 | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
index 62d14c142f..21d210a908 100644
--- a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
+++ b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1
@@ -129,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' {