├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── validate.yml ├── .gitignore ├── LICENSE ├── MiniGraph ├── LICENSE ├── MiniGraph.psd1 ├── MiniGraph.psm1 ├── functions │ ├── Connect-GraphAzure.ps1 │ ├── Connect-GraphBrowser.ps1 │ ├── Connect-GraphCertificate.ps1 │ ├── Connect-GraphClientSecret.ps1 │ ├── Connect-GraphCredential.ps1 │ ├── Connect-GraphDeviceCode.ps1 │ ├── Connect-GraphToken.ps1 │ ├── Get-GraphToken.ps1 │ ├── Invoke-GraphRequest.ps1 │ ├── Invoke-GraphRequestBatch.ps1 │ └── Set-GraphEndpoint.ps1 └── internal │ ├── functions │ ├── Assert-GraphConnection.ps1 │ ├── Connect-GraphRefreshToken.ps1 │ ├── ConvertTo-Base64.ps1 │ ├── ConvertTo-SignedString.ps1 │ ├── Invoke-TerminatingException.ps1 │ ├── Set-ReconnectInfo.ps1 │ └── Update-Token.ps1 │ └── scripts │ └── variables.ps1 ├── build ├── vsts-build.ps1 ├── vsts-prerequisites.ps1 └── vsts-validate.ps1 ├── changelog.md ├── nothing-to-see-here.txt ├── readme.md └── tests ├── functions └── readme.md ├── general ├── FileIntegrity.Exceptions.ps1 ├── FileIntegrity.Tests.ps1 ├── Help.Exceptions.ps1 ├── Help.Tests.ps1 ├── Manifest.Tests.ps1 └── PSScriptAnalyzer.Tests.ps1 ├── pester.ps1 └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | FriedrichWeinmann 5 | patreon: # Replace with a single Patreon username 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: windows-2019 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Install Prerequisites 14 | run: .\build\vsts-prerequisites.ps1 15 | shell: powershell 16 | - name: Validate 17 | run: .\build\vsts-validate.ps1 18 | shell: powershell 19 | - name: Build 20 | run: .\build\vsts-build.ps1 -ApiKey $env:APIKEY 21 | shell: powershell 22 | env: 23 | APIKEY: ${{ secrets.ApiKey }} -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request] 2 | 3 | jobs: 4 | validate: 5 | 6 | runs-on: windows-2019 7 | 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Install Prerequisites 11 | run: .\build\vsts-prerequisites.ps1 12 | shell: powershell 13 | - name: Validate 14 | run: .\build\vsts-validate.ps1 15 | shell: powershell -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | TestResults 2 | publish -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Friedrich Weinmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MiniGraph/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Friedrich Weinmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MiniGraph/MiniGraph.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | 3 | # Script module or binary module file associated with this manifest. 4 | RootModule = 'MiniGraph.psm1' 5 | 6 | # Version number of this module. 7 | ModuleVersion = '1.3.18' 8 | 9 | # Supported PSEditions 10 | # CompatiblePSEditions = @() 11 | 12 | # ID used to uniquely identify this module 13 | GUID = '7c8f0a96-e788-4b09-a518-5afbd8546055' 14 | 15 | # Author of this module 16 | Author = 'Friedrich Weinmann' 17 | 18 | # Company or vendor of this module 19 | CompanyName = 'Microsoft' 20 | 21 | # Copyright statement for this module 22 | Copyright = '(c) Friedrich Weinmann. All rights reserved.' 23 | 24 | # Description of the functionality provided by this module 25 | Description = 'Minimal query infrastructure for interacting with MS Graph' 26 | 27 | # Minimum version of the PowerShell engine required by this module 28 | # PowerShellVersion = '' 29 | 30 | # Name of the PowerShell host required by this module 31 | # PowerShellHostName = '' 32 | 33 | # Minimum version of the PowerShell host required by this module 34 | # PowerShellHostVersion = '' 35 | 36 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 37 | # DotNetFrameworkVersion = '' 38 | 39 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 40 | # ClrVersion = '' 41 | 42 | # Processor architecture (None, X86, Amd64) required by this module 43 | # ProcessorArchitecture = '' 44 | 45 | # Modules that must be imported into the global environment prior to importing this module 46 | # RequiredModules = @() 47 | 48 | # Assemblies that must be loaded prior to importing this module 49 | # RequiredAssemblies = @() 50 | 51 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 52 | # ScriptsToProcess = @() 53 | 54 | # Type files (.ps1xml) to be loaded when importing this module 55 | # TypesToProcess = @() 56 | 57 | # Format files (.ps1xml) to be loaded when importing this module 58 | # FormatsToProcess = @() 59 | 60 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 61 | # NestedModules = @() 62 | 63 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 64 | FunctionsToExport = @( 65 | 'Connect-GraphAzure' 66 | 'Connect-GraphBrowser' 67 | 'Connect-GraphCertificate' 68 | 'Connect-GraphClientSecret' 69 | 'Connect-GraphCredential' 70 | 'Connect-GraphDeviceCode' 71 | 'Connect-GraphToken' 72 | 'Get-GraphToken' 73 | 'Invoke-GraphRequest' 74 | 'Invoke-GraphRequestBatch' 75 | 'Set-GraphEndpoint' 76 | ) 77 | 78 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 79 | # CmdletsToExport = '*' 80 | 81 | # Variables to export from this module 82 | # VariablesToExport = '*' 83 | 84 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 85 | # AliasesToExport = '*' 86 | 87 | # DSC resources to export from this module 88 | # DscResourcesToExport = @() 89 | 90 | # List of all modules packaged with this module 91 | # ModuleList = @() 92 | 93 | # List of all files packaged with this module 94 | # FileList = @() 95 | 96 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 97 | PrivateData = @{ 98 | 99 | PSData = @{ 100 | 101 | # Tags applied to this module. These help with module discovery in online galleries. 102 | Tags = @('msgraph') 103 | 104 | # A URL to the license for this module. 105 | LicenseUri = 'https://github.com/FriedrichWeinmann/MiniGraph/blob/master/LICENSE' 106 | 107 | # A URL to the main website for this project. 108 | ProjectUri = 'https://github.com/FriedrichWeinmann/MiniGraph' 109 | 110 | # A URL to an icon representing this module. 111 | # IconUri = '' 112 | 113 | # ReleaseNotes of this module 114 | # ReleaseNotes = '' 115 | 116 | # Prerelease string of this module 117 | # Prerelease = '' 118 | 119 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 120 | # RequireLicenseAcceptance = $false 121 | 122 | # External dependent modules of this module 123 | # ExternalModuleDependencies = @() 124 | 125 | } # End of PSData hashtable 126 | 127 | } # End of PrivateData hashtable 128 | 129 | # HelpInfo URI of this module 130 | # HelpInfoURI = '' 131 | 132 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 133 | # DefaultCommandPrefix = '' 134 | 135 | } 136 | 137 | -------------------------------------------------------------------------------- /MiniGraph/MiniGraph.psm1: -------------------------------------------------------------------------------- 1 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/scripts" -Filter *.ps1 -Recurse) { 2 | . $file.FullName 3 | } 4 | 5 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/functions" -Filter *.ps1 -Recurse) { 6 | . $file.FullName 7 | } 8 | 9 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/functions" -Filter *.ps1 -Recurse) { 10 | . $file.FullName 11 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphAzure.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphAzure { 2 | <# 3 | .SYNOPSIS 4 | Connect to graph using your current Az session. 5 | 6 | .DESCRIPTION 7 | Connect to graph using your current Az session. 8 | Requires the Az.Accounts module and for the current session to already be connected via Connect-AzAccount. 9 | 10 | .PARAMETER Authority 11 | Authority to connect to. 12 | Defaults to: "https://graph.microsoft.com" 13 | 14 | .PARAMETER ShowDialog 15 | Whether to risk showing a dialog during authentication. 16 | If set to never, it will fail if not possible to do silent authentication. 17 | Defaults to: Auto 18 | 19 | .PARAMETER NoReconnect 20 | Disables automatic reconnection. 21 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 22 | 23 | .EXAMPLE 24 | PS C:\> Connect-GraphAzure 25 | 26 | Connect to graph via the current Az session 27 | #> 28 | [CmdletBinding()] 29 | param ( 30 | [string] 31 | $Authority = "https://graph.microsoft.com", 32 | 33 | [ValidateSet('Auto', 'Always', 'Never')] 34 | [string] 35 | $ShowDialog = 'Auto', 36 | 37 | [switch] 38 | $NoReconnect 39 | ) 40 | 41 | try { $azContext = Get-AzContext -ErrorAction Stop } 42 | catch { $PSCmdlet.ThrowTerminatingError($_) } 43 | 44 | try { 45 | $result = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate( 46 | $azContext.Account, 47 | $azContext.Environment, 48 | "$($azContext.Tenant.id)", 49 | $null, 50 | $ShowDialog, 51 | $null, 52 | $Authority 53 | ) 54 | 55 | } 56 | catch { $PSCmdlet.ThrowTerminatingError($_) } 57 | 58 | $script:token = $result.AccessToken 59 | 60 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 61 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphBrowser.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphBrowser { 2 | <# 3 | .SYNOPSIS 4 | Interactive logon using the Authorization flow and browser. Supports SSO. 5 | 6 | .DESCRIPTION 7 | Interactive logon using the Authorization flow and browser. Supports SSO. 8 | 9 | This flow requires an App Registration configured for the platform "Mobile and desktop applications". 10 | Its redirect Uri must be "http://localhost" 11 | 12 | On successful authentication 13 | 14 | .PARAMETER ClientID 15 | The ID of the registered app used with this authentication request. 16 | 17 | .PARAMETER TenantID 18 | The ID of the tenant connected to with this authentication request. 19 | 20 | .PARAMETER SelectAccount 21 | Forces account selection on logon. 22 | As this flow supports single-sign-on, it will otherwise not prompt for anything if already signed in. 23 | This could be a problem if you want to connect using another (e.g. an admin) account. 24 | 25 | .PARAMETER Scopes 26 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' 27 | 28 | .PARAMETER LocalPort 29 | The local port that should be redirected to. 30 | In order to process the authentication response, we need to listen to a local web request on some port. 31 | Usually needs not be redirected. 32 | Defaults to: 8080 33 | 34 | .PARAMETER Resource 35 | The resource the token grants access to. 36 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' 37 | Only needed when connecting to another service. 38 | 39 | .PARAMETER Browser 40 | The path to the browser to use for the authentication flow. 41 | Provide the full path to the executable. 42 | The browser must accept the url to open as its only parameter. 43 | Defaults to your default browser. 44 | 45 | .PARAMETER NoReconnect 46 | Disables automatic reconnection. 47 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 48 | 49 | .EXAMPLE 50 | PS C:\> Connect-GraphBrowser -ClientID '' -TenantID '' 51 | 52 | Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. 53 | #> 54 | [CmdletBinding()] 55 | param ( 56 | [Parameter(Mandatory = $true)] 57 | [string] 58 | $TenantID, 59 | 60 | [Parameter(Mandatory = $true)] 61 | [string] 62 | $ClientID, 63 | 64 | [switch] 65 | $SelectAccount, 66 | 67 | [string[]] 68 | $Scopes = 'https://graph.microsoft.com/.default', 69 | 70 | [int] 71 | $LocalPort = 8080, 72 | 73 | [Uri] 74 | $Resource = 'https://graph.microsoft.com/', 75 | 76 | [string] 77 | $Browser, 78 | 79 | [switch] 80 | $NoReconnect 81 | ) 82 | process { 83 | Add-Type -AssemblyName System.Web 84 | 85 | $redirectUri = "http://localhost:$LocalPort" 86 | $actualScopes = foreach ($scope in $Scopes) { 87 | if ($scope -like 'https://*/*') { $scope } 88 | else { "{0}://{1}/{2}" -f $Resource.Scheme, $Resource.Host, $scope } 89 | } 90 | 91 | if (-not $NoReconnect) { 92 | $actualScopes = @($actualScopes) + 'offline_access' 93 | } 94 | 95 | $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/authorize?" 96 | $state = Get-Random 97 | $parameters = @{ 98 | client_id = $ClientID 99 | response_type = 'code' 100 | redirect_uri = $redirectUri 101 | response_mode = 'query' 102 | scope = $Scopes -join ' ' 103 | state = $state 104 | } 105 | if ($SelectAccount) { 106 | $parameters.prompt = 'select_account' 107 | } 108 | 109 | $paramStrings = foreach ($pair in $parameters.GetEnumerator()) { 110 | $pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '=' 111 | } 112 | $uriFinal = $uri + ($paramStrings -join '&') 113 | Write-Verbose "Authorize Uri: $uriFinal" 114 | 115 | $redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/MiniGraph/master/nothing-to-see-here.txt' 116 | if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) { 117 | $redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' 118 | } 119 | 120 | # Start local server to catch the redirect 121 | $http = [System.Net.HttpListener]::new() 122 | $http.Prefixes.Add("$redirectUri/") 123 | try { $http.Start() } 124 | catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError } 125 | 126 | # Execute in default browser 127 | if ($Browser) { & $Browser $uriFinal } 128 | else { Start-Process $uriFinal } 129 | 130 | # Get Result 131 | $task = $http.GetContextAsync() 132 | $authorizationCode, $stateReturn, $sessionState = $null 133 | try { 134 | while (-not $task.IsCompleted) { 135 | Start-Sleep -Milliseconds 200 136 | } 137 | $context = $task.Result 138 | $context.Response.Redirect($redirectTo) 139 | $context.Response.Close() 140 | $authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&" 141 | } 142 | finally { 143 | $http.Stop() 144 | $http.Dispose() 145 | } 146 | 147 | if (-not $stateReturn) { 148 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError 149 | } 150 | 151 | if ($state -ne $stateReturn.Split("=")[1]) { 152 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation 153 | } 154 | 155 | $actualAuthorizationCode = $authorizationCode.Split("=")[1] 156 | 157 | $body = @{ 158 | client_id = $ClientID 159 | scope = $actualScopes -join " " 160 | code = $actualAuthorizationCode 161 | redirect_uri = $redirectUri 162 | grant_type = 'authorization_code' 163 | } 164 | $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" 165 | try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop } 166 | catch { 167 | if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ } 168 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category 169 | } 170 | $script:token = $authResponse.access_token 171 | 172 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect -RefreshToken $authResponse.refresh_token 173 | } 174 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphCertificate.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphCertificate { 2 | <# 3 | .SYNOPSIS 4 | Connect to graph as an application using a certificate 5 | 6 | .DESCRIPTION 7 | Connect to graph as an application using a certificate 8 | 9 | .PARAMETER Certificate 10 | The certificate to use for authentication. 11 | 12 | .PARAMETER TenantID 13 | The Guid of the tenant to connect to. 14 | 15 | .PARAMETER ClientID 16 | The ClientID / ApplicationID of the application to connect as. 17 | 18 | .PARAMETER Scopes 19 | The scopes to request when connecting. 20 | IN Application flows, this only determines the service for which to retrieve the scopes already configured on the App Registration. 21 | Defaults to graph API. 22 | 23 | .PARAMETER NoReconnect 24 | Disables automatic reconnection. 25 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 26 | 27 | .EXAMPLE 28 | PS C:\> $cert = Get-Item -Path 'Cert:\CurrentUser\My\082D5CB4BA31EED7E2E522B39992E34871C92BF5' 29 | PS C:\> Connect-GraphCertificate -TenantID '0639f07d-76e1-49cb-82ac-abcdefabcdefa' -ClientID '0639f07d-76e1-49cb-82ac-1234567890123' -Certificate $cert 30 | 31 | Connect to graph with the specified cert stored in the current user's certificate store. 32 | 33 | .LINK 34 | https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials 35 | #> 36 | [CmdletBinding()] 37 | param ( 38 | [Parameter(Mandatory = $true)] 39 | [ValidateScript({ 40 | if (-not $_.HasPrivateKey) { throw "Certificate has no private key!" } 41 | $true 42 | })] 43 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 44 | $Certificate, 45 | 46 | [Parameter(Mandatory = $true)] 47 | [string] 48 | $TenantID, 49 | 50 | [Parameter(Mandatory = $true)] 51 | [string] 52 | $ClientID, 53 | 54 | [string[]] 55 | $Scopes = 'https://graph.microsoft.com/.default', 56 | 57 | [switch] 58 | $NoReconnect 59 | ) 60 | 61 | $jwtHeader = @{ 62 | alg = "RS256" 63 | typ = "JWT" 64 | x5t = [Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '=' 65 | } 66 | $encodedHeader = $jwtHeader | ConvertTo-Json | ConvertTo-Base64 67 | $claims = @{ 68 | aud = "https://login.microsoftonline.com/$TenantID/v2.0" 69 | exp = ((Get-Date).AddMinutes(5) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int] 70 | iss = $ClientID 71 | jti = "$(New-Guid)" 72 | nbf = ((Get-Date) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int] 73 | sub = $ClientID 74 | } 75 | $encodedClaims = $claims | ConvertTo-Json | ConvertTo-Base64 76 | $jwtPreliminary = $encodedHeader, $encodedClaims -join "." 77 | $jwtSigned = ($jwtPreliminary | ConvertTo-SignedString -Certificate $Certificate) -replace '\+', '-' -replace '/', '_' -replace '=' 78 | $jwt = $jwtPreliminary, $jwtSigned -join '.' 79 | 80 | $body = @{ 81 | client_id = $ClientID 82 | client_assertion = $jwt 83 | client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' 84 | scope = $Scopes -join ' ' 85 | grant_type = 'client_credentials' 86 | } 87 | $header = @{ 88 | Authorization = "Bearer $jwt" 89 | } 90 | $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" 91 | try { $script:token = (Invoke-RestMethod -Uri $uri -Method Post -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop).access_token } 92 | catch { $PSCmdlet.ThrowTerminatingError($_) } 93 | 94 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 95 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphClientSecret.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphClientSecret { 2 | <# 3 | .SYNOPSIS 4 | Connects using a client secret. 5 | 6 | .DESCRIPTION 7 | Connects using a client secret. 8 | 9 | .PARAMETER ClientID 10 | The ID of the registered app used with this authentication request. 11 | 12 | .PARAMETER TenantID 13 | The ID of the tenant connected to with this authentication request. 14 | 15 | .PARAMETER ClientSecret 16 | The actual secret used for authenticating the request. 17 | 18 | .PARAMETER Scopes 19 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' 20 | 21 | .PARAMETER Resource 22 | The resource the token grants access to. 23 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' 24 | Only needed when connecting to another service. 25 | 26 | .PARAMETER NoReconnect 27 | Disables automatic reconnection. 28 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 29 | 30 | .EXAMPLE 31 | PS C:\> Connect-GraphClientSecret -ClientID '' -TenantID '' -ClientSecret $secret 32 | 33 | Connects to the specified tenant using the specified client and secret. 34 | #> 35 | [CmdletBinding()] 36 | param ( 37 | [Parameter(Mandatory = $true)] 38 | [string] 39 | $ClientID, 40 | 41 | [Parameter(Mandatory = $true)] 42 | [string] 43 | $TenantID, 44 | 45 | [Parameter(Mandatory = $true)] 46 | [securestring] 47 | $ClientSecret, 48 | 49 | [string[]] 50 | $Scopes = 'https://graph.microsoft.com/.default', 51 | 52 | [string] 53 | $Resource = 'https://graph.microsoft.com/', 54 | 55 | [switch] 56 | $NoReconnect 57 | ) 58 | 59 | process { 60 | $body = @{ 61 | client_id = $ClientID 62 | client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password 63 | scope = $Scopes -join " " 64 | grant_type = 'client_credentials' 65 | resource = $Resource 66 | } 67 | try { $authResponse = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$TenantId/oauth2/token" -Body $body -ErrorAction Stop } 68 | catch { $PSCmdlet.ThrowTerminatingError($_) } 69 | $script:token = $authResponse.access_token 70 | 71 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 72 | } 73 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphCredential.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphCredential { 2 | <# 3 | .SYNOPSIS 4 | Connect to graph using username and password. 5 | 6 | .DESCRIPTION 7 | Connect to graph using username and password. 8 | This logs into graph as a user, not as an application. 9 | Only cloud-only accounts can be used for this workflow. 10 | Consent to scopes must be granted before using them, as this command cannot show the consent prompt. 11 | 12 | .PARAMETER Credential 13 | Credentials of the user to connect as. 14 | 15 | .PARAMETER TenantID 16 | The Guid of the tenant to connect to. 17 | 18 | .PARAMETER ClientID 19 | The ClientID / ApplicationID of the application to use. 20 | 21 | .PARAMETER Scopes 22 | The permission scopes to request. 23 | 24 | .PARAMETER NoReconnect 25 | Disables automatic reconnection. 26 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 27 | 28 | .EXAMPLE 29 | PS C:\> Connect-GraphCredential -Credential max@contoso.com -ClientID $client -TenantID $tenant -Scopes 'user.read','user.readbasic.all' 30 | 31 | Connect as max@contoso.com with the rights to read user information. 32 | #> 33 | [CmdletBinding()] 34 | param ( 35 | [Parameter(Mandatory = $true)] 36 | [System.Management.Automation.PSCredential] 37 | $Credential, 38 | 39 | [Parameter(Mandatory = $true)] 40 | [string] 41 | $ClientID, 42 | 43 | [Parameter(Mandatory = $true)] 44 | [string] 45 | $TenantID, 46 | 47 | [string[]] 48 | $Scopes = 'user.read', 49 | 50 | [switch] 51 | $NoReconnect 52 | ) 53 | 54 | $request = @{ 55 | client_id = $ClientID 56 | scope = $Scopes -join " " 57 | username = $Credential.UserName 58 | password = $Credential.GetNetworkCredential().Password 59 | grant_type = 'password' 60 | } 61 | 62 | try { $answer = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop } 63 | catch { $PSCmdlet.ThrowTerminatingError($_) } 64 | $script:token = $answer.access_token 65 | 66 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 67 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphDeviceCode.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphDeviceCode { 2 | <# 3 | .SYNOPSIS 4 | Connects to Azure AD using the Device Code authentication workflow. 5 | 6 | .DESCRIPTION 7 | Connects to Azure AD using the Device Code authentication workflow. 8 | 9 | .PARAMETER ClientID 10 | The ID of the registered app used with this authentication request. 11 | 12 | .PARAMETER TenantID 13 | The ID of the tenant connected to with this authentication request. 14 | 15 | .PARAMETER Scopes 16 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default' 17 | 18 | .PARAMETER Resource 19 | The resource the token grants access to. 20 | Generally doesn't need to be changed from the default 'https://graph.microsoft.com/' 21 | Only needed when connecting to another service. 22 | 23 | .PARAMETER NoReconnect 24 | Disables automatic reconnection. 25 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 26 | 27 | .EXAMPLE 28 | PS C:\> Connect-GraphDeviceCode -ClientID '' -TenantID '' 29 | 30 | Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. 31 | #> 32 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] 33 | [CmdletBinding()] 34 | param ( 35 | 36 | [Parameter(Mandatory = $true)] 37 | [string] 38 | $ClientID, 39 | 40 | [Parameter(Mandatory = $true)] 41 | [string] 42 | $TenantID, 43 | 44 | [string[]] 45 | $Scopes = 'https://graph.microsoft.com/.default', 46 | 47 | [Uri] 48 | $Resource = 'https://graph.microsoft.com/', 49 | 50 | [switch] 51 | $NoReconnect 52 | ) 53 | 54 | $actualScopes = foreach ($scope in $Scopes) { 55 | if ($scope -like 'https://*/*') { $scope } 56 | else { "{0}://{1}/{2}" -f $Resource.Scheme, $Resource.Host, $scope } 57 | } 58 | 59 | try { 60 | $initialResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/devicecode" -Body @{ 61 | client_id = $ClientID 62 | scope = @($actualScopes) + 'offline_access' -join " " 63 | } -ErrorAction Stop 64 | } 65 | catch { 66 | throw 67 | } 68 | 69 | Write-Host $initialResponse.message 70 | 71 | $paramRetrieve = @{ 72 | Uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" 73 | Method = "POST" 74 | Body = @{ 75 | grant_type = "urn:ietf:params:oauth:grant-type:device_code" 76 | client_id = $ClientID 77 | device_code = $initialResponse.device_code 78 | } 79 | ErrorAction = 'Stop' 80 | } 81 | $limit = (Get-Date).AddSeconds($initialResponse.expires_in) 82 | while ($true) { 83 | if ((Get-Date) -gt $limit) { 84 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Timelimit exceeded, device code authentication failed" -Category AuthenticationError 85 | } 86 | Start-Sleep -Seconds $initialResponse.interval 87 | try { $authResponse = Invoke-RestMethod @paramRetrieve } 88 | catch { 89 | if ($_ -match '"error":\s*"authorization_pending"') { continue } 90 | $PSCmdlet.ThrowTerminatingError($_) 91 | } 92 | if ($authResponse) { 93 | break 94 | } 95 | } 96 | 97 | $script:token = $authResponse.access_token 98 | 99 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect -RefreshToken $authResponse.refresh_token 100 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Connect-GraphToken.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphToken { 2 | <# 3 | .SYNOPSIS 4 | Connect to graph using a token and the on behalf of flow. 5 | 6 | .DESCRIPTION 7 | Connect to graph using a token and the on behalf of flow. 8 | 9 | .PARAMETER Token 10 | The existing token to use for the request. 11 | 12 | .PARAMETER TenantID 13 | The Guid of the tenant to connect to. 14 | 15 | .PARAMETER ClientID 16 | The ClientID / ApplicationID of the application to connect as. 17 | 18 | .PARAMETER ClientSecret 19 | The secret used to authorize the OBO flow. 20 | 21 | .PARAMETER Scopes 22 | The scopes to request 23 | Defaults to: 'https://graph.microsoft.com/.default' 24 | 25 | .PARAMETER NoReconnect 26 | Disables automatic reconnection. 27 | By default, MiniGraph will automatically try to reaquire a new token before the old one expires. 28 | 29 | .EXAMPLE 30 | PS C:\> Connect-GraphToken -Token $token -TenantID $tenantID -ClientID $clientID -CLientSecret $secret 31 | 32 | Connect to graph using a token and the on behalf of flow. 33 | 34 | .LINK 35 | https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow 36 | #> 37 | [CmdletBinding()] 38 | param ( 39 | [Parameter(Mandatory = $true)] 40 | [string] 41 | $Token, 42 | 43 | [Parameter(Mandatory = $true)] 44 | [string] 45 | $TenantID, 46 | 47 | [Parameter(Mandatory = $true)] 48 | [string] 49 | $ClientID, 50 | 51 | [Parameter(Mandatory = $true)] 52 | [SecureString] 53 | $ClientSecret, 54 | 55 | [string[]] 56 | $Scopes = 'https://graph.microsoft.com/.default', 57 | 58 | [switch] 59 | $NoReconnect 60 | ) 61 | 62 | $body = @{ 63 | grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer' 64 | client_id = $ClientID 65 | client_secret = ([PSCredential]::new("Whatever", $ClientSecret)).GetNetworkCredential().Password 66 | assertion = $Token 67 | scope = @($Scopes) 68 | requested_token_use = 'on_behalf_of' 69 | } 70 | $param = @{ 71 | Method = "POST" 72 | Uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" 73 | Body = $body 74 | ContentType = 'application/x-www-form-urlencoded' 75 | } 76 | 77 | try { $script:token = Invoke-RestMethod @param -ErrorAction Stop } 78 | catch { $PSCmdlet.ThrowTerminatingError($_) } 79 | 80 | Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 81 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Get-GraphToken.ps1: -------------------------------------------------------------------------------- 1 | function Get-GraphToken { 2 | <# 3 | .SYNOPSIS 4 | Retrieve the currently used graph token. 5 | 6 | .DESCRIPTION 7 | Retrieve the currently used graph token. 8 | Use one of the Connect-Graph* commands to first establish a connection. 9 | The token retrieved is a static copy of the current token - it will not be automatically refreshed once expired. 10 | 11 | .EXAMPLE 12 | PS C:\> Get-GraphToken 13 | 14 | Retrieve the currently used graph token. 15 | #> 16 | [CmdletBinding()] 17 | param ( 18 | 19 | ) 20 | process { 21 | [PSCustomObject]@{ 22 | Token = $script:token 23 | Created = $script:lastConnect.When 24 | HasRefresh = $script:lastConnect.Refresh -as [bool] 25 | Endpoint = $script:baseEndpoint 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Invoke-GraphRequest.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-GraphRequest { 2 | <# 3 | .SYNOPSIS 4 | Execute a request against the graph API 5 | 6 | .DESCRIPTION 7 | Execute a request against the graph API 8 | 9 | .PARAMETER Query 10 | The relative graph query with all conditions appended. 11 | Uses the full query if the query starts with http:// or https://. 12 | 13 | .PARAMETER Method 14 | Which rest method to use. 15 | Defaults to GET. 16 | 17 | .PARAMETER ContentType 18 | Which content type to specify. 19 | Defaults to "Application/Json" 20 | 21 | .PARAMETER Body 22 | Any body to specify. 23 | Anything not a string, will be converted to json. 24 | 25 | .PARAMETER Raw 26 | Return the raw response, rather than processing the output. 27 | 28 | .PARAMETER NoPaging 29 | Only return the first set of data, rather than paging through the entire set. 30 | 31 | .PARAMETER Header 32 | Additional header entries to include beside authentication 33 | 34 | .EXAMPLE 35 | PS C:\> Invoke-GraphRequest -Query me 36 | 37 | Returns information about the current user. 38 | #> 39 | [CmdletBinding()] 40 | param ( 41 | [Parameter(Mandatory = $true)] 42 | [string] 43 | $Query, 44 | 45 | [Microsoft.PowerShell.Commands.WebRequestMethod] 46 | $Method = 'GET', 47 | 48 | [string] 49 | $ContentType = 'application/json', 50 | 51 | $Body, 52 | 53 | [switch] 54 | $Raw, 55 | 56 | [switch] 57 | $NoPaging, 58 | 59 | [hashtable] 60 | $Header = @{ } 61 | ) 62 | 63 | begin { 64 | Assert-GraphConnection -Cmdlet $PSCmdlet 65 | } 66 | process { 67 | $parameters = @{ 68 | Uri = "$($script:baseEndpoint)/$($Query.TrimStart("/"))" 69 | Method = $Method 70 | ContentType = $ContentType 71 | } 72 | if ($Query -match '^http://|https://') { 73 | $parameters.Uri = $Query 74 | } 75 | if ($Body) { 76 | if ($Body -is [string]) { $parameters.Body = $Body } 77 | else { $parameters.Body = $Body | ConvertTo-Json -Compress -Depth 99 } 78 | } 79 | do { 80 | try { Update-Token } 81 | catch { throw } 82 | $parameters.Headers = @{ Authorization = "Bearer $($script:Token)" } + $Header 83 | 84 | try { $data = Invoke-RestMethod @parameters -ErrorAction Stop } 85 | catch { throw } 86 | if ($Raw) { $data } 87 | elseif ($data.Value) { $data.Value } 88 | elseif ($data -and $null -eq $data.Value) { $data } 89 | $parameters.Uri = $data.'@odata.nextLink' 90 | } 91 | until (-not $data.'@odata.nextLink' -or $NoPaging) 92 | } 93 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Invoke-GraphRequestBatch.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-GraphRequestBatch { 2 | <# 3 | .SYNOPSIS 4 | Invoke a batch request against the graph API 5 | 6 | .DESCRIPTION 7 | Invoke a batch request against the graph API in batches of twenty. 8 | 9 | .PARAMETER Request 10 | A list of requests to batch. 11 | Each entry should either be ... 12 | - A relative uri to query (what you would send to Invoke-GraphRequest) 13 | - A hashtable consisting of url (mandatory), method (optional), id (optional), body (optional), headers (optional) and dependsOn (optional). 14 | 15 | .PARAMETER Method 16 | The method to use with requests, that do not specify their method. 17 | Defaults to "GET" 18 | 19 | .PARAMETER Body 20 | The body to add to requests that do not specify their own body. 21 | 22 | .PARAMETER Header 23 | The header to add to requests that do not specify their own header. 24 | 25 | .EXAMPLE 26 | $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq true" 27 | $requests = @($servicePrincipals).ForEach{ "/servicePrincipals/$($_.id)/appRoleAssignments" } 28 | Invoke-GraphRequestBatch -Request $requests 29 | 30 | Retrieve the role assignments for all enabled service principals 31 | 32 | .EXAMPLE 33 | $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq false" 34 | $requests = @($servicePrincipals).ForEach{ "/servicePrincipals/$($_.id)" } 35 | Invoke-GraphRequestBatch -Request $requests -Body { accountEnabled = $true } -Method PATCH 36 | 37 | Enables all disabled service principals 38 | 39 | .EXAMPLE 40 | $servicePrincipals = Invoke-GraphRequest -Query "servicePrincipals?&`$filter=accountEnabled eq true" 41 | $araCounter = 1 42 | $idToSp = @{} 43 | $appRoleAssignmentsRequest = foreach ($sp in $servicePrincipals) 44 | { 45 | @{ 46 | url = "/servicePrincipals/$($sp.id)/appRoleAssignments" 47 | method = "GET" 48 | id = $araCounter 49 | } 50 | $idToSp[$araCounter] = $sp 51 | $araCounter++ 52 | } 53 | Invoke-GraphRequestBatch -Request $appRoleAssignmentsRequest 54 | 55 | Retrieve the role assignments for all enabled service principals 56 | #> 57 | [CmdletBinding()] 58 | param ( 59 | [Parameter(Mandatory = $true)] 60 | [object[]] 61 | $Request, 62 | 63 | [Microsoft.PowerShell.Commands.WebRequestMethod] 64 | $Method = 'Get', 65 | 66 | [hashtable] 67 | $Body, 68 | 69 | [hashtable] 70 | $Header 71 | ) 72 | 73 | begin { 74 | function ConvertTo-BatchRequest { 75 | [CmdletBinding()] 76 | param ( 77 | [object[]] 78 | $Request, 79 | 80 | [Microsoft.PowerShell.Commands.WebRequestMethod] 81 | $Method, 82 | 83 | $Cmdlet, 84 | 85 | [AllowNull()] 86 | [hashtable] 87 | $Body, 88 | 89 | [AllowNull()] 90 | [hashtable] 91 | $Header 92 | ) 93 | $defaultMethod = "$Method".ToUpper() 94 | 95 | $results = @{} 96 | $requests = foreach ($entry in $Request) { 97 | $newRequest = @{ 98 | url = '' 99 | method = $defaultMethod 100 | id = 0 101 | } 102 | if ($Body) { $newRequest.body = $Body } 103 | if ($Header) { $newRequest.headers = $Header } 104 | if ($entry -is [string]) { 105 | $newRequest.url = $entry 106 | $newRequest 107 | continue 108 | } 109 | 110 | if (-not $entry.url) { 111 | Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Invalid batch request: No Url found! $entry" -Category InvalidArgument 112 | } 113 | $newRequest.url = $entry.url 114 | if ($entry.Method) { 115 | $newRequest.method = "$($entry.Method)".ToUpper() 116 | } 117 | if ($entry.id -as [int]) { 118 | $newRequest.id = $entry.id -as [int] 119 | $results[($entry.id -as [int])] = $newRequest 120 | } 121 | if ($entry.body) { 122 | $newRequest.body = $entry.body 123 | } 124 | if ($entry.headers) { 125 | $newRequest.headers = $entry.headers 126 | } 127 | if ($entry.dependsOn) { 128 | $newRequest.dependsOn 129 | } 130 | $newRequest 131 | } 132 | 133 | $index = 1 134 | $finalList = foreach ($requestItem in $requests) { 135 | $requestItem.id = $requestItem.id -as [string] 136 | if ($requestItem.id) { 137 | $requestItem 138 | continue 139 | } 140 | 141 | while ($results[$index]) { 142 | $index++ 143 | } 144 | $requestItem.id = $index 145 | $results[$index] = $requestItem 146 | $requestItem 147 | } 148 | 149 | $finalList | Sort-Object { $_.id -as [int] } 150 | } 151 | } 152 | 153 | process { 154 | $batchSize = 20 # Currently hardcoded API limit 155 | $counter = [pscustomobject] @{ Value = 0 } 156 | $batches = ConvertTo-BatchRequest -Request $Request -Method $Method -Cmdlet $PSCmdlet -Body $Body -Header $Header | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } -AsHashTable 157 | 158 | foreach ($batch in ($batches.GetEnumerator() | Sort-Object -Property Key)) { 159 | [array] $innerResult = try { 160 | $jsonbody = @{requests = [array]$batch.Value } | ConvertTo-Json -Depth 42 -Compress 161 | (MiniGraph\Invoke-GraphRequest -Query '$batch' -Method Post -Body $jsonbody -ErrorAction Stop).responses 162 | } 163 | catch { 164 | Write-Error -Message "Error sending batch: $($_.Exception.Message)" -TargetObject $jsonbody 165 | continue 166 | } 167 | 168 | $throttledRequests = $innerResult | Where-Object status -EQ 429 169 | $failedRequests = $innerResult | Where-Object { $_.status -ne 429 -and $_.status -in (400..499) } 170 | $successRequests = $innerResult | Where-Object status -In (200..299) 171 | 172 | foreach ($failedRequest in $failedRequests) { 173 | Write-Error -Message "Error in batch request $($failedRequest.id): $($failedRequest.body.error.message)" 174 | } 175 | 176 | if ($successRequests) { 177 | $successRequests 178 | } 179 | 180 | if ($throttledRequests) { 181 | $interval = ($throttledRequests.Headers | Sort-Object 'Retry-After' | Select-Object -Last 1).'Retry-After' 182 | Write-Verbose -Message "Throttled requests detected, waiting $interval seconds before retrying" 183 | 184 | Start-Sleep -Seconds $interval 185 | $retry = $Request | Where-Object id -In $throttledRequests.id 186 | 187 | if (-not $retry) { 188 | continue 189 | } 190 | 191 | try { 192 | (MiniGraph\Invoke-GraphRequestBatch -Request $retry -ErrorAction Stop).responses 193 | } 194 | catch { 195 | Write-Error -Message "Error sending retry batch: $($_.Exception.Message)" -TargetObject $retry 196 | } 197 | } 198 | } 199 | } 200 | } -------------------------------------------------------------------------------- /MiniGraph/functions/Set-GraphEndpoint.ps1: -------------------------------------------------------------------------------- 1 | function Set-GraphEndpoint { 2 | <# 3 | .SYNOPSIS 4 | Specify which graph endpoint to use for subsequent requests. 5 | 6 | .DESCRIPTION 7 | Specify which graph endpoint to use for subsequent requests. 8 | 9 | .PARAMETER Type 10 | Which kind of endpoint to use. 11 | v1 or beta 12 | 13 | .PARAMETER Url 14 | Specify a custom Url as endpoint. 15 | Used to switch to a government cloud. 16 | 17 | .EXAMPLE 18 | PS C:\> Set-GraphEndpoint -Type beta 19 | 20 | Switch to using the beta graph endpoint 21 | #> 22 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] 23 | [CmdletBinding()] 24 | param ( 25 | [Parameter(Mandatory = $true, ParameterSetName = 'Default')] 26 | [ValidateSet('v1','beta')] 27 | [string] 28 | $Type, 29 | 30 | [Parameter(Mandatory = $true, ParameterSetName = 'Url')] 31 | [string] 32 | $Url 33 | ) 34 | 35 | if ($Type) { 36 | switch ($Type) { 37 | 'v1' { $script:baseEndpoint = 'https://graph.microsoft.com/v1.0' } 38 | 'beta' { $script:baseEndpoint = 'https://graph.microsoft.com/beta' } 39 | } 40 | } 41 | if ($Url) { $script:baseEndpoint = $Url.Trim("/") } 42 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/Assert-GraphConnection.ps1: -------------------------------------------------------------------------------- 1 | function Assert-GraphConnection 2 | { 3 | <# 4 | .SYNOPSIS 5 | Asserts a valid graph connection has been established. 6 | 7 | .DESCRIPTION 8 | Asserts a valid graph connection has been established. 9 | 10 | .PARAMETER Cmdlet 11 | The $PSCmdlet variable of the calling command. 12 | 13 | .EXAMPLE 14 | PS C:\> Assert-GraphConnection -Cmdlet $PSCmdlet 15 | 16 | Asserts a valid graph connection has been established. 17 | #> 18 | [CmdletBinding()] 19 | param ( 20 | [Parameter(Mandatory = $true)] 21 | $Cmdlet 22 | ) 23 | 24 | process 25 | { 26 | if ($script:token) { return } 27 | 28 | $exception = [System.InvalidOperationException]::new('Not yet connected to MSGraph. Use Connect-Graph* to establish a connection!') 29 | $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, "NotConnected", 'InvalidOperation', $null) 30 | 31 | $Cmdlet.ThrowTerminatingError($errorRecord) 32 | } 33 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/Connect-GraphRefreshToken.ps1: -------------------------------------------------------------------------------- 1 | function Connect-GraphRefreshToken { 2 | <# 3 | .SYNOPSIS 4 | Connect with the refresh token provided previously. 5 | 6 | .DESCRIPTION 7 | Connect with the refresh token provided previously. 8 | Used mostly for delegate authentication flows to avoid interactivity. 9 | 10 | .EXAMPLE 11 | PS C:\> Connect-GraphRefreshToken 12 | 13 | Connect with the refresh token provided previously. 14 | #> 15 | [CmdletBinding()] 16 | param ( 17 | 18 | ) 19 | process { 20 | if (-not $script:lastConnect.Refresh) { 21 | throw "No refresh token found!" 22 | } 23 | 24 | $scopes = 'https://graph.microsoft.com/.default' 25 | if ($script:lastConnect.Parameters.Scopes) { 26 | $scopes = $script:lastConnect.Parameters.Scopes 27 | } 28 | 29 | $body = @{ 30 | client_id = $script:lastConnect.Parameters.ClientID 31 | scope = $scopes -join " " 32 | refresh_token = [PSCredential]::new("whatever", $script:lastConnect.Refresh).GetNetworkCredential().Password 33 | grant_type = 'refresh_token' 34 | } 35 | $uri = "https://login.microsoftonline.com/$($script:lastConnect.Parameters.TenantID)/oauth2/v2.0/token" 36 | $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body 37 | $script:token = $authResponse.access_token 38 | $script:lastConnect.Refresh = $authResponse.refresh_token 39 | } 40 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/ConvertTo-Base64.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-Base64 { 2 | <# 3 | .SYNOPSIS 4 | Converts input string to base 64. 5 | 6 | .DESCRIPTION 7 | Converts input string to base 64. 8 | 9 | .PARAMETER Text 10 | The text to encode. 11 | 12 | .PARAMETER Encoding 13 | The encoding of the input text. 14 | 15 | .EXAMPLE 16 | PS C:\> "Hello World" | ConvertTo-Base64 17 | 18 | Converts the string "Hello World" to base 64. 19 | #> 20 | [OutputType([string])] 21 | [CmdletBinding()] 22 | param ( 23 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 24 | [string[]] 25 | $Text, 26 | 27 | [System.Text.Encoding] 28 | $Encoding = [System.Text.Encoding]::UTF8 29 | ) 30 | 31 | process { 32 | foreach ($entry in $Text) { 33 | $bytes = $Encoding.GetBytes($entry) 34 | [Convert]::ToBase64String($bytes) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/ConvertTo-SignedString.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-SignedString { 2 | <# 3 | .SYNOPSIS 4 | Signs a string. 5 | 6 | .DESCRIPTION 7 | Signs a string. 8 | Used for certificate authentication. 9 | 10 | .PARAMETER Text 11 | The text to sign. 12 | 13 | .PARAMETER Certificate 14 | The certificate to sign with. 15 | Must have private key. 16 | 17 | .PARAMETER Padding 18 | The padding mechanism to use while signing. 19 | Defaults to "Pkcs1" 20 | 21 | .PARAMETER Algorithm 22 | The signing algorithm to use. 23 | Defaults to "SHA256" 24 | 25 | .PARAMETER Encoding 26 | Encoding of the source text. 27 | Defaults to UTF8 28 | 29 | .EXAMPLE 30 | PS C:\> $token | ConvertTo-SignedString -Certificate $cert 31 | 32 | Signs the text stored in $token with the certificate stored in $cert 33 | #> 34 | [OutputType([string])] 35 | [CmdletBinding()] 36 | param ( 37 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 38 | [string[]] 39 | $Text, 40 | 41 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 42 | $Certificate, 43 | 44 | [Security.Cryptography.RSASignaturePadding] 45 | $Padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1, 46 | 47 | [Security.Cryptography.HashAlgorithmName] 48 | $Algorithm = [Security.Cryptography.HashAlgorithmName]::SHA256, 49 | 50 | [System.Text.Encoding] 51 | $Encoding = [System.Text.Encoding]::UTF8 52 | ) 53 | 54 | begin { 55 | $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) 56 | } 57 | process { 58 | foreach ($entry in $Text) { 59 | $inBytes = $Encoding.GetBytes($entry) 60 | $outBytes = $privateKey.SignData($inBytes, $Algorithm, $Padding) 61 | [convert]::ToBase64String($outBytes) 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/Invoke-TerminatingException.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-TerminatingException 2 | { 3 | <# 4 | .SYNOPSIS 5 | Throw a terminating exception in the context of the caller. 6 | 7 | .DESCRIPTION 8 | Throw a terminating exception in the context of the caller. 9 | Masks the actual code location from the end user in how the message will be displayed. 10 | 11 | .PARAMETER Cmdlet 12 | The $PSCmdlet variable of the calling command. 13 | 14 | .PARAMETER Message 15 | The message to show the user. 16 | 17 | .PARAMETER Exception 18 | A nested exception to include in the exception object. 19 | 20 | .PARAMETER Category 21 | The category of the error. 22 | 23 | .PARAMETER ErrorRecord 24 | A full error record that was caught by the caller. 25 | Use this when you want to rethrow an existing error. 26 | 27 | .PARAMETER Target 28 | The target of the exception. 29 | 30 | .EXAMPLE 31 | PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module' 32 | 33 | Terminates the calling command, citing an unknown caller. 34 | #> 35 | [CmdletBinding()] 36 | Param ( 37 | [Parameter(Mandatory = $true)] 38 | $Cmdlet, 39 | 40 | [string] 41 | $Message, 42 | 43 | [System.Exception] 44 | $Exception, 45 | 46 | [System.Management.Automation.ErrorCategory] 47 | $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, 48 | 49 | [System.Management.Automation.ErrorRecord] 50 | $ErrorRecord, 51 | 52 | $Target 53 | ) 54 | 55 | process{ 56 | if ($ErrorRecord -and -not $Message) { 57 | $Cmdlet.ThrowTerminatingError($ErrorRecord) 58 | } 59 | 60 | $exceptionType = switch ($Category) { 61 | default { [System.Exception] } 62 | 'InvalidArgument' { [System.ArgumentException] } 63 | 'InvalidData' { [System.IO.InvalidDataException] } 64 | 'AuthenticationError' { [System.Security.Authentication.AuthenticationException] } 65 | 'InvalidOperation' { [System.InvalidOperationException] } 66 | } 67 | 68 | 69 | if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) } 70 | elseif ($ErrorRecord) { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) } 71 | else { $newException = $exceptionType::new($Message) } 72 | $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target) 73 | $Cmdlet.ThrowTerminatingError($record) 74 | } 75 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/Set-ReconnectInfo.ps1: -------------------------------------------------------------------------------- 1 | function Set-ReconnectInfo { 2 | <# 3 | .SYNOPSIS 4 | Helper Utility to set the automatic reconnection information. 5 | 6 | .DESCRIPTION 7 | Helper Utility to set the automatic reconnection information. 8 | Registers the connection time, parameters used and command for ease of reuse. 9 | 10 | .PARAMETER BoundParameters 11 | The parameters the Connect-Graph* command was called with 12 | 13 | .PARAMETER NoReconnect 14 | Whether to not reconnect after all. 15 | 16 | .PARAMETER RefreshToken 17 | The refresh token returned after the calling command's connection. 18 | When provided, will be used to do the refreshing when possible. 19 | 20 | .EXAMPLE 21 | PS C:\> Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect 22 | 23 | Called from within a Connect-Graph* command, this will set itself to auto-reconnect. 24 | #> 25 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] 26 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 27 | [CmdletBinding()] 28 | param ( 29 | $BoundParameters, 30 | 31 | [switch] 32 | $NoReconnect, 33 | 34 | [string] 35 | $RefreshToken 36 | ) 37 | process { 38 | if ($NoReconnect) { 39 | $script:lastConnect = @{ 40 | When = $null 41 | Command = $null 42 | Parameters = $null 43 | Refresh = $null 44 | } 45 | return 46 | } 47 | $script:lastConnect = @{ 48 | When = Get-Date 49 | Command = Get-Command (Get-PSCallStack)[1].InvocationInfo.MyCommand 50 | Parameters = $BoundParameters 51 | Refresh = $null 52 | } 53 | if ($RefreshToken) { 54 | $script:lastConnect.Refresh = $RefreshToken | ConvertTo-SecureString -AsPlainText -Force 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /MiniGraph/internal/functions/Update-Token.ps1: -------------------------------------------------------------------------------- 1 | function Update-Token { 2 | <# 3 | .SYNOPSIS 4 | Automatically reconnects if necessary, using the previous method of connecting. 5 | 6 | .DESCRIPTION 7 | Automatically reconnects if necessary, using the previous method of connecting. 8 | Called from within Invoke-GraphRequest, it ensures that tokens don't expire, especially during long-running queries. 9 | 10 | Will not cause errors directly, but the reconnection attempt might fail. 11 | 12 | .EXAMPLE 13 | PS C:\> Update-Token 14 | 15 | Automatically reconnects if necessary, using the previous method of connecting. 16 | #> 17 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 18 | [CmdletBinding()] 19 | param ( 20 | 21 | ) 22 | 23 | process { 24 | # If no reconnection data is set, terminate 25 | if (-not $script:lastConnect) { return } 26 | if (-not $script:lastConnect.When) { return } 27 | # If the last connection is less than 50 minutes ago, terminate 28 | if ($script:lastConnect.When -gt (Get-Date).AddMinutes(-50)) { return } 29 | 30 | if ($script:lastConnect.Refresh) { 31 | Connect-GraphRefreshToken 32 | return 33 | } 34 | 35 | $command = $script:lastConnect.Command 36 | $param = $script:lastConnect.Parameters 37 | & $command @param 38 | } 39 | } -------------------------------------------------------------------------------- /MiniGraph/internal/scripts/variables.ps1: -------------------------------------------------------------------------------- 1 | # Graph Token used for connections 2 | $script:token = $null 3 | 4 | # Endpoint used for queries 5 | $script:baseEndpoint = 'https://graph.microsoft.com/v1.0' 6 | 7 | # Cached Connection Data 8 | $script:lastConnect = @{ 9 | When = $null 10 | Command = $null 11 | Parameters = $null 12 | Refresh = $null 13 | } 14 | 15 | # Used for Browser-Based interactive logon 16 | $script:browserPath = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' -------------------------------------------------------------------------------- /build/vsts-build.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | This script publishes the module to the gallery. 3 | It expects as input an ApiKey authorized to publish the module. 4 | 5 | Insert any build steps you may need to take before publishing it here. 6 | #> 7 | param ( 8 | $ApiKey, 9 | 10 | $WorkingDirectory, 11 | 12 | $Repository = 'PSGallery', 13 | 14 | [switch] 15 | $LocalRepo, 16 | 17 | [switch] 18 | $SkipPublish, 19 | 20 | [switch] 21 | $AutoVersion 22 | ) 23 | 24 | #region Handle Working Directory Defaults 25 | if (-not $WorkingDirectory) 26 | { 27 | if ($env:RELEASE_PRIMARYARTIFACTSOURCEALIAS) 28 | { 29 | $WorkingDirectory = Join-Path -Path $env:SYSTEM_DEFAULTWORKINGDIRECTORY -ChildPath $env:RELEASE_PRIMARYARTIFACTSOURCEALIAS 30 | } 31 | else { $WorkingDirectory = $env:SYSTEM_DEFAULTWORKINGDIRECTORY } 32 | } 33 | if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path $PSScriptRoot } 34 | #endregion Handle Working Directory Defaults 35 | 36 | # Prepare publish folder 37 | Write-Host "Creating and populating publishing directory" 38 | $publishDir = New-Item -Path $WorkingDirectory -Name publish -ItemType Directory -Force 39 | Copy-Item -Path "$($WorkingDirectory)\MiniGraph" -Destination $publishDir.FullName -Recurse -Force 40 | 41 | #region Gather text data to compile 42 | $text = @() 43 | 44 | # Gather commands 45 | Get-ChildItem -Path "$($publishDir.FullName)\MiniGraph\internal\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 46 | $text += [System.IO.File]::ReadAllText($_.FullName) 47 | } 48 | Get-ChildItem -Path "$($publishDir.FullName)\MiniGraph\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 49 | $text += [System.IO.File]::ReadAllText($_.FullName) 50 | } 51 | 52 | # Gather scripts 53 | Get-ChildItem -Path "$($publishDir.FullName)\MiniGraph\internal\scripts\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 54 | $text += [System.IO.File]::ReadAllText($_.FullName) 55 | } 56 | 57 | #region Update the psm1 file & Cleanup 58 | [System.IO.File]::WriteAllText("$($publishDir.FullName)\MiniGraph\MiniGraph.psm1", ($text -join "`n`n"), [System.Text.Encoding]::UTF8) 59 | Remove-Item -Path "$($publishDir.FullName)\MiniGraph\internal" -Recurse -Force 60 | Remove-Item -Path "$($publishDir.FullName)\MiniGraph\functions" -Recurse -Force 61 | #endregion Update the psm1 file & Cleanup 62 | 63 | #region Updating the Module Version 64 | if ($AutoVersion) 65 | { 66 | Write-Host "Updating module version numbers." 67 | try { [version]$remoteVersion = (Find-Module 'MiniGraph' -Repository $Repository -ErrorAction Stop).Version } 68 | catch 69 | { 70 | throw "Failed to access $($Repository) : $_" 71 | } 72 | if (-not $remoteVersion) 73 | { 74 | throw "Couldn't find MiniGraph on repository $($Repository) : $_" 75 | } 76 | $newBuildNumber = $remoteVersion.Build + 1 77 | [version]$localVersion = (Import-PowerShellDataFile -Path "$($publishDir.FullName)\MiniGraph\MiniGraph.psd1").ModuleVersion 78 | Update-ModuleManifest -Path "$($publishDir.FullName)\MiniGraph\MiniGraph.psd1" -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" 79 | } 80 | #endregion Updating the Module Version 81 | 82 | #region Publish 83 | if ($SkipPublish) { return } 84 | if ($LocalRepo) 85 | { 86 | # Dependencies must go first 87 | Write-Host "Creating Nuget Package for module: PSFramework" 88 | New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . 89 | Write-Host "Creating Nuget Package for module: MiniGraph" 90 | New-PSMDModuleNugetPackage -ModulePath "$($publishDir.FullName)\MiniGraph" -PackagePath . 91 | } 92 | else 93 | { 94 | # Publish to Gallery 95 | Write-Host "Publishing the MiniGraph module to $($Repository)" 96 | Publish-Module -Path "$($publishDir.FullName)\MiniGraph" -NuGetApiKey $ApiKey -Force -Repository $Repository 97 | } 98 | #endregion Publish -------------------------------------------------------------------------------- /build/vsts-prerequisites.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [string] 3 | $Repository = 'PSGallery' 4 | ) 5 | 6 | $modules = @("Pester", "PSScriptAnalyzer") 7 | 8 | # Automatically add missing dependencies 9 | $data = Import-PowerShellDataFile -Path "$PSScriptRoot\..\MiniGraph\MiniGraph.psd1" 10 | foreach ($dependency in $data.RequiredModules) { 11 | if ($dependency -is [string]) { 12 | if ($modules -contains $dependency) { continue } 13 | $modules += $dependency 14 | } 15 | else { 16 | if ($modules -contains $dependency.ModuleName) { continue } 17 | $modules += $dependency.ModuleName 18 | } 19 | } 20 | 21 | foreach ($module in $modules) { 22 | Write-Host "Installing $module" -ForegroundColor Cyan 23 | Install-Module $module -Force -SkipPublisherCheck -Repository $Repository 24 | Import-Module $module -Force -PassThru 25 | } -------------------------------------------------------------------------------- /build/vsts-validate.ps1: -------------------------------------------------------------------------------- 1 | # Run internal pester tests 2 | & "$PSScriptRoot\..\tests\pester.ps1" -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.18 (2024-03-19) 4 | 5 | + Fix: Invoke-GraphRequest - fails to execute queries starting with http 6 | 7 | ## 1.3.17 (2024-03-13) 8 | 9 | + Upd: Invoke-GraphRequest - supports full http links as query 10 | 11 | ## 1.3.16 (2024-03-05) 12 | 13 | + New: Get-GraphToken - retrieve the currently used token 14 | + Upd: Connect-GraphBrowser - opens logon screen in the default browser by default 15 | + Fix: Invoke-GraphRequestBatch - retries stop failing 16 | 17 | ## 1.3.13 (2023-12-03) 18 | 19 | + Upd: Invoke-GraphRequestBatch - simplified requests specification 20 | 21 | ## 1.3.12 (2023-12-01) 22 | 23 | + New: Connect-GraphBrowser - Interactive logon using the Authorization flow and browser. Supports SSO. 24 | + New: Invoke-GraphRequestBatch - allows executing batch requests (thanks @nyanhp) 25 | + Upd: Added support for automatic token refresh once tokens expire 26 | + Upd: Connect-GraphCertificate - added -Scopes parameter to allow retrieving token for other service than graph 27 | + Fix: DeviceCode flow fails due to change in error message (thanks @nyanhp) 28 | 29 | ## 1.2.7 (ancient times) 30 | 31 | + All the previous features 32 | -------------------------------------------------------------------------------- /nothing-to-see-here.txt: -------------------------------------------------------------------------------- 1 | Authentication flow completed, you can close the tab now. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # MiniGraph 2 | 3 | ## Introduction 4 | 5 | The MiniGraph module is designed as a minimal overhead Microsoft Graph client implementation. 6 | It is intended for lean environments such as Azure Functions where a maximum performance in all aspects is required. 7 | 8 | ## Installation 9 | 10 | The module has been published to the PowerShell Gallery. 11 | To install it, run: 12 | 13 | ```powershell 14 | Install-Module MiniGraph 15 | ``` 16 | 17 | ## Use 18 | 19 | > Authenticate 20 | 21 | First you need to authenticate. 22 | Three authentication workflows are provided: 23 | 24 | + Application: Certificate 25 | + Application: Secret 26 | + Delegate: Username & Password 27 | 28 | For example, the connection with a certificate object could work like this: 29 | 30 | ```powershell 31 | $cert = Get-Item -Path 'Cert:\CurrentUser\My\082D5CB4BA31EED7E2E522B39992E34871C92BF5' 32 | Connect-GraphCertificate -TenantID '0639f07d-76e1-49cb-82ac-abcdefabcdefa' -ClientID '0639f07d-76e1-49cb-82ac-1234567890123' -Certificate $cert 33 | ``` 34 | 35 | > Execute 36 | 37 | After connecting to graph, execute queries like this: 38 | 39 | ```powershell 40 | # Return all groups 41 | Invoke-GraphRequest -Query groups 42 | ``` 43 | 44 | You can now basically follow the guidance in the graph api reference and take it from there. 45 | 46 | > Graph Beta Endpoint 47 | 48 | If you need to work against the beta endpoint, switching to that for the current session can be done like this: 49 | 50 | ```powershell 51 | Set-GraphEndpoint -Type beta 52 | ``` 53 | 54 | ## Common Issues 55 | 56 | > Scopes 57 | 58 | Make sure you verify the scopes (permissions) needed for a request. 59 | They must be assigned as Api Permission in the registered application in the Azure portal. 60 | Admin Consent must be given for Application permissions. 61 | For delegate permissions, either Admin Consent or User Consent must have been granted, as `Connect-GraphCredential` does not support any mechanisms to request Consent. 62 | 63 | > Must contain client_assertion or client_secret 64 | 65 | This usually happens when trying to connect with credentials. 66 | The registered application must be configured for this authentication type in the authentication tab: 67 | 68 | + In Platform configurations, add a Web Platform with redirect URI "http://localhost" 69 | + In Advanced settings, enable "Allow public client flows" 70 | -------------------------------------------------------------------------------- /tests/functions/readme.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | This is where the function tests go. 4 | 5 | Make sure to put them in folders reflecting the actual module structure. 6 | 7 | It is not necessary to differentiate between internal and public functions here. -------------------------------------------------------------------------------- /tests/general/FileIntegrity.Exceptions.ps1: -------------------------------------------------------------------------------- 1 | # List of forbidden commands 2 | $global:BannedCommands = @( 3 | 'Write-Output' 4 | 5 | # Use CIM instead where possible 6 | 'Get-WmiObject' 7 | 'Invoke-WmiMethod' 8 | 'Register-WmiEvent' 9 | 'Remove-WmiObject' 10 | 'Set-WmiInstance' 11 | 12 | # Use Get-WinEvent instead 13 | 'Get-EventLog' 14 | ) 15 | 16 | <# 17 | Contains list of exceptions for banned cmdlets. 18 | Insert the file names of files that may contain them. 19 | 20 | Example: 21 | "Write-Host" = @('Write-PSFHostColor.ps1','Write-PSFMessage.ps1') 22 | #> 23 | $global:MayContainCommand = @{ 24 | "Write-Host" = @() 25 | "Write-Verbose" = @() 26 | "Write-Warning" = @() 27 | "Write-Error" = @() 28 | "Write-Output" = @() 29 | "Write-Information" = @() 30 | "Write-Debug" = @() 31 | } -------------------------------------------------------------------------------- /tests/general/FileIntegrity.Tests.ps1: -------------------------------------------------------------------------------- 1 | $moduleRoot = (Resolve-Path "$global:testroot\..").Path 2 | 3 | . "$global:testroot\general\FileIntegrity.Exceptions.ps1" 4 | 5 | Describe "Verifying integrity of module files" { 6 | BeforeAll { 7 | function Get-FileEncoding 8 | { 9 | <# 10 | .SYNOPSIS 11 | Tests a file for encoding. 12 | 13 | .DESCRIPTION 14 | Tests a file for encoding. 15 | 16 | .PARAMETER Path 17 | The file to test 18 | #> 19 | [CmdletBinding()] 20 | Param ( 21 | [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)] 22 | [Alias('FullName')] 23 | [string] 24 | $Path 25 | ) 26 | 27 | if ($PSVersionTable.PSVersion.Major -lt 6) 28 | { 29 | [byte[]]$byte = get-content -Encoding byte -ReadCount 4 -TotalCount 4 -Path $Path 30 | } 31 | else 32 | { 33 | [byte[]]$byte = Get-Content -AsByteStream -ReadCount 4 -TotalCount 4 -Path $Path 34 | } 35 | 36 | if ($byte[0] -eq 0xef -and $byte[1] -eq 0xbb -and $byte[2] -eq 0xbf) { 'UTF8 BOM' } 37 | elseif ($byte[0] -eq 0xfe -and $byte[1] -eq 0xff) { 'Unicode' } 38 | elseif ($byte[0] -eq 0 -and $byte[1] -eq 0 -and $byte[2] -eq 0xfe -and $byte[3] -eq 0xff) { 'UTF32' } 39 | elseif ($byte[0] -eq 0x2b -and $byte[1] -eq 0x2f -and $byte[2] -eq 0x76) { 'UTF7' } 40 | else { 'Unknown' } 41 | } 42 | } 43 | 44 | Context "Validating PS1 Script files" { 45 | $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object FullName -NotLike "$moduleRoot\tests\*" 46 | 47 | foreach ($file in $allFiles) 48 | { 49 | $name = $file.FullName.Replace("$moduleRoot\", '') 50 | 51 | It "[$name] Should have UTF8 encoding with Byte Order Mark" -TestCases @{ file = $file } { 52 | Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' 53 | } 54 | 55 | It "[$name] Should have no trailing space" -TestCases @{ file = $file } { 56 | ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0}).LineNumber | Should -BeNullOrEmpty 57 | } 58 | 59 | $tokens = $null 60 | $parseErrors = $null 61 | $null = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors) 62 | 63 | It "[$name] Should have no syntax errors" -TestCases @{ parseErrors = $parseErrors } { 64 | $parseErrors | Should -BeNullOrEmpty 65 | } 66 | 67 | foreach ($command in $global:BannedCommands) 68 | { 69 | if ($global:MayContainCommand["$command"] -notcontains $file.Name) 70 | { 71 | It "[$name] Should not use $command" -TestCases @{ tokens = $tokens; command = $command } { 72 | $tokens | Where-Object Text -EQ $command | Should -BeNullOrEmpty 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | Context "Validating help.txt help files" { 80 | $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike "$moduleRoot\tests\*" 81 | 82 | foreach ($file in $allFiles) 83 | { 84 | $name = $file.FullName.Replace("$moduleRoot\", '') 85 | 86 | It "[$name] Should have UTF8 encoding" -TestCases @{ file = $file } { 87 | Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' 88 | } 89 | 90 | It "[$name] Should have no trailing space" -TestCases @{ file = $file } { 91 | ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0 } | Measure-Object).Count | Should -Be 0 92 | } 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /tests/general/Help.Exceptions.ps1: -------------------------------------------------------------------------------- 1 | # List of functions that should be ignored 2 | $global:FunctionHelpTestExceptions = @( 3 | 4 | ) 5 | 6 | <# 7 | List of arrayed enumerations. These need to be treated differently. Add full name. 8 | Example: 9 | 10 | "Sqlcollaborative.Dbatools.Connection.ManagementConnectionType[]" 11 | #> 12 | $global:HelpTestEnumeratedArrays = @( 13 | 14 | ) 15 | 16 | <# 17 | Some types on parameters just fail their validation no matter what. 18 | For those it becomes possible to skip them, by adding them to this hashtable. 19 | Add by following this convention: = @() 20 | Example: 21 | 22 | "Get-DbaCmObject" = @("DoNotUse") 23 | #> 24 | $global:HelpTestSkipParameterType = @{ 25 | 26 | } 27 | -------------------------------------------------------------------------------- /tests/general/Help.Tests.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .NOTES 3 | The original test this is based upon was written by June Blender. 4 | After several rounds of modifications it stands now as it is, but the honor remains hers. 5 | 6 | Thank you June, for all you have done! 7 | 8 | .DESCRIPTION 9 | This test evaluates the help for all commands in a module. 10 | 11 | .PARAMETER SkipTest 12 | Disables this test. 13 | 14 | .PARAMETER CommandPath 15 | List of paths under which the script files are stored. 16 | This test assumes that all functions have their own file that is named after themselves. 17 | These paths are used to search for commands that should exist and be tested. 18 | Will search recursively and accepts wildcards, make sure only functions are found 19 | 20 | .PARAMETER ModuleName 21 | Name of the module to be tested. 22 | The module must already be imported 23 | 24 | .PARAMETER ExceptionsFile 25 | File in which exceptions and adjustments are configured. 26 | In it there should be two arrays and a hashtable defined: 27 | $global:FunctionHelpTestExceptions 28 | $global:HelpTestEnumeratedArrays 29 | $global:HelpTestSkipParameterType 30 | These can be used to tweak the tests slightly in cases of need. 31 | See the example file for explanations on each of these usage and effect. 32 | #> 33 | [CmdletBinding()] 34 | Param ( 35 | [switch] 36 | $SkipTest, 37 | 38 | [string[]] 39 | $CommandPath = @("$global:testroot\..\MiniGraph\functions", "$global:testroot\..\MiniGraph\internal\functions"), 40 | 41 | [string] 42 | $ModuleName = "MiniGraph", 43 | 44 | [string] 45 | $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" 46 | ) 47 | if ($SkipTest) { return } 48 | . $ExceptionsFile 49 | 50 | $CommandPath = @( 51 | "$global:testroot\..\MiniGraph\functions" 52 | "$global:testroot\..\MiniGraph\internal\functions" 53 | ) 54 | 55 | $includedNames = foreach ($path in $CommandPath) { (Get-ChildItem $path -Recurse -File | Where-Object Name -like "*.ps1").BaseName } 56 | $commandTypes = @('Cmdlet', 'Function') 57 | if ($PSVersionTable.PSEdition -eq 'Desktop' ) { $commandTypes += 'Workflow' } 58 | $commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTypes | Where-Object Name -In $includedNames 59 | 60 | ## When testing help, remember that help is cached at the beginning of each session. 61 | ## To test, restart session. 62 | 63 | 64 | foreach ($command in $commands) { 65 | $commandName = $command.Name 66 | 67 | # Skip all functions that are on the exclusions list 68 | if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } 69 | 70 | # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets 71 | $Help = Get-Help $commandName -ErrorAction SilentlyContinue 72 | 73 | Describe "Test help for $commandName" { 74 | 75 | # If help is not found, synopsis in auto-generated help is the syntax diagram 76 | It "should not be auto-generated" -TestCases @{ Help = $Help } { 77 | $Help.Synopsis | Should -Not -BeLike '*`[``]*' 78 | } 79 | 80 | # Should be a description for every function 81 | It "gets description for $commandName" -TestCases @{ Help = $Help } { 82 | $Help.Description | Should -Not -BeNullOrEmpty 83 | } 84 | 85 | # Should be at least one example 86 | It "gets example code from $commandName" -TestCases @{ Help = $Help } { 87 | ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty 88 | } 89 | 90 | # Should be at least one example description 91 | It "gets example help from $commandName" -TestCases @{ Help = $Help } { 92 | ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty 93 | } 94 | 95 | Context "Test parameter help for $commandName" { 96 | 97 | $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' 98 | 99 | $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common 100 | $parameterNames = $parameters.Name 101 | $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique 102 | foreach ($parameter in $parameters) { 103 | $parameterName = $parameter.Name 104 | $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName 105 | 106 | # Should be a description for every parameter 107 | It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { 108 | $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty 109 | } 110 | 111 | $codeMandatory = $parameter.IsMandatory.toString() 112 | It "help for $parameterName parameter in $commandName has correct Mandatory value" -TestCases @{ parameterHelp = $parameterHelp; codeMandatory = $codeMandatory } { 113 | $parameterHelp.Required | Should -Be $codeMandatory 114 | } 115 | 116 | if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } 117 | 118 | $codeType = $parameter.ParameterType.Name 119 | 120 | if ($parameter.ParameterType.IsEnum) { 121 | # Enumerations often have issues with the typename not being reliably available 122 | $names = $parameter.ParameterType::GetNames($parameter.ParameterType) 123 | # Parameter type in Help should match code 124 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { 125 | $parameterHelp.parameterValueGroup.parameterValue | Should -be $names 126 | } 127 | } 128 | elseif ($parameter.ParameterType.FullName -in $HelpTestEnumeratedArrays) { 129 | # Enumerations often have issues with the typename not being reliably available 130 | $names = [Enum]::GetNames($parameter.ParameterType.DeclaredMembers[0].ReturnType) 131 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { 132 | $parameterHelp.parameterValueGroup.parameterValue | Should -be $names 133 | } 134 | } 135 | else { 136 | # To avoid calling Trim method on a null object. 137 | $helpType = if ($parameterHelp.parameterValue) { $parameterHelp.parameterValue.Trim() } 138 | # Parameter type in Help should match code 139 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ helpType = $helpType; codeType = $codeType } { 140 | $helpType | Should -be $codeType 141 | } 142 | } 143 | } 144 | foreach ($helpParm in $HelpParameterNames) { 145 | # Shouldn't find extra parameters in help. 146 | It "finds help parameter in code: $helpParm" -TestCases @{ helpParm = $helpParm; parameterNames = $parameterNames } { 147 | $helpParm -in $parameterNames | Should -Be $true 148 | } 149 | } 150 | } 151 | } 152 | } -------------------------------------------------------------------------------- /tests/general/Manifest.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe "Validating the module manifest" { 2 | $moduleRoot = (Resolve-Path "$global:testroot\..\MiniGraph").Path 3 | $manifest = ((Get-Content "$moduleRoot\MiniGraph.psd1") -join "`n") | Invoke-Expression 4 | Context "Basic resources validation" { 5 | $files = Get-ChildItem "$moduleRoot\functions" -Recurse -File | Where-Object Name -like "*.ps1" 6 | It "Exports all functions in the public folder" -TestCases @{ files = $files; manifest = $manifest } { 7 | 8 | $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '<=').InputObject 9 | $functions | Should -BeNullOrEmpty 10 | } 11 | It "Exports no function that isn't also present in the public folder" -TestCases @{ files = $files; manifest = $manifest } { 12 | $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '=>').InputObject 13 | $functions | Should -BeNullOrEmpty 14 | } 15 | 16 | It "Exports none of its internal functions" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { 17 | $files = Get-ChildItem "$moduleRoot\internal\functions" -Recurse -File -Filter "*.ps1" 18 | $files | Where-Object BaseName -In $manifest.FunctionsToExport | Should -BeNullOrEmpty 19 | } 20 | } 21 | 22 | Context "Individual file validation" { 23 | It "The root module file exists" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { 24 | Test-Path "$moduleRoot\$($manifest.RootModule)" | Should -Be $true 25 | } 26 | 27 | foreach ($format in $manifest.FormatsToProcess) 28 | { 29 | It "The file $format should exist" -TestCases @{ moduleRoot = $moduleRoot; format = $format } { 30 | Test-Path "$moduleRoot\$format" | Should -Be $true 31 | } 32 | } 33 | 34 | foreach ($type in $manifest.TypesToProcess) 35 | { 36 | It "The file $type should exist" -TestCases @{ moduleRoot = $moduleRoot; type = $type } { 37 | Test-Path "$moduleRoot\$type" | Should -Be $true 38 | } 39 | } 40 | 41 | foreach ($assembly in $manifest.RequiredAssemblies) 42 | { 43 | if ($assembly -like "*.dll") { 44 | It "The file $assembly should exist" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { 45 | Test-Path "$moduleRoot\$assembly" | Should -Be $true 46 | } 47 | } 48 | else { 49 | It "The file $assembly should load from the GAC" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { 50 | { Add-Type -AssemblyName $assembly } | Should -Not -Throw 51 | } 52 | } 53 | } 54 | 55 | foreach ($tag in $manifest.PrivateData.PSData.Tags) 56 | { 57 | It "Tags should have no spaces in name" -TestCases @{ tag = $tag } { 58 | $tag -match " " | Should -Be $false 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /tests/general/PSScriptAnalyzer.Tests.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | Param ( 3 | [switch] 4 | $SkipTest, 5 | 6 | [string[]] 7 | $CommandPath = @("$global:testroot\..\MiniGraph\functions", "$global:testroot\..\MiniGraph\internal\functions") 8 | ) 9 | 10 | if ($SkipTest) { return } 11 | 12 | $global:__pester_data.ScriptAnalyzer = New-Object System.Collections.ArrayList 13 | 14 | Describe 'Invoking PSScriptAnalyzer against commandbase' { 15 | $commandFiles = foreach ($path in $CommandPath) { Get-ChildItem -Path $path -Recurse | Where-Object Name -like "*.ps1" } 16 | $scriptAnalyzerRules = Get-ScriptAnalyzerRule 17 | 18 | foreach ($file in $commandFiles) 19 | { 20 | Context "Analyzing $($file.BaseName)" { 21 | $analysis = Invoke-ScriptAnalyzer -Path $file.FullName -ExcludeRule PSAvoidTrailingWhitespace, PSShouldProcess 22 | 23 | forEach ($rule in $scriptAnalyzerRules) 24 | { 25 | It "Should pass $rule" -TestCases @{ analysis = $analysis; rule = $rule } { 26 | If ($analysis.RuleName -contains $rule) 27 | { 28 | $analysis | Where-Object RuleName -EQ $rule -outvariable failures | ForEach-Object { $null = $global:__pester_data.ScriptAnalyzer.Add($_) } 29 | 30 | 1 | Should -Be 0 31 | } 32 | else 33 | { 34 | 0 | Should -Be 0 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tests/pester.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | $TestGeneral = $true, 3 | 4 | $TestFunctions = $true, 5 | 6 | [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] 7 | [Alias('Show')] 8 | $Output = "None", 9 | 10 | $Include = "*", 11 | 12 | $Exclude = "" 13 | ) 14 | 15 | Write-Host "Starting Tests" 16 | 17 | Write-Host "Importing Module" 18 | 19 | $global:testroot = $PSScriptRoot 20 | $global:__pester_data = @{ } 21 | 22 | Remove-Module MiniGraph -ErrorAction Ignore 23 | Import-Module "$PSScriptRoot\..\MiniGraph\MiniGraph.psd1" 24 | Import-Module "$PSScriptRoot\..\MiniGraph\MiniGraph.psm1" -Force 25 | 26 | # Need to import explicitly so we can use the configuration class 27 | Import-Module Pester 28 | 29 | Write-Host "Creating test result folder" 30 | $null = New-Item -Path "$PSScriptRoot\.." -Name TestResults -ItemType Directory -Force 31 | 32 | $totalFailed = 0 33 | $totalRun = 0 34 | 35 | $testresults = @() 36 | $config = [PesterConfiguration]::Default 37 | $config.TestResult.Enabled = $true 38 | 39 | #region Run General Tests 40 | if ($TestGeneral) 41 | { 42 | Write-Host "Modules imported, proceeding with general tests" 43 | foreach ($file in (Get-ChildItem "$PSScriptRoot\general" | Where-Object Name -like "*.Tests.ps1")) 44 | { 45 | if ($file.Name -notlike $Include) { continue } 46 | if ($file.Name -like $Exclude) { continue } 47 | 48 | Write-Host " Executing $($file.Name)" 49 | $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" 50 | $config.Run.Path = $file.FullName 51 | $config.Run.PassThru = $true 52 | $config.Output.Verbosity = $Output 53 | $results = Invoke-Pester -Configuration $config 54 | foreach ($result in $results) 55 | { 56 | $totalRun += $result.TotalCount 57 | $totalFailed += $result.FailedCount 58 | $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { 59 | $testresults += [pscustomobject]@{ 60 | Block = $_.Block 61 | Name = "It $($_.Name)" 62 | Result = $_.Result 63 | Message = $_.ErrorRecord.DisplayErrorMessage 64 | } 65 | } 66 | } 67 | } 68 | } 69 | #endregion Run General Tests 70 | 71 | $global:__pester_data.ScriptAnalyzer | Out-Host 72 | 73 | #region Test Commands 74 | if ($TestFunctions) 75 | { 76 | Write-Host "Proceeding with individual tests" 77 | foreach ($file in (Get-ChildItem "$PSScriptRoot\functions" -Recurse -File | Where-Object Name -like "*Tests.ps1")) 78 | { 79 | if ($file.Name -notlike $Include) { continue } 80 | if ($file.Name -like $Exclude) { continue } 81 | 82 | Write-Host " Executing $($file.Name)" 83 | $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" 84 | $config.Run.Path = $file.FullName 85 | $config.Run.PassThru = $true 86 | $config.Output.Verbosity = $Output 87 | $results = Invoke-Pester -Configuration $config 88 | foreach ($result in $results) 89 | { 90 | $totalRun += $result.TotalCount 91 | $totalFailed += $result.FailedCount 92 | $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { 93 | $testresults += [pscustomobject]@{ 94 | Block = $_.Block 95 | Name = "It $($_.Name)" 96 | Result = $_.Result 97 | Message = $_.ErrorRecord.DisplayErrorMessage 98 | } 99 | } 100 | } 101 | } 102 | } 103 | #endregion Test Commands 104 | 105 | $testresults | Sort-Object Describe, Context, Name, Result, Message | Format-List 106 | 107 | if ($totalFailed -eq 0) { Write-Host "All $totalRun tests executed without a single failure!" } 108 | else { Write-Host "$totalFailed tests out of $totalRun tests failed!" } 109 | 110 | if ($totalFailed -gt 0) 111 | { 112 | throw "$totalFailed / $totalRun tests failed!" 113 | } -------------------------------------------------------------------------------- /tests/readme.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | This is the folder, where all the tests go. 4 | 5 | Those are subdivided in two categories: 6 | 7 | - General 8 | - Function 9 | 10 | ## General Tests 11 | 12 | General tests are function generic and test for general policies. 13 | 14 | These test scan answer questions such as: 15 | 16 | - Is my module following my style guides? 17 | - Does any of my scripts have a syntax error? 18 | - Do my scripts use commands I do not want them to use? 19 | - Do my commands follow best practices? 20 | - Do my commands have proper help? 21 | 22 | Basically, these allow a general module health check. 23 | 24 | These tests are already provided as part of the template. 25 | 26 | ## Function Tests 27 | 28 | A healthy module should provide unit and integration tests for the commands & components it ships. 29 | Only then can be guaranteed, that they will actually perform as promised. 30 | 31 | However, as each such test must be specific to the function it tests, there cannot be much in the way of templates. --------------------------------------------------------------------------------