Private/Graph.ps1

function Resolve-AgentIdUri {
    param([Parameter(Mandatory)][string]$Uri)
    if ($Uri -match '^https?://') { return $Uri }
    # Relative URIs let Invoke-MgGraphRequest resolve the endpoint for the connected cloud (e.g. national clouds).
    return "$script:AgentIdGraphVersion/$($Uri.TrimStart('/'))"
}

function Invoke-AgentIdRawRequest {
    # The single point where the module talks to Microsoft Graph. Tests replace it by setting
    # $script:AgentIdGraphHandler to a scriptblock that takes (Uri, Headers).
    param(
        [Parameter(Mandatory)][string]$Uri,
        [hashtable]$Headers
    )
    if ($script:AgentIdGraphHandler) {
        return & $script:AgentIdGraphHandler $Uri $Headers
    }
    $params = @{ Method = 'GET'; Uri = $Uri; OutputType = 'PSObject'; ErrorAction = 'Stop' }
    if ($Headers) { $params.Headers = $Headers }
    Invoke-MgGraphRequest @params
}

function Get-AgentIdErrorStatus {
    # Classifies a failed Graph call:
    # Denied - 401/403: the signed-in identity lacks a permission or role
    # NotSupported - 400/404: endpoint, cast or query option not available in this tenant/API version
    # Error - anything else (throttling after SDK retries, service errors, network)
    param([Parameter(Mandatory)]$ErrorRecord)

    if ($ErrorRecord.TargetObject -is [pscustomobject] -and $ErrorRecord.TargetObject.PSObject.Properties['AgentIdStatus']) {
        return $ErrorRecord.TargetObject   # already classified by Invoke-AgentIdGraphRequest
    }

    $code = $null
    try {
        $response = $ErrorRecord.Exception.Response
        if ($response -and $response.StatusCode) { $code = [int]$response.StatusCode }
    } catch { $code = $null }

    $message = @($ErrorRecord.Exception.Message, $ErrorRecord.ErrorDetails.Message) -join ' '
    if (-not $code) {
        switch -Regex ($message) {
            '\b403\b|Forbidden|Authorization_RequestDenied|Insufficient privileges' { $code = 403; break }
            '\b401\b|Unauthorized'                                                   { $code = 401; break }
            '\b404\b|NotFound|Request_ResourceNotFound'                              { $code = 404; break }
            '\b400\b|BadRequest|Request_BadRequest|Request_UnsupportedQuery'         { $code = 400; break }
        }
    }

    $status = switch ($code) {
        401 { 'Denied' }
        403 { 'Denied' }
        400 { 'NotSupported' }
        404 { 'NotSupported' }
        default { 'Error' }
    }
    $short = ($message -replace '\s+', ' ').Trim()
    if ($short.Length -gt 300) { $short = $short.Substring(0, 300) + '...' }

    [pscustomobject]@{
        AgentIdStatus = $status
        StatusCode    = $code
        Message       = $short
    }
}

function Invoke-AgentIdGraphRequest {
    <#
    Sends a GET request to Microsoft Graph.
    -Uri accepts several candidate URIs; each is tried in order, moving to the next only when the previous one
    is NotSupported (400/404) - used where the documented shape of a request is uncertain. A Denied or Error
    result stops immediately, since another spelling of the same request won't fix a missing permission.
    -All follows @odata.nextLink and writes the items of every page to the pipeline; callers wrap the call
    in @() so that an empty result is an empty array rather than $null.
    Failures are re-thrown as an ErrorRecord whose TargetObject carries the classification.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$Uri,
        [switch]$All,
        [hashtable]$Headers
    )

    $failure = $null
    foreach ($candidate in $Uri) {
        try {
            $response = Invoke-AgentIdRawRequest -Uri (Resolve-AgentIdUri $candidate) -Headers $Headers
            if (-not $All) { return $response }

            $items = [System.Collections.Generic.List[object]]::new()
            while ($true) {
                foreach ($item in @($response.value)) {
                    if ($null -ne $item) { $items.Add($item) }
                }
                $next = $response.'@odata.nextLink'
                if (-not $next) { break }
                $response = Invoke-AgentIdRawRequest -Uri $next -Headers $Headers
            }
            return $items.ToArray()
        } catch {
            $failure = Get-AgentIdErrorStatus $_
            Write-Verbose "GET $candidate -> $($failure.AgentIdStatus) ($($failure.StatusCode)): $($failure.Message)"
            if ($failure.AgentIdStatus -ne 'NotSupported') { break }
        }
    }

    $exception = [System.Exception]::new("Microsoft Graph request failed ($($failure.AgentIdStatus)): $($failure.Message)")
    throw [System.Management.Automation.ErrorRecord]::new($exception, "AgentIdGraph.$($failure.AgentIdStatus)", 'NotSpecified', $failure)
}

function Test-AgentIdConnection {
    if ($script:AgentIdGraphHandler) { return }
    if (-not (Get-Command Get-MgContext -ErrorAction SilentlyContinue)) {
        throw 'The Microsoft.Graph.Authentication module is required. Install it with: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
    }
    $context = Get-MgContext
    if (-not $context) {
        throw 'Not connected to Microsoft Graph. Run Connect-AgentIdAudit first (or Connect-MgGraph with the scopes listed in the README).'
    }
    if ($context.AuthType -eq 'Delegated' -and $context.Scopes) {
        $missing = $script:AgentIdCoreScopes | Where-Object { $_ -notin $context.Scopes }
        if ($missing) {
            Write-Warning ("The current Graph connection is missing scopes: {0}. Checks that depend on them will be reported as not evaluated." -f ($missing -join ', '))
        }
    }
}