diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 index 76f7fe6a5..b786513c7 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)" } 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 20001143e..41e6bc401 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 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 eb531b22e..0666a0cc0 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 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 c9428f34d..9d7f5a4f4 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 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 9d1f97d19..6819a5d9d 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 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 1d222cde0..014b1dd0c 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 diff --git a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 index a02043172..21d210a90 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 @@ -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' { diff --git a/backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1 b/backend/Tests/Reports/Invoke-ExecGetBecReportPdf.Tests.ps1 new file mode 100644 index 000000000..0f0542eb5 --- /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) } + } +} diff --git a/backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1 b/backend/Tests/Reports/Invoke-ExecPdfBrandingDefaults.Tests.ps1 new file mode 100644 index 000000000..5c78eb7bd --- /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) } + } +} diff --git a/frontend/src/components/BECRemediationReportButton.jsx b/frontend/src/components/BECRemediationReportButton.jsx index b9f1be324..d62250cbc 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) diff --git a/frontend/src/components/CippPdf/MailFlowReportButton.jsx b/frontend/src/components/CippPdf/MailFlowReportButton.jsx index 065057ed4..7cea54f28 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) diff --git a/frontend/src/components/CippPdf/PermissionsReportButton.jsx b/frontend/src/components/CippPdf/PermissionsReportButton.jsx index 560fd92c5..d37eab296 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) diff --git a/frontend/src/components/CippPdf/SharingReportButton.jsx b/frontend/src/components/CippPdf/SharingReportButton.jsx index e96298e52..81628642d 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) diff --git a/frontend/src/components/ShadowAIReportButton.jsx b/frontend/src/components/ShadowAIReportButton.jsx index 66c76515d..9bcb35650 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, }) diff --git a/frontend/tests/components/BECRemediationReportButton.test.jsx b/frontend/tests/components/BECRemediationReportButton.test.jsx new file mode 100644 index 000000000..88fe1dfa7 --- /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 (BEC Remediation Report PDF|PDF Report)/i })) + 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 (BEC Remediation Report PDF|PDF Report)/i })) + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(lastParams().get('brandingPresetId')).toBe('') + }) +}) diff --git a/frontend/tests/components/PdfReportBrandingDefaults.test.jsx b/frontend/tests/components/PdfReportBrandingDefaults.test.jsx new file mode 100644 index 000000000..4e843b7f8 --- /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('') + } + }) +})