tests/Test-Assessment.25466.ps1

<#
.SYNOPSIS
    Validates that at least two Private Access Connectors are active and healthy per connector group.
 
.DESCRIPTION
    This test checks if each connector group has at least two active connectors to ensure
    redundant access paths and resilience against connector failure. Each connector group
    acts as the sole access path for private applications assigned to it. A single connector
    failure eliminates all Private Access routing through that group until manually restored.
 
.NOTES
    Test ID: 25466
    Category: Private Access
    Required API: onPremisesPublishingProfiles/applicationProxy/connectorGroups (beta), connectorGroups/{id}/members (beta)
#>


function Test-Assessment-25466 {
    [ZtTest(
        Category = 'Private Access',
        ImplementationCost = 'Low',
        MinimumLicense = ('Entra_Premium_Private_Access'),
        Pillar = 'Network',
        RiskLevel = 'High',
        SfiPillar = 'Protect networks',
        TenantType = ('Workforce'),
        TestId = 25466,
        Title = 'At least two Private Access connectors are active and healthy per connector group',
        UserImpact = 'Medium'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity = 'Checking Private Access Connector redundancy per connector group'
    Write-ZtProgress -Activity $activity -Status 'Querying connector groups'

    # Query 1: Get all connector groups
    $connectorGroups = @()
    $query1Failed = $false
    try {
        $connectorGroups = Invoke-ZtGraphRequest `
            -RelativeUri 'onPremisesPublishingProfiles/applicationProxy/connectorGroups' `
            -ApiVersion beta
    }
    catch {
        $query1Failed = $true
        Write-PSFMessage "Failed to retrieve connector groups: $_" -Tag Test -Level Warning
    }

    # Query 2: For each connector group, get its member connectors
    $groupsWithConnectors = [System.Collections.Generic.List[object]]::new()
    $failedGroups = [System.Collections.Generic.List[object]]::new()
    $query2FailedGroups = [System.Collections.Generic.List[object]]::new()

    if ($connectorGroups -and $connectorGroups.Count -gt 0) {
        Write-ZtProgress -Activity $activity -Status 'Analyzing connector membership per group'

        # Filter for applicationProxy type groups only (API returns mixed types despite being scoped to applicationProxy profile)
        $applicationProxyGroups = @($connectorGroups | Where-Object { $_.connectorGroupType -eq 'applicationProxy' })

        if ($applicationProxyGroups.Count -eq 0 -and -not $query1Failed) {
            # No applicationProxy groups found - Private Access not configured
            # Note: If Query 1 failed, don't return NotApplicable - let it fall through to assessment logic for Investigate status
            Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Private Access connector groups are configured in the tenant. This check applies only when Microsoft Entra Private Access or Application Proxy is deployed.'
            return
        }

        foreach ($group in $applicationProxyGroups) {
            try {
                $members = Invoke-ZtGraphRequest `
                    -RelativeUri "onPremisesPublishingProfiles/applicationProxy/connectorGroups/$($group.id)/members" `
                    -ApiVersion beta

                # Normalize to array to ensure .Count works correctly with single objects
                $members = @($members)

                # Count active connectors
                $activeConnectors = @($members | Where-Object { $_.status -eq 'active' })
                $activeCount = $activeConnectors.Count
                $totalCount = $members.Count

                $regionDisplayRaw = if ($group.region) { $group.region } else { 'Default' }
                $regionDisplay = Get-SafeMarkdown -Text $regionDisplayRaw
                $groupStatus = if ($activeCount -ge 2) { 'Pass' } else { 'Fail' }

                $groupInfo = [PSCustomObject]@{
                    Name        = $group.name
                    Region      = $regionDisplay
                    ActiveCount = $activeCount
                    TotalCount  = $totalCount
                    Status      = $groupStatus
                    Members     = $members
                }

                $groupsWithConnectors.Add($groupInfo)

                if ($groupStatus -eq 'Fail') {
                    $failedGroups.Add($groupInfo)
                }
            }
            catch {
                Write-PSFMessage "Failed to retrieve connectors for group $($group.id): $_" -Tag Test -Level Warning
                $query2FailedGroups.Add($group)
            }
        }
    }
    elseif (-not $query1Failed) {
        # No connector groups found at all - Private Access / Application Proxy not configured
        # Note: If Query 1 failed, don't return NotApplicable - let it fall through to assessment logic for Investigate status
        Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Private Access connector groups are configured in the tenant. This check applies only when Microsoft Entra Private Access or Application Proxy is deployed.'
        return
    }
    #endregion Data Collection

    #region Assessment Logic
    $testResultMarkdown = ''
    $passed = $false
    $customStatus = $null

    if ($query1Failed -or $query2FailedGroups.Count -gt 0) {
        # Query failures - unable to complete assessment
        $passed = $false
        $customStatus = 'Investigate'
        $testResultMarkdown = "⚠️ Unable to determine connector redundancy due to query failure, connection issues, or insufficient permissions.`n`n%TestResult%"
    }
    elseif ($failedGroups.Count -eq 0) {
        # All groups pass
        $passed = $true
        $testResultMarkdown = "✅ All Private Access connector groups have at least two active and healthy connectors, ensuring redundant access paths per deployment region.`n`n%TestResult%"
    }
    else {
        # At least one group fails
        $passed = $false
        $testResultMarkdown = "❌ One or more Private Access connector groups have fewer than two active connectors, exposing private application access to a single point of failure.`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $mdInfo = ''

    # Show groups where Query 2 failed
    if ($query2FailedGroups.Count -gt 0) {
        $mdInfo += "`n**⚠️ Failed to query connectors for the following group(s):**`n`n"
        foreach ($failedGroup in $query2FailedGroups) {
            $groupName = Get-SafeMarkdown -Text $failedGroup.name
            $regionDisplayRaw = if ($failedGroup.region) { $failedGroup.region } else { 'Default' }
            $regionDisplay = Get-SafeMarkdown -Text $regionDisplayRaw
            $mdInfo += "- $groupName (Region: $regionDisplay)`n"
        }
        $mdInfo += "`n"
    }

    if ($groupsWithConnectors.Count -gt 0) {
        $reportTitle = 'Private Access Connector Groups'
        $portalLink = 'https://entra.microsoft.com/#view/Microsoft_Entra_GSA_Connect/Connectors.ReactView'

        # Build connector groups summary table
        $formatTemplate = @'
 
#### [{0}]({1})
 
| Connector group name | Region | Active connectors | Total connectors | Status |
| :------------------- | :----- | ----------------: | ---------------: | :----- |
{2}
'@


        $tableRows = ""
        $maxGroupsToShow = 10
        $groupsToDisplay = $groupsWithConnectors | Sort-Object Name | Select-Object -First $maxGroupsToShow

        foreach ($group in $groupsToDisplay) {
            $groupName = Get-SafeMarkdown -Text $group.Name
            $region = $group.Region
            $statusIcon = if ($group.Status -eq 'Pass') { '✅ Pass' } else { '❌ Fail' }
            $tableRows += "| $groupName | $region | $($group.ActiveCount) | $($group.TotalCount) | $statusIcon |`n"
        }

        $mdInfo += $formatTemplate -f $reportTitle, $portalLink, $tableRows

        # Add note if groups were truncated
        if ($groupsWithConnectors.Count -gt $maxGroupsToShow) {
            $remainingGroups = $groupsWithConnectors.Count - $maxGroupsToShow
            $mdInfo += "`n_Showing first $maxGroupsToShow of $($groupsWithConnectors.Count) connector groups. $remainingGroups additional group(s) not shown._`n"
        }

        # Add detailed tables for failing groups
        if ($failedGroups.Count -gt 0) {
            $mdInfo += "`n`n#### Connector Details for Failing Groups`n`n"

            $maxFailingGroupsToShow = 10
            $maxConnectorsPerGroup = 10
            $failingGroupsToDisplay = $failedGroups | Sort-Object Name | Select-Object -First $maxFailingGroupsToShow

            foreach ($failedGroup in $failingGroupsToDisplay) {
                $groupName = Get-SafeMarkdown -Text $failedGroup.Name
                $failedRegion = $failedGroup.Region
                $mdInfo += "**Connector Group: $groupName** (Region: $failedRegion)`n`n"

                if ($failedGroup.Members -and $failedGroup.Members.Count -gt 0) {
                    $mdInfo += "| Group Name | Machine Name | External IP | Connector Status | Version |`n"
                    $mdInfo += "| :--------- | :----------- | :---------- | :--------------- | :------ |`n"

                    $connectorsToDisplay = $failedGroup.Members | Sort-Object machineName | Select-Object -First $maxConnectorsPerGroup

                    foreach ($member in $connectorsToDisplay) {
                        $machineName = Get-SafeMarkdown -Text $member.machineName
                        $externalIpRaw = if ($member.externalIp) { $member.externalIp } else { 'N/A' }
                        $externalIp = Get-SafeMarkdown -Text $externalIpRaw
                        $status = if ($member.status -eq 'active') { '✅ Active' } else { '❌ Inactive' }
                        $versionRaw = if ($member.version) { $member.version } else { 'N/A' }
                        $version = Get-SafeMarkdown -Text $versionRaw
                        $mdInfo += "| $groupName | $machineName | $externalIp | $status | $version |`n"
                    }

                    # Add note if connectors were truncated for this group
                    if ($failedGroup.Members.Count -gt $maxConnectorsPerGroup) {
                        $remainingConnectors = $failedGroup.Members.Count - $maxConnectorsPerGroup
                        $mdInfo += "`n_Showing first $maxConnectorsPerGroup of $($failedGroup.Members.Count) connectors. $remainingConnectors additional connector(s) not shown._`n"
                    }

                    $mdInfo += "`n"
                }
                else {
                    $mdInfo += "_No connectors found in this group._`n`n"
                }
            }

            # Add note if failing groups were truncated
            if ($failedGroups.Count -gt $maxFailingGroupsToShow) {
                $remainingFailedGroups = $failedGroups.Count - $maxFailingGroupsToShow
                $mdInfo += "`n_Showing first $maxFailingGroupsToShow of $($failedGroups.Count) failing connector groups. $remainingFailedGroups additional failing group(s) not shown._`n"
            }
        }
    }

    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '25466'
        Title  = 'At least two Private Access connectors are active and healthy per connector group'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}