diff --git a/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 new file mode 100644 index 0000000000000..9578039306621 --- /dev/null +++ b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 @@ -0,0 +1,63 @@ +function Resolve-NinjaOneCveScanGroup { + <# + .SYNOPSIS + Resolves the NinjaOne vulnerability scan group used for CVE sync, creating it if it does not exist. + .DESCRIPTION + Looks up the CVE sync scan group by name via the NinjaOne API. If the lookup fails, or if no matching + scan group is found and the subsequent create attempt also fails, logs the error and returns $null so + the caller can skip CVE sync for this tenant without failing the overall tenant sync. + .PARAMETER Configuration + The NinjaOne configuration object. Must expose CveSyncDeviceIdHeader / CveSyncCveIdHeader (both optional). + .PARAMETER TenantFilter + The tenant identifier used for logging context. + .PARAMETER ScanGroupName + The resolved scan group name to look up or create. + .PARAMETER NinjaBaseUrl + The base NinjaOne API URL (e.g. https://instance/api/v2). + .PARAMETER Token + The NinjaOne OAuth token object. Must expose access_token. + .OUTPUTS + The resolved (existing or newly-created) scan group object, or $null if it could not be resolved. + #> + [CmdletBinding()] + param ( + $Configuration, + [string]$TenantFilter, + [string]$ScanGroupName, + [string]$NinjaBaseUrl, + $Token + ) + + try { + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — could not look up scan group '$ScanGroupName': $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return $null + } + + # Where-Object returns an array when multiple scan groups share the same name; take the + # first match so callers always receive a single object as documented, not a collection. + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } | Select-Object -First 1 + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — scan group '$ScanGroupName' not found, attempting to create it" -sev 'Info' + + try { + $NewScanGroupBody = @{ + groupName = $ScanGroupName + deviceIdHeader = $Configuration.CveSyncDeviceIdHeader ?? 'deviceName' + cveIdHeader = $Configuration.CveSyncCveIdHeader ?? 'cveId' + } | ConvertTo-Json -Depth 5 + + $ResolvedScanGroup = Invoke-RestMethod -Method Post -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)"; 'Content-Type' = 'application/json' } -Body $NewScanGroupBody -TimeoutSec 30 -ErrorAction Stop + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — created scan group '$ScanGroupName'" -sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found and could not be created: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + $ResolvedScanGroup = $null + } + } + + return $ResolvedScanGroup +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index e415d4cf580c1..d3a99fcc51fa7 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2198,12 +2198,9 @@ function Invoke-NinjaOneTenantSync { $ScanGroupName = "$ScanGroupPrefix$TenantFilter" $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" - $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop - $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + $ResolvedScanGroup = Resolve-NinjaOneCveScanGroup -Configuration $Configuration -TenantFilter $TenantFilter -ScanGroupName $ScanGroupName -NinjaBaseUrl $NinjaBaseUrl -Token $Token - if (-not $ResolvedScanGroup) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' - } else { + if ($ResolvedScanGroup) { $ResolvedScanGroupId = $ResolvedScanGroup.id $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader $CveIdHeader = $ResolvedScanGroup.cveIdHeader diff --git a/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 new file mode 100644 index 0000000000000..b2197f467cdd9 --- /dev/null +++ b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 @@ -0,0 +1,480 @@ +# Pester tests for Invoke-NinjaOneTenantSync +# +# Scope (per "solid coverage" agreement): happy-path end-to-end plus key +# error/edge branches per major section. Deep internals of the optional +# UserDocuments/LicenseDocuments related-items linking flows are exercised +# only at the "does it run without throwing / batch call happens" level — +# not every one of the 17 Invoke-WebRequest call sites is asserted +# individually, since that would require disproportionate fixture effort +# relative to this file's actual bug surface (CVE scan-group auto-create, +# GH issue #6349). +# +# Strategy: Get-CIPPTable is stubbed (not Mocked) to tag its returned +# Context with the requested table name, so downstream +# Get-CIPPAzDataTableEntity / Add-CIPPAzDataTableEntity mocks can +# distinguish table + filter combinations via -ParameterFilter. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1' + + # Minimal stubs so Mock has commands to replace during tests. + function Get-CIPPTable { param($tablename) @{ Context = $tablename } } + function Get-CippTable { param($tablename) @{ Context = $tablename } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Remove-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($tenant, $API, $message, $Sev, $LogData) } + function Get-NinjaOneToken { param($configuration) } + function Invoke-WebRequest { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Get-CippExtensionReportingData { param($TenantFilter, [switch]$IncludeMailboxes) } + function Get-NinjaOneLinks { param($Data, $Title, $SmallCols, $MedCols, $LargeCols, $XLCols) } + function Get-NinjaOneInfoCard { param($Title, $Data, $Icon) } + function Get-NinjaOneCard { param($Title, $Body, $Icon, $TitleLink) } + function Get-NinjaOneWidgetCard { param($Title, $Data, $Icon) } + function Get-NinjaInLineBarGraph { param($Title, $Data, [switch]$KeyInLine) } + function Get-SharePointAdminLink { param($TenantFilter) } + function Get-CIPPStandards { param($TenantFilter) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + function Get-NormalizedError { param($Message) $Message } + function New-CIPPGraphSubscription { param($TenantFilter, $Type) } + function New-VulnCsvBytes { param($Rows, $Headers) } + function Invoke-NinjaOneDocumentTemplate { param($TenantFilter, $TemplateName) } + function Invoke-NinjaOneVulnCsvUpload { param($Uri, $PollUri, $CsvBytes, $Headers) } + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Resolve-NinjaOneCveScanGroup { param($Configuration, $TenantFilter, $ScanGroupName, $NinjaBaseUrl, $Token) } + function convert-skuname { param($skuname) $skuname } + + . $FunctionPath + + function New-DefaultConfiguration { + [pscustomobject]@{ + Instance = 'app.ninjarmm.com' + UserDocumentsEnabled = $false + LicenseDocumentsEnabled = $false + LicensedOnly = $false + CveSyncEnabled = $true + CveSyncPrefix = 'CIPP-' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + } + + function New-DefaultQueueItem { + [pscustomobject]@{ + MappedTenant = [pscustomobject]@{ + RowKey = 'contoso-tenant-id' + IntegrationId = '918273' + } + } + } +} + +Describe 'Invoke-NinjaOneTenantSync' { + + BeforeEach { + # --- Common baseline mocks used by (almost) every test --- + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-NinjaOneToken -MockWith { [pscustomobject]@{ access_token = 'fake-token' } } + + Mock -CommandName Get-Tenants -MockWith { + @([pscustomobject]@{ + customerId = 'contoso-tenant-id' + defaultDomainName = 'contoso.onmicrosoft.com' + displayName = 'Contoso Ltd' + initialDomainName = 'contoso.onmicrosoft.com' + }) + } + + # CippMapping table: two different partitions are read via the same + # table/context — the sync-lock lookup (NinjaOneMapping) and the + # field-mapping lookup (NinjaOneFieldMapping). Default: no active + # lock, no field mappings (keeps the HTML/summary-card machinery + # entirely skipped for the core happy path). + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $null + lastEndTime = $null + }) + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneFieldMapping'" + } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Config' } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + [pscustomobject]@{ config = (@{ NinjaOne = (New-DefaultConfiguration) } | ConvertTo-Json -Depth 10) } + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedDevices' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneDeviceMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedUsers' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneUsersUpdate' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneUserMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { @() } + + # $CurrentItem is a single mutable object that the function under test + # mutates in place (via Add-Member -Force) and re-passes to + # Add-CIPPAzDataTableEntity multiple times (Running, then + # Completed/Failed). Pester's Should -Invoke -ParameterFilter + # re-evaluates filters against the *current* state of each recorded + # call's arguments, not a snapshot taken at call time - so once the + # shared object is mutated to 'Completed', every historical call + # appears to match. Capture an immutable clone of $Entity on every + # call so assertions can check actual per-call state. + $script:RecordedEntities = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + if ($null -ne $Entity) { + $script:RecordedEntities.Add(($Entity | ConvertTo-Json -Depth 10 | ConvertFrom-Json)) + } + } + Mock -CommandName Remove-AzDataTableEntity -MockWith { } + + # Empty extension-cache data by default — this keeps the device/user + # processing loops, tenant-summary cards, and doc-toggle blocks from + # ever executing their bodies (empty collections => no iterations). + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + + # Device fetch — one page, below PageSize, so the pagination loop + # terminates after a single call. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json -Depth 10) } + } + + # Final org-level custom-fields PATCH — succeeds by default. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + [pscustomobject]@{ content = (@{ success = $true } | ConvertTo-Json) } + } + + # CVE sync — scan group resolves, no vulnerability data by default. + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { + [pscustomobject]@{ id = 'scan-group-1'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { @() } + } + + Context 'Happy path (docs disabled, CVE sync enabled)' { + It 'completes successfully and records lastStatus Completed' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Running' }).Count | Should -Be 1 + } + + It 'fetches devices and posts the final org custom-fields update' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 1 -Exactly + Should -Invoke Invoke-WebRequest -ParameterFilter { $Method -eq 'PATCH' -and $Uri -like '*/organization/*/custom-fields' } -Times 1 -Exactly + } + + It 'does not call UserDocuments or LicenseDocuments endpoints when both toggles are disabled' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' } -Times 0 -Exactly + } + } + + Context 'Concurrency guard' { + It 'throws and does not process when a sync is already running for the tenant' { + # NOTE: the source parses `lastStartTime` with `Get-Date($string)`, + # which returns a Kind=Local DateTime, then compares it directly + # against `(Get-Date).ToUniversalTime()` (Kind=Utc). .NET's + # comparison operators ignore Kind and compare raw ticks, so this + # comparison is off by the host's UTC offset (tracked upstream as + # KelvinTegelaar/CIPP#6351 - not fixed here, out of scope for #6349). + # To keep this test deterministic across hosts in any timezone, + # build the mock timestamp the same way the guard will actually + # read it back: pick a UTC instant that, once shifted by the + # local offset (as the buggy parse does), reads as "now" - i.e. + # comfortably within the "still running" window on this host. + $LocalOffset = [System.TimeZoneInfo]::Local.GetUtcOffset((Get-Date)) + $MockStartTime = ((Get-Date).ToUniversalTime() - $LocalOffset).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $MockStartTime + lastEndTime = $null + }) + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + # The function's try/catch always returns $true — failure is + # observable only via the logged 'Failed' status and the fact + # that no device fetch happened. + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*still running*' } -Times 1 -Exactly + } + } + + Context 'Tenant match validation' { + It 'fails when the tenant cannot be uniquely matched' { + Mock -CommandName Get-Tenants -MockWith { @() } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + } + } + + Context 'Hostname validation' { + It 'fails when the configured NinjaOne instance is not an allow-listed hostname' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $BadConfig = New-DefaultConfiguration + $BadConfig.Instance = 'evil.example.com' + [pscustomobject]@{ config = (@{ NinjaOne = $BadConfig } | ConvertTo-Json -Depth 10) } + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*NinjaOne URL is invalid*' } -Times 1 -Exactly + } + } + + Context 'CVE sync' { + It 'uploads CVE rows built from vulnerability data when vulnerabilities exist' { + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1, 2, 3) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { + [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 1 } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 1 -Exactly + Should -Invoke Invoke-NinjaOneVulnCsvUpload -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Info' -and $message -like '*CVE sync complete*' } -Times 1 -Exactly + } + + It 'logs a warning and uploads a placeholder row when no vulnerability data is returned' { + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*no vulnerability data returned*' } -Times 1 -Exactly + } + + It 'excludes CVEs covered by a tenant or ALL exception before uploading' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { + @([pscustomobject]@{ RowKey = 'ALL'; cveId = 'CVE-2024-0001' }) + } + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $message -like '*filtered 1 excepted CVEs, 0 remaining*' } -Times 1 -Exactly + } + + It 'isolates a CVE sync failure so the overall tenant sync still completes' { + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { throw 'NinjaOne API unavailable' } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*CVE sync failed*' } -Times 1 -Exactly + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not attempt CVE sync when CveSyncEnabled is disabled' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $NoConfig = New-DefaultConfiguration + $NoConfig.CveSyncEnabled = $false + [pscustomobject]@{ config = (@{ NinjaOne = $NoConfig } | ConvertTo-Json -Depth 10) } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'Final org custom-fields PATCH failure' { + It 'propagates the failure to the outer catch and records lastStatus Failed' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + throw 'NinjaOne API returned 500' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Failed' }).Count | Should -Be 1 + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + # CVE sync runs after the final PATCH in the source, so a PATCH + # failure should prevent CVE sync from ever being attempted. + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'UserDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.UserDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'user1@contoso.onmicrosoft.com'; displayName = 'User One'; accountEnabled = $true; assignedLicenses = @() }) + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4001' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating user documents for new users' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the user-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } + + Context 'LicenseDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.LicenseDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @([pscustomobject]@{ skuId = 'sku-1'; skuPartNumber = 'ENTERPRISEPACK'; prepaidUnits = @{ enabled = 10 }; consumedUnits = 5 }) + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4002' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating license documents, using convert-skuname for friendly names' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the license-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } +} diff --git a/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 new file mode 100644 index 0000000000000..26c58261d2b52 --- /dev/null +++ b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 @@ -0,0 +1,140 @@ +# Pester tests for Resolve-NinjaOneCveScanGroup +# Verifies the CVE sync scan-group lookup/auto-create behavior (GH issue #6349): +# - reuses an existing scan group when the name matches +# - creates a new scan group via the NinjaOne API when none exists +# - logs and returns $null (without throwing) when creation also fails, so the +# overall tenant sync is not interrupted by CVE sync failures + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Resolve-NinjaOneCveScanGroup' { + BeforeEach { + $script:Configuration = [pscustomobject]@{ + Instance = 'contoso.rmmservice.com' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + $script:Token = [pscustomobject]@{ access_token = 'fake-token-value' } + $script:TenantFilter = 'contoso.onmicrosoft.com' + $script:ScanGroupName = 'CIPP-contoso.onmicrosoft.com' + $script:NinjaBaseUrl = 'https://contoso.rmmservice.com/api/v2' + + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns the existing scan group without attempting to create one' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'existing-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'existing-id' + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + } + + It 'creates a new scan group when no matching group exists' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'other-id'; groupName = 'SomeOtherGroup'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'new-id' + $Result.groupName | Should -Be $script:ScanGroupName + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Info' -and $message -like '*created scan group*' } -Times 1 -Exactly + } + + It 'sends the configured header names and a bearer token when creating a scan group' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq "$($script:NinjaBaseUrl)/vulnerability/scan-groups" -and + $Headers.Authorization -eq "Bearer $($script:Token.access_token)" -and + $Headers.'Content-Type' -eq 'application/json' -and + ($Body | ConvertFrom-Json).groupName -eq $script:ScanGroupName -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } + + It 'returns $null and logs an error when the scan group cannot be found or created' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'NinjaOne API unavailable' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not be created*' } -Times 1 -Exactly + } + + It 'returns $null and logs an error without throwing when the initial lookup GET fails' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { throw 'NinjaOne API unreachable' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not look up scan group*' } -Times 1 -Exactly + } + + It 'returns a single scan group object when multiple groups share the same name' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'dup-1'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + [pscustomobject]@{ id = 'dup-2'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.GetType().IsArray | Should -Be $false + $Result.id | Should -Be 'dup-1' + } + + It 'falls back to default header names when CveSyncDeviceIdHeader/CveSyncCveIdHeader are not configured' { + $script:Configuration = [pscustomobject]@{ Instance = 'contoso.rmmservice.com' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } +}