Src/Private/Helpers.ps1
|
function Test-UserPrincipalName { <# .SYNOPSIS Validates that a string is a properly formatted User Principal Name. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [string]$UserPrincipalName ) return ($UserPrincipalName -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') } function Write-TranscriptLog { <# .SYNOPSIS Writes a structured log entry to the host and optionally to a transcript file. Level values: DEBUG | INFO | SUCCESS | WARNING | ERROR #> [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0)] [string]$Message, [Parameter(Position = 1)] [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')] [string]$Level = 'INFO', [Parameter(Position = 2)] [string]$Category = 'GENERAL' ) $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $Entry = "[$Timestamp] [$Level] [$Category] $Message" $null = switch ($Level) { 'DEBUG' { Write-PScriboMessage -Message $Entry } 'INFO' { Write-Host " $Entry" } 'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green } 'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry } 'ERROR' { Write-Host " $Entry" -ForegroundColor Red } } if ($script:TranscriptLogPath) { try { Add-Content -Path $script:TranscriptLogPath -Value $Entry -ErrorAction SilentlyContinue | Out-Null } catch { } } } function Invoke-WithRetry { <# .SYNOPSIS Executes a ScriptBlock with configurable retry logic and exponential back-off. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [scriptblock]$ScriptBlock, [Parameter(Mandatory)] [string]$OperationName, [int]$MaxAttempts = 3, [int]$DelaySeconds = 5 ) $Attempt = 0 $CurrentDelay = $DelaySeconds while ($Attempt -lt $MaxAttempts) { $Attempt++ try { Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts}: $OperationName" 'DEBUG' 'RETRY' $null = (& $ScriptBlock) Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} succeeded: $OperationName" 'DEBUG' 'RETRY' return } catch { if ($Attempt -lt $MaxAttempts) { Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} failed for '$OperationName'. Retrying in ${CurrentDelay}s... Error: $($_.Exception.Message)" 'WARNING' 'RETRY' Start-Sleep -Seconds $CurrentDelay $CurrentDelay = $CurrentDelay * 2 } else { Write-TranscriptLog "All ${MaxAttempts} attempts failed for '$OperationName'. Error: $($_.Exception.Message)" 'ERROR' 'RETRY' throw } } } } function Show-AbrDebugExecutionTime { <# .SYNOPSIS Records section start/stop timing for debug output. Compatible shim -- outputs timing to debug log if enabled. #> [CmdletBinding()] param( [switch]$Start, [switch]$End, [string]$TitleMessage = 'Section' ) if ($Start) { $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew() Write-AbrDebugLog "[$TitleMessage] Started" 'DEBUG' 'TIMER' } if ($End) { if ($script:SectionTimers.ContainsKey($TitleMessage)) { $sw = $script:SectionTimers[$TitleMessage] $sw.Stop() Write-AbrDebugLog "[$TitleMessage] Completed in $($sw.Elapsed.TotalSeconds.ToString('F2'))s" 'DEBUG' 'TIMER' } } } function ConvertTo-HashToYN { <# .SYNOPSIS Converts boolean values in a hashtable/ordered-dictionary to Yes/No strings. #> [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [System.Collections.IDictionary]$Hash ) process { $Result = [ordered]@{} foreach ($Key in $Hash.Keys) { $Value = $Hash[$Key] if ($Value -is [bool]) { $Result[$Key] = if ($Value) { 'Yes' } else { 'No' } } elseif ($null -eq $Value -or $Value -eq '') { $Result[$Key] = '--' } else { $Result[$Key] = $Value } } return $Result } } function Export-SharePointToExcel { <# .SYNOPSIS Exports one or more arrays of PSCustomObjects to a multi-sheet Excel workbook. .DESCRIPTION Uses the ImportExcel module to generate a formatted .xlsx report. Each key in the Sheets hashtable becomes a worksheet tab. Falls back to CSV if ImportExcel is not installed. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary]$Sheets, [Parameter(Mandatory)] [string]$Path, [string]$TenantId = 'Unknown Tenant' ) if (-not (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue)) { Write-Warning "ImportExcel module is not installed. Run 'Install-Module -Name ImportExcel -Force' to enable Excel exports." Write-Warning "Falling back to CSV export..." foreach ($SheetName in $Sheets.Keys) { $CsvPath = $Path -replace '\.xlsx$', "_$($SheetName -replace '\s','_').csv" $Sheets[$SheetName] | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 Write-Host " - CSV exported: $CsvPath" -ForegroundColor Cyan } return } Import-Module ImportExcel -ErrorAction Stop if (Test-Path $Path) { Remove-Item $Path -Force } $SheetIndex = 0 foreach ($SheetName in $Sheets.Keys) { $Data = $Sheets[$SheetName] if (-not $Data -or @($Data).Count -eq 0) { Write-TranscriptLog "Sheet '$SheetName' has no data -- skipping." 'WARNING' 'EXPORT' continue } $ExcelParams = @{ Path = $Path WorksheetName = $SheetName AutoSize = $true AutoFilter = $true FreezeTopRow = $true BoldTopRow = $true TableStyle = 'Medium9' Append = ($SheetIndex -gt 0) PassThru = $false } $Data | Export-Excel @ExcelParams $SheetIndex++ Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)." -ForegroundColor Cyan } Write-Host " - Excel report saved to: $Path" -ForegroundColor Green Write-TranscriptLog "Excel export complete: $Path" 'SUCCESS' 'EXPORT' } function Write-AbrSectionError { <# .SYNOPSIS Writes a standardised error paragraph inside a PScribo section. Prevents empty sections that crash the Word exporter. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Section, [Parameter(Mandatory)][string]$Message ) Write-PScriboMessage -IsWarning "[$Section] $Message" Paragraph " [!] Unable to retrieve $Section data: $Message" Write-TranscriptLog "Section error [$Section]: $Message" 'ERROR' $Section if ($script:ErrorLogPath) { try { $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [ERROR] [$Section] $Message" Add-Content -Path $script:ErrorLogPath -Value $entry -ErrorAction SilentlyContinue } catch {} } } function Write-AbrDebugLog { <# .SYNOPSIS Writes a structured debug log entry (no-op if DebugLog is disabled). #> [CmdletBinding()] param ( [string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG', 'SUCCESS')][string]$Level = 'INFO', [string]$Section = 'GENERAL' ) # Forward to the script-scoped function defined in Invoke function if it exists # This stub ensures Private functions can call it without knowing if it's defined yet $Entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] [$Level] [$Section] $Message" if ($script:DebugLogEnabled) { $null = $script:DebugLogEntries.Add($Entry) switch ($Level) { 'ERROR' { Write-Host " [DBG-ERR] $Entry" -ForegroundColor Red } 'WARN' { Write-Host " [DBG-WRN] $Entry" -ForegroundColor Yellow } 'SUCCESS' { Write-Host " [DBG] $Entry" -ForegroundColor Green } default { Write-Host " [DBG] $Entry" -ForegroundColor DarkGray } } } } function Write-ReportModuleInfo { <# .SYNOPSIS Writes the module header banner to console. #> [CmdletBinding()] param([string]$ModuleName = 'Microsoft.SharePoint') Write-Host "" Write-Host " AsBuiltReport.Microsoft.SharePoint" -ForegroundColor Cyan Write-Host " SharePoint Online & OneDrive for Business As-Built Report" -ForegroundColor Cyan Write-Host "" } function ConvertTo-SPEnumString { <# .SYNOPSIS Safely converts a SharePoint/PnP enum value to a string. Guards against null values which cause "You cannot call a method on a null-valued expression" errors when calling .ToString() directly on PnP v3 enum properties. .PARAMETER Value The enum or object to convert. May be $null. .PARAMETER Default The string to return when Value is null or empty. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Position = 0)] $Value, [Parameter(Position = 1)] [string]$Default = 'Unknown' ) if ($null -eq $Value) { return $Default } $str = "$Value".Trim() if ($str -eq '' -or $str -eq '0') { return $Default } return $str } function Invoke-SPGraphRequest { <# .SYNOPSIS Makes a Microsoft Graph REST API call without using the Microsoft.Graph SDK. .DESCRIPTION PnP.PowerShell v3 loads Microsoft.Graph.Core v1.25.1 into the AppDomain when it connects. This conflicts with Graph SDK v2.x (which needs Core v3.x) and breaks ALL Microsoft.Graph.* cmdlets after PnP connects, including Get-MgOrganization, Get-MgSubscribedSku, and Invoke-MgGraphRequest. This function bypasses the Graph SDK entirely using PowerShell's built-in Invoke-RestMethod with a Bearer token obtained from PnP's own auth stack. Token acquisition is attempted in this order: 1. Get-PnPAccessToken -ResourceTypeName Graph (PnP v3 standard) 2. Get-PnPAccessToken (no param, some builds) 3. Get-PnPGraphAccessToken (PnP v2, removed in some v3 builds) 4. PnP connection context MSAL token #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [string]$Method = 'GET' ) $BaseUrl = 'https://graph.microsoft.com' $FullUri = if ($Uri -like 'https://*') { $Uri } else { "$BaseUrl/$($Uri.TrimStart('/'))" } #region Obtain access token $Token = $null # Method 1: Get-PnPAccessToken -ResourceTypeName Graph (PnP v3 preferred) if (-not $Token) { try { $Result = Get-PnPAccessToken -ResourceTypeName Graph -ErrorAction Stop $Token = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { "$Result" } } catch { Write-Verbose "Invoke-SPGraphRequest[1]: $($_.Exception.Message)" } } # Method 2: Get-PnPAccessToken (no ResourceTypeName -- returns SPO token in some builds, # but Graph in others when the app has Graph permissions) if (-not $Token) { try { $Result = Get-PnPAccessToken -ErrorAction Stop $Candidate = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { "$Result" } # Only use if it looks like a JWT (starts with 'ey') if ($Candidate -and $Candidate.StartsWith('ey')) { $Token = $Candidate } } catch { Write-Verbose "Invoke-SPGraphRequest[2]: $($_.Exception.Message)" } } # Method 3: Get-PnPGraphAccessToken (PnP v2 / some v3 builds) if (-not $Token) { try { $Result = Get-PnPGraphAccessToken -ErrorAction Stop $Token = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { $null } } catch { Write-Verbose "Invoke-SPGraphRequest[3]: $($_.Exception.Message)" } } # Method 4: Pull raw token from PnP connection context's MSAL provider if (-not $Token) { try { $Conn = Get-PnPConnection -ErrorAction Stop if ($Conn -and $Conn.Context) { # Try GetAccessTokenAsync if it exists on the context object $ContextType = $Conn.Context.GetType() $Method4 = $ContextType.GetMethod('GetAccessTokenAsync', [type[]]@([string[]])) if ($Method4) { $Task = $Method4.Invoke($Conn.Context, @(, @('https://graph.microsoft.com/.default'))) $Token = $Task.GetAwaiter().GetResult() } } } catch { Write-Verbose "Invoke-SPGraphRequest[4]: $($_.Exception.Message)" } } if (-not $Token -or $Token.Length -lt 20) { throw "Invoke-SPGraphRequest: Could not obtain a Graph access token from PnP. Tried 4 methods. URI: $FullUri" } #endregion $Headers = @{ 'Authorization' = "Bearer $Token" 'Content-Type' = 'application/json' 'Accept' = 'application/json' } try { return Invoke-RestMethod -Uri $FullUri -Method $Method -Headers $Headers -ErrorAction Stop } catch { # Surface the HTTP status code if available $StatusCode = $_.Exception.Response.StatusCode.value__ $ErrMsg = if ($StatusCode) { "HTTP $StatusCode -- $($_.Exception.Message)" } else { $_.Exception.Message } throw "Invoke-SPGraphRequest: REST call failed for '$FullUri': $ErrMsg" } } function ConvertTo-SPEnumString { <# .SYNOPSIS Safely converts a SharePoint/PnP enum value to a string. Guards against null values which cause "You cannot call a method on a null-valued expression" errors when calling .ToString() directly on PnP v3 enum properties. .PARAMETER Value The enum or object to convert. May be $null. .PARAMETER Default The string to return when Value is null or empty. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Position = 0)] $Value, [Parameter(Position = 1)] [string]$Default = 'Unknown' ) if ($null -eq $Value) { return $Default } $str = "$Value".Trim() if ($str -eq '' -or $str -eq '0') { return $Default } return $str } function Invoke-SPGraphRequest { <# .SYNOPSIS Makes a Microsoft Graph REST API call using PnP's access token. .DESCRIPTION This function is the ONLY safe way to call Graph APIs in this module when PnP.PowerShell v3 is loaded. PnP v3 loads Microsoft.Graph.Core v1.25.1 into the AppDomain which conflicts with Graph SDK v2.x (needs Core v3.x). After PnP connects, ALL Microsoft.Graph.* cmdlets (including Get-MgOrganization, Get-MgSubscribedSku, Invoke-MgGraphRequest) throw: "Could not load type 'AzureIdentityAccessTokenProvider' from assembly 'Microsoft.Graph.Core, Version=1.25.1.0'" This function bypasses the Graph SDK entirely by: 1. Obtaining a Bearer token from PnP via Get-PnPGraphAccessToken 2. Calling Graph REST directly with PowerShell's built-in Invoke-RestMethod .PARAMETER Uri The Graph API URI (relative path like '/v1.0/organization' or full URL). .PARAMETER Method HTTP method. Default: GET. .EXAMPLE $org = Invoke-SPGraphRequest '/v1.0/organization?$select=id,displayName' $org.value[0].displayName #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [string]$Method = 'GET' ) # Build full URL $BaseUrl = 'https://graph.microsoft.com' $FullUri = if ($Uri -like 'https://*') { $Uri } else { "$BaseUrl/$($Uri.TrimStart('/'))" } # Get access token -- try PnP first (works even when Graph SDK is broken by assembly conflict) $Token = $null try { $Token = Get-PnPGraphAccessToken -ErrorAction Stop } catch { # Fallback: try Graph SDK token (may fail if assembly conflict is present) try { $TokenObj = [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance.AuthContext if ($TokenObj) { $TokenResult = $TokenObj.AuthenticationProvider.GetAuthorizationTokenAsync( [Uri]"https://graph.microsoft.com", @{}, [System.Threading.CancellationToken]::None ).GetAwaiter().GetResult() $Token = $TokenResult } } catch { } } if (-not $Token) { throw "Invoke-SPGraphRequest: Could not obtain a Graph access token. Ensure PnP.PowerShell is connected." } $Headers = @{ 'Authorization' = "Bearer $Token" 'Content-Type' = 'application/json' 'Accept' = 'application/json' } try { $Response = Invoke-RestMethod -Uri $FullUri -Method $Method -Headers $Headers -ErrorAction Stop return $Response } catch { throw "Invoke-SPGraphRequest: Graph call failed for '$FullUri': $($_.Exception.Message)" } } function Test-SPGraphToken { <# .SYNOPSIS Diagnostic helper -- tests all Graph token acquisition methods and prints results. Run after connecting PnP to diagnose token issues: Connect-PnPOnline -Url https://tenant-admin.sharepoint.com -ClientId <id> -Interactive Test-SPGraphToken #> [CmdletBinding()] param() Write-Host "" Write-Host " === Graph Token Diagnostic ===" -ForegroundColor Cyan $Methods = @( @{ Name = 'Get-PnPAccessToken -ResourceTypeName Graph'; Script = { Get-PnPAccessToken -ResourceTypeName Graph -ErrorAction Stop } } @{ Name = 'Get-PnPAccessToken (no param)'; Script = { Get-PnPAccessToken -ErrorAction Stop } } @{ Name = 'Get-PnPGraphAccessToken'; Script = { Get-PnPGraphAccessToken -ErrorAction Stop } } ) $WorkingMethod = $null foreach ($m in $Methods) { try { $result = & $m.Script $token = if ($result -is [string]) { $result } elseif ($result.AccessToken) { $result.AccessToken } else { "$result" } if ($token -and $token.Length -gt 20) { Write-Host " [OK] $($m.Name) -- token length $($token.Length)" -ForegroundColor Green if (-not $WorkingMethod) { $WorkingMethod = $m.Name } } else { Write-Host " [EMPTY] $($m.Name) -- returned empty/short value" -ForegroundColor Yellow } } catch { Write-Host " [FAIL] $($m.Name) -- $($_.Exception.Message)" -ForegroundColor Red } } if ($WorkingMethod) { Write-Host "" Write-Host " Working method: $WorkingMethod" -ForegroundColor Green # Test a real Graph call try { $token = & ($Methods | Where-Object { $_.Name -eq $WorkingMethod }).Script $t = if ($token -is [string]) { $token } elseif ($token.AccessToken) { $token.AccessToken } else { "$token" } $resp = Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/organization?$select=displayName' ` -Headers @{ Authorization = "Bearer $t"; Accept = 'application/json' } -ErrorAction Stop Write-Host " [OK] Graph REST test -- Tenant: $($resp.value[0].displayName)" -ForegroundColor Green } catch { Write-Host " [FAIL] Graph REST test -- $($_.Exception.Message)" -ForegroundColor Red } } else { Write-Host "" Write-Host " [ERROR] No working token method found. Is PnP connected?" -ForegroundColor Red Write-Host " Run: Connect-PnPOnline -Url <adminUrl> -ClientId <id> -Interactive" -ForegroundColor Yellow } Write-Host "" } |