public/Connect-ZtAssessment.ps1

function Connect-ZtAssessment {
    <#
    .SYNOPSIS
        Helper method to connect to Microsoft Graph and other services with the appropriate parameters
        and scopes for the Zero Trust Assessment.
 
    .DESCRIPTION
        Use this cmdlet to connect to Microsoft Graph and other services using the appropriate parameters and scopes
        for the Zero Trust Assessment.
        This cmdlet will import the necessary modules and establish connections based on the specified parameters.
 
    .PARAMETER UseDeviceCode
        If specified, the cmdlet will use the device code flow to authenticate to Graph and Azure.
        This will open a browser window to prompt for authentication and is useful for non-interactive sessions and on Windows when SSO is not desired.
 
    .PARAMETER Environment
        The environment to connect to. Default is Global.
 
    .PARAMETER UseTokenCache
        Uses Graph Powershell's cached authentication tokens.
 
    .PARAMETER TenantId
        The tenant ID to connect to. If not specified, the default tenant will be used.
 
    .PARAMETER ClientId
        If specified, connects using a custom application identity. See https://learn.microsoft.com/powershell/microsoftgraph/authentication-commands
 
    .PARAMETER Certificate
        The certificate to use for the connection(s).
        Use this to authenticate in Application mode, rather than in Delegate (user) mode.
        The application will need to be configured to have the matching Application scopes, compared to the Delegate scopes and may need to be added into roles.
        If this certificate is also used for connecting to Azure, it must come from a certificate store on the local computer.
 
    .PARAMETER SkipAzureConnection
        If specified, skips connecting to Azure and only connects to other services.
 
    .EXAMPLE
        PS C:\> Connect-ZtAssessment
 
        Connects to Microsoft Graph and other services using Connect-MgGraph with the required scopes and other services.
        By default, on Windows, this connects to Graph, Azure, Exchange Online, Security & Compliance, SharePoint Online, and Azure Information Protection.
        On other platforms, this connects to Graph, Azure, Exchange and Security & Compliance (where supported).
 
    .EXAMPLE
        PS C:\> Connect-ZtAssessment -UseDeviceCode
 
        Connects to Microsoft Graph and Azure using the device code flow. This will open a browser window to prompt for authentication.
 
    .EXAMPLE
        PS C:\> Connect-ZtAssessment -SkipAzureConnection
 
        Connects to services but skipping the Azure connection. The tests that require Azure connectivity will be skipped.
 
    .EXAMPLE
        PS C:\> Connect-ZtAssessment -ClientID $clientID -TenantID $tenantID -Certificate 'CN=ZeroTrustAssessment' -Service Graph,Azure
 
        Connects to Microsoft Graph and Azure using the specified client/application ID & tenant ID, using the latest, valid certificate available with the subject 'CN=ZeroTrustAssessment'.
        This assumes the correct scopes and permissions are assigned to the application used.
    #>

    [CmdletBinding()]
    param (
        [switch]
        $UseDeviceCode,

        [ValidateSet('China', 'Germany', 'Global', 'USGov', 'USGovDoD')]
        [string]
        $Environment = 'Global',

        [Parameter(DontShow)]
        [switch]
        $UseTokenCache, # Latest Graph module broke it...

        [string]
        $TenantId,

        [string]
        $ClientId,

        [PSFramework.Parameter.CertificateParameter]
        $Certificate,

        # The services to connect to such as Azure and ExchangeOnline. Default is All.
        [ValidateSet('All', 'Graph', 'Azure', 'AipService', 'ExchangeOnline', 'SecurityCompliance', 'SharePointOnline')]
        [string[]]$Service = 'All',

        # The Exchange environment to connect to. Default is O365Default. Supported values include O365China, O365Default, O365GermanyCloud, O365USGovDoD, O365USGovGCCHigh.
        [ValidateSet('O365China', 'O365Default', 'O365GermanyCloud', 'O365USGovDoD', 'O365USGovGCCHigh')]
        [string]$ExchangeEnvironmentName = 'O365Default',

        # The User Principal Name to use for Security & Compliance PowerShell connection.
        [string]$UserPrincipalName,

        # The SharePoint Admin URL to use for SharePoint Online connection.
        [string]$SharePointAdminUrl
    )

    if (-not (Test-ZtLanguageMode)) {
        Stop-PSFFunction -Message "PowerShell is running in Constrained Language Mode, which is not supported." -EnableException $true -Cmdlet $PSCmdlet
        return
    }

    if ($Service -contains 'All') {
        $Service = [string[]]@('Graph', 'Azure', 'AipService', 'ExchangeOnline', 'SecurityCompliance', 'SharePointOnline')
    }
    elseif ($Service -notcontains 'Graph') {
        $Service += 'Graph'
    }

    #TODO: UseDeviceCode does not work with ExchangeOnline

    #region Validate Services
    $Service = $Service | Select-Object -Unique
    $resolvedRequiredModules = Resolve-ZtServiceRequiredModule -Service $Service
    Write-Host -Object ('🔑 Authentication to {0}.' -f ($Service -join ', ')) -ForegroundColor DarkGray
    Write-Host -Object ('During the next steps, you may be prompted to authenticate separately for several services.') -ForegroundColor DarkGray
    $resolvedRequiredModules.ServiceAvailable.ForEach{
        Write-PSFMessage -Message ("Service '{0}' is available with its required modules:" -f $_) -Level Debug
        $resolvedRequiredModules.($_).Foreach{
            Write-PSFMessage -Message (" - {0} v{1}" -f $_.Name,$_.Version) -Level Debug
        }
    }

    $resolvedRequiredModules.ServiceUnavailable.ForEach{
        $serviceName = $_
        Write-Host -Object (' ⚠️ Service "{0}" is not available due to missing required modules: {1}.' -f $serviceName, ($resolvedRequiredModules.Errors.Where({ $_.Service -eq $serviceName }).ModuleSpecification -join ', ')) -ForegroundColor Yellow
    }

    #endregion

    # For services where their requiredModules are available, attempt to import and connect.
    # If errors occurs, mark them as service unavailable and continue with the rest, instead of stopping the entire connection process.
    # if the connection is successful, add them to service available (module scope).
    switch ($resolvedRequiredModules.ServiceAvailable) {
        'Graph' {
            Write-Host -Object "`nConnecting to Microsoft Graph" -ForegroundColor Cyan
            Write-PSFMessage -Message 'Connecting to Microsoft Graph' -Level Verbose
            try {
                Write-PSFMessage -Message ('Loading graph required modules: {0}' -f ($resolvedRequiredModules.Graph.Name -join ', ')) -Level Verbose
                $loadedGraphModules = $resolvedRequiredModules.Graph.ForEach{
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru
                }

                $loadedGraphModules.ForEach{
                    Write-Debug -Message ('Module ''{0}'' v{1} loaded for Graph.' -f $_.Name, $_.Version)
                }

                $connectMgGraphParams = @{
                    NoWelcome = $true
                    UseDeviceCode = $UseDeviceCode.IsPresent
                    Environment = $Environment
                    # TenantId = $TenantId
                    # ClientId = $ClientId
                }

                if ($ClientId) {
                    $connectMgGraphParams.ClientId = $ClientId
                }

                if ($TenantId) {
                    $connectMgGraphParams.TenantId = $TenantId
                }

                if ($Certificate) {
                    $connectMgGraphParams.Certificate = $Certificate
                }
                else {
                    $connectMgGraphParams.Scopes = Get-ZtGraphScope
                }

                if (-not $UseTokenCache) {
                    $connectMgGraphParams.ContextScope = 'Process'
                }

                Write-PSFMessage -Message "Connecting to Microsoft Graph with params: $($connectMgGraphParams | Out-String)" -Level Verbose
                $null = Connect-MgGraph @connectMgGraphParams -ErrorAction Stop -InformationAction SilentlyContinue
                $contextTenantId = (Get-MgContext).TenantId
                Write-Host -Object " ✅ Connected" -ForegroundColor Green
                Add-ZtConnectedService -Service 'Graph'
            }
            catch {
                $graphException = $_
                Write-PSFMessage -Message ("Failed to authenticate to Graph: {0}" -f $graphException.Message) -Level Error -ErrorRecord $_
                # Remove service from the connected list.
                Remove-ZtConnectedService -Service 'Graph'
                Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Microsoft Graph cannot be executed." -ForegroundColor Yellow
                Write-Host -Object " Graph is critical to the ZeroTrustAssessment report. Aborting." -ForegroundColor Yellow
                $methodNotFound = $null
                if ($graphException.Exception.InnerException -is [System.MissingMethodException]) {
                    $methodNotFound = $graphException.Exception.InnerException
                }
                elseif ($graphException.Exception -is [System.MissingMethodException]) {
                    $methodNotFound = $graphException.Exception
                }

                if ($methodNotFound -and $methodNotFound.Message -like '*Microsoft.Identity*') {
                    Write-Warning -Message "DLL conflict detected (MissingMethodException in Microsoft.Identity). This typically occurs when incompatible versions of Microsoft.Identity.Client or Microsoft.IdentityModel.Abstractions are loaded."
                    Write-Warning -Message "Please RESTART your PowerShell session and run Connect-ZtAssessment again, ensuring no other Microsoft modules are imported first."
                }

                Stop-PSFFunction -Message "Failed to authenticate to Graph. The requirements for the ZeroTrustAssessment are not met by the established session:`n$graphException" -ErrorRecord $graphException -EnableException $true -Cmdlet $PSCmdlet
            }

            try {
                if ($script:ConnectedService -contains 'Graph') {
                    Write-PSFMessage -Message "Verifying Graph connection and permissions..." -Level Debug
                    $null = Test-ZtContext
                    Write-PSFMessage -Message "Ok." -Level Debug
                }
            }
            catch {
                Remove-ZtConnectedService -Service 'Graph'
                Stop-PSFFunction -Message "Authenticated to Graph, but the requirements for the ZeroTrustAssessment are not met by the established session:`n$_" -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet
            }
        }

        'Azure' {
            Write-Host -Object "`nConnecting to Azure" -ForegroundColor Cyan
            Write-PSFMessage -Message 'Connecting to Azure' -Level Verbose
            try {
                Write-PSFMessage -Message ('Loading Azure required modules: {0}' -f ($resolvedRequiredModules.Azure.Name -join ', ')) -Level Verbose
                $loadedAzureModules = $resolvedRequiredModules.Azure.ForEach{
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru
                }

                $loadedAzureModules.ForEach{
                    Write-Debug -Message ('Module ''{0}'' v{1} loaded for Azure.' -f $_.Name, $_.Version)
                }

                $azEnvironment = 'AzureCloud'
                if ($Environment -eq 'China') {
                    $azEnvironment = Get-AzEnvironment -Name AzureChinaCloud
                }
                elseif ($Environment -in 'USGov', 'USGovDoD') {
                    $azEnvironment = 'AzureUSGovernment'
                }

                $tenantParam = $TenantId
                if (-not $tenantParam) {
                    if ($contextTenantId) {
                        $tenantParam = $contextTenantId
                    }
                }

                $azParams = @{
                    UseDeviceAuthentication = $UseDeviceCode
                    Environment             = $azEnvironment
                    # Tenant = $tenantParam
                }

                if ($tenantParam) {
                    Write-Verbose -Message ("Using tenant ID '{0}' for Azure connection." -f $tenantParam)
                    $azParams.Tenant = $tenantParam
                }

                if ($ClientId -and $Certificate) {
                    $azParams.ApplicationId = $ClientId
                    $azParams.CertificateThumbprint = $Certificate.Certificate.Thumbprint
                }

                Write-Verbose -Message ("Connecting to Azure with parameters: {0}" -f ($azParams | Out-String))
                $null = Connect-AzAccount @azParams -ErrorAction Stop -InformationAction Ignore
                Write-Host -Object " ✅ Connected" -ForegroundColor Green
                Add-ZtConnectedService -Service 'Azure'
            }
            catch {
                Write-PSFMessage -Message ("Failed to authenticate to Azure: {0}" -f $_) -Level Debug -ErrorRecord $_
                Remove-ZtConnectedService -Service 'Azure'
                Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Azure will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_) -ForegroundColor Red
            }
        }

        'AipService' {
            Write-Host -Object "`nConnecting to Azure Information Protection" -ForegroundColor Cyan
            Write-PSFMessage -Message 'Connecting to Azure Information Protection' -Level Verbose
            $aipServiceModuleLoaded = $false
            try {
                Write-PSFMessage -Message ('Loading Azure Information Protection required modules: {0}' -f ($resolvedRequiredModules.AipService.Name -join ', ')) -Level Verbose
                $loadedAipServiceModules = $resolvedRequiredModules.AipService.ForEach{
                    #TODO: only add -UseWindowsPowerShell for the modules in WindowsRequiredModules based on module manifest.
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru -UseWindowsPowerShell -WarningAction SilentlyContinue
                }

                $loadedAipServiceModules.ForEach{
                    Write-Debug -Message ('Module ''{0}'' v{1} loaded for Azure Information Protection.' -f $_.Name, $_.Version)
                }

                $aipServiceModuleLoaded = $true
            }
            catch {
                Write-Host -Object " ❌ Failed to load Azure Information Protection modules." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Azure Information Protection will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_) -ForegroundColor Red
                Write-PSFMessage -Message ("Error loading AipService Module in WindowsPowerShell: {0}" -f $_) -Level Debug -ErrorRecord $_
                # Mark service as unavailable and skip connection attempt.
                Remove-ZtConnectedService -Service 'AipService'
            }

            try {
                if ($aipServiceModuleLoaded) {
                    Write-PSFMessage -Message "Connecting to Azure Information Protection" -Level Verbose
                    # Connect-AipService does not have parameters for non-interactive auth, so it will use the existing Graph connection context if available, or prompt if not.
                    $null = Connect-AipService -ErrorAction Stop
                    Write-Host -Object " ✅ Connected" -ForegroundColor Green
                    Add-ZtConnectedService -Service 'AipService'
                }
            }
            catch {
                Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Azure Information Protection will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_) -ForegroundColor Red
                Write-PSFMessage -Message ("Failed to connect to Azure Information Protection: {0}" -f $_) -Level Debug -ErrorRecord $_
                # Mark service as unavailable.
                Remove-ZtConnectedService -Service 'AipService'
            }
        }

        'ExchangeOnline' {
            Write-Host -Object "`nConnecting to Exchange Online" -ForegroundColor Cyan
            try {
                Write-PSFMessage -Message ('Loading Exchange Online required modules: {0}' -f ($resolvedRequiredModules.ExchangeOnline.Name -join ', ')) -Level Verbose
                $loadedExoModules = $resolvedRequiredModules.ExchangeOnline.ForEach{
                    #TODO: only add -UseWindowsPowerShell for the modules in WindowsRequiredModules based on module manifest.
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru -WarningAction SilentlyContinue
                }

                $loadedExoModules.ForEach{
                    Write-Debug -Message ('Module ''{0}'' v{1} loaded for Exchange Online.' -f $_.Name, $_.Version)
                }

                Write-Verbose -Message 'Connecting to Microsoft Exchange Online'
                if ($UseDeviceCode) {
                    $null = Connect-ExchangeOnline -ShowBanner:$false -Device:$UseDeviceCode -ExchangeEnvironmentName $ExchangeEnvironmentName -ErrorAction Stop -InformationAction Ignore
                }
                else {
                    $null = Connect-ExchangeOnline -ShowBanner:$false -ExchangeEnvironmentName $ExchangeEnvironmentName -ErrorAction Stop -InformationAction Ignore
                }

                # Fix for Get-Label visibility in other scopes
                if (Get-Command -Name Get-Label -ErrorAction Ignore) {
                    $module = Get-Command -Name Get-Label | Select-Object -ExpandProperty Module
                    if ($module -and $module.Name -like 'tmp_*') {
                        Import-Module $module -Global #-Force
                    }
                }

                Write-Host -Object " ✅ Connected" -ForegroundColor Green
                Add-ZtConnectedService -Service 'ExchangeOnline'
            }
            catch {
                Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Exchange Online will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_) -ForegroundColor Red
                Write-PSFMessage -Message ("Failed to connect to Exchange Online: {0}" -f $_) -Level Debug -ErrorRecord $_
                Remove-ZtConnectedService -Service 'ExchangeOnline'
            }
        }

        'SecurityCompliance' {
            Write-Host -Object "`nConnecting to Microsoft Security & Compliance PowerShell" -ForegroundColor Cyan
            $Environments = @{
                'O365China'        = @{
                    ConnectionUri    = 'https://ps.compliance.protection.partner.outlook.cn/powershell-liveid'
                    AuthZEndpointUri = 'https://login.chinacloudapi.cn/common'
                }
                'O365GermanyCloud' = @{
                    ConnectionUri    = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
                    AuthZEndpointUri = 'https://login.microsoftonline.com/common'
                }
                'O365Default'      = @{
                    ConnectionUri    = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
                    AuthZEndpointUri = 'https://login.microsoftonline.com/common'
                }
                'O365USGovGCCHigh' = @{
                    ConnectionUri    = 'https://ps.compliance.protection.office365.us/powershell-liveid/'
                    AuthZEndpointUri = 'https://login.microsoftonline.us/common'
                }
                'O365USGovDoD'     = @{
                    ConnectionUri    = 'https://l5.ps.compliance.protection.office365.us/powershell-liveid/'
                    AuthZEndpointUri = 'https://login.microsoftonline.us/common'
                }
                Default            = @{
                    ConnectionUri    = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
                    AuthZEndpointUri = 'https://login.microsoftonline.com/common'
                }
            }

            $exoSnCModulesLoaded = $false
            try {
                $loadedExoSnCModules = $resolvedRequiredModules.SecurityCompliance.ForEach{
                    #TODO: only add -UseWindowsPowerShell for the modules in WindowsRequiredModules based on module manifest.
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru -WarningAction SilentlyContinue
                }

                $loadedExoSnCModules.ForEach{
                    Write-PSFMessage -Message ('Module ''{0}'' v{1} loaded for Security & Compliance.' -f $_.Name, $_.Version) -Level Debug
                }

                $exoSnCModulesLoaded = $true
            }
            catch {
                Write-Host -Object " ❌ Failed to load required modules for Security & Compliance." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring Security & Compliance will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_) -ForegroundColor Red
                Remove-ZtConnectedService -Service 'SecurityCompliance'
                Write-PSFMessage -Message "Failed to load required modules for Security & Compliance: $_" -Level Debug -ErrorRecord $_
            }

            if ($UseDeviceCode) {
                Write-Host -Object "`nThe Security & Compliance module does not support device code flow authentication." -ForegroundColor Red
            }
            elseif ($exoSnCModulesLoaded) {
                try {
                    # Get UPN from Exchange connection or Graph context
                    #TODO: is that a nice to have or a hard dependency?
                    $ExoUPN = $UserPrincipalName

                    # Attempt to resolve UPN before any connection to avoid token acquisition failures without identity
                    $connectionInformation = $null
                    try {
                        $connectionInformation = Get-ConnectionInformation
                    }
                    catch {
                        # Intentionally swallow errors here; fall back to provided UPN if any
                        $connectionInfoError = $_
                        Write-Verbose -Message "Get-ConnectionInformation failed; falling back to provided UserPrincipalName if available. Error: $($connectionInfoError.Exception.Message)"
                    }

                    if (-not $ExoUPN) {
                        $ExoUPN = $connectionInformation | Where-Object { $_.IsEopSession -ne $true -and $_.State -eq 'Connected' } | Select-Object -ExpandProperty UserPrincipalName -First 1 -ErrorAction SilentlyContinue
                    }

                    if (-not $ExoUPN) {
                        throw "`nUnable to determine a UserPrincipalName for Security & Compliance. Please supply -UserPrincipalName or connect to Exchange Online first."
                    }

                    $ippSessionParams = @{
                        BypassMailboxAnchoring = $true
                        UserPrincipalName      = $ExoUPN
                        ShowBanner             = $false
                        ErrorAction            = 'Stop'
                    }

                    # Only override endpoints for non-default clouds to reduce token acquisition failures in Default
                    if ($ExchangeEnvironmentName -ne 'O365Default') {
                        $ippSessionParams.ConnectionUri = $Environments[$ExchangeEnvironmentName].ConnectionUri
                        $ippSessionParams.AzureADAuthorizationEndpointUri = $Environments[$ExchangeEnvironmentName].AuthZEndpointUri
                    }

                    Write-Verbose -Message "Connecting to Security & Compliance with UPN: $ExoUPN"
                    Connect-IPPSSession @ippSessionParams
                    Write-Host -Object " ✅ Connected" -ForegroundColor Green

                    # Fix for Get-Label visibility in other scopes
                    if (Get-Command -Name Get-Label -ErrorAction Ignore) {
                        $module = Get-Command -Name Get-Label | Select-Object -ExpandProperty Module
                        if ($module -and $module.Name -like 'tmp_*') {
                            Import-Module $module -Global #-Force
                        }
                    }

                    Add-ZtConnectedService -Service 'SecurityCompliance'
                }
                catch {
                    Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                    Write-Host -Object " Tests requiring Security & Compliance will be skipped." -ForegroundColor Yellow
                    Write-Host -Object (" Error details: {0}" -f $_.Exception.Message) -ForegroundColor Red
                    Write-PSFMessage -Message ("Failed to connect to Security & Compliance PowerShell: {0}" -f $_.Exception.Message) -Level Debug -ErrorRecord $_

                    Remove-ZtConnectedService -Service 'SecurityCompliance'
                    $exception = $_
                    $methodNotFoundException = $null

                    # Detect DLL conflict via a specific MissingMethodException, preferring the inner exception when present
                    if ($exception.Exception.InnerException -is [System.MissingMethodException]) {
                        $methodNotFoundException = $exception.Exception.InnerException
                    }
                    elseif ($exception.Exception -is [System.MissingMethodException]) {
                        $methodNotFoundException = $exception.Exception
                    }

                    if ($methodNotFoundException -and $methodNotFoundException.Message -like "*Microsoft.Identity.Client*") {
                        Write-Warning "DLL Conflict detected (Method not found in Microsoft.Identity.Client). This usually happens if Microsoft.Graph is loaded before ExchangeOnlineManagement."
                        Write-Warning "Please RESTART your PowerShell session and run Connect-ZtAssessment again."
                    }

                }
            }
        }

        'SharePointOnline' {
            Write-Host -Object "`nConnecting to SharePoint Online" -ForegroundColor Cyan
            try {
                Write-PSFMessage -Message ('Loading SharePoint Online required modules: {0}' -f ($resolvedRequiredModules.SharePointOnline.Name -join ', ')) -Level Verbose
                $loadedSharePointOnlineModules = $resolvedRequiredModules.SharePointOnline.ForEach{
                    #TODO: only add -UseWindowsPowerShell for the modules in WindowsRequiredModules based on module manifest.
                    $_ | Import-Module -Global -ErrorAction Stop -PassThru -UseWindowsPowerShell -WarningAction SilentlyContinue
                    # Import-Module Microsoft.Online.SharePoint.PowerShell -UseWindowsPowerShell -WarningAction SilentlyContinue -ErrorAction Stop -Global
                }

                $loadedSharePointOnlineModules.ForEach{
                    Write-Debug -Message ('Module ''{0}'' v{1} loaded for SharePoint Online.' -f $_.Name, $_.Version)
                }
            }
            catch {
                Write-Host -Object " ❌ Failed to load required modules for SharePoint Online." -ForegroundColor Yellow
                Write-Host -Object " Tests requiring SharePoint Online will be skipped." -ForegroundColor Yellow
                Write-Host -Object (" Error details: {0}" -f $_.Exception.Message) -ForegroundColor Red
                Write-PSFMessage -Message ("Failed to load required modules for SharePoint Online: {0}" -f $_) -Level Debug -ErrorRecord $_
                # Mark service as unavailable
                Remove-ZtConnectedService -Service 'SharePointOnline'
                continue
            }

            $adminUrl = $SharePointAdminUrl
            if (-not $adminUrl) {
                # Try to infer from Graph context
                if ($contextTenantId) {
                    try {
                        $org = Invoke-ZtGraphRequest -RelativeUri 'organization'
                        $initialDomain = $org.verifiedDomains | Where-Object { $_.isInitial } | Select-Object -ExpandProperty name -First 1
                        if ($initialDomain) {
                            $tenantName = $initialDomain.Split('.')[0]
                            $adminUrl = "https://$tenantName-admin.sharepoint.com"
                            Write-Verbose -Message "Inferred SharePoint Admin URL: $adminUrl"
                        }
                    }
                    catch {
                        Write-Verbose -Message "Failed to infer SharePoint Admin URL from Graph: $_"
                    }
                }
            }

            if (-not $adminUrl) {
                Write-Host -Object "SharePoint Admin URL not provided and could not be inferred. Skipping SharePoint connection." -ForegroundColor Yellow
                Write-PSFMessage -Message "SharePoint Admin URL not provided and could not be inferred. Skipping SharePoint connection." -Level Warning
                Remove-ZtConnectedService -Service 'SharePointOnline'
            }
            else {
                try {
                    Connect-SPOService -Url $adminUrl -ErrorAction Stop
                    Write-Host -Object " ✅ Connected" -ForegroundColor Green
                    Add-ZtConnectedService -Service 'SharePointOnline'
                }
                catch {
                    Write-PSFMessage -Message ('Failed to connect to SharePoint Online: {0}' -f $_.Exception.Message) -Level Debug -ErrorRecord $_
                    Write-Host -Object " ❌ Failed to connect." -ForegroundColor Yellow
                    Write-Host -Object " Tests requiring SharePoint Online will be skipped." -ForegroundColor Yellow
                    Write-Host -Object (" Error details: {0}" -f $_.Exception.Message) -ForegroundColor Red
                    Remove-ZtConnectedService -Service 'SharePointOnline'
                }
            }
        }
    }
}