diff --git a/CHANGELOG.md b/CHANGELOG.md index d480d6506..e8c637ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - AzureDevOpsDsc + - Azure Managed Identity supporting classes. These classes are used by `AzureDevOpsDsc.Common`. - Updated pipeline files to support change of default branch to main. - Added GitHub issue templates and pull request template ([issue #1](https://github.com/dsccommunity/AzureDevOpsDsc/issues/1)) @@ -29,7 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 file `Home.md` will be updated with the correct module version on each publish to gallery (including preview). - AzureDevOpsDsc.Common + - Managed Identity has been added to the system. This feature can be used before invoking Invoke-DSCResource. With New-AzManagedIdentity, the bearer token is automatically authenticated, retrieved, and managed. It’s worth noting that bearer tokens take precedence over basic tokens. When using Invoke-AzDevOpsApiRestMethod, the token is automatically interpolated as required. - Added 'wrapper' functionality around the [Azure DevOps REST API](https://docs.microsoft.com/en-us/rest/api/azure/devops/) + - Added Supporting Functions for Azure Managed Identity. ### Changed diff --git a/source/Classes/000.ManagedIdentityDataResources.ps1 b/source/Classes/000.ManagedIdentityDataResources.ps1 new file mode 100644 index 000000000..4da713ddc --- /dev/null +++ b/source/Classes/000.ManagedIdentityDataResources.ps1 @@ -0,0 +1,16 @@ +data AzManagedIdentityLocalizedData { + + ConvertFrom-StringData @' + Global_AzureDevOps_Resource_Id=499b84ac-1321-427f-aa17-267ca6975798 + Global_Url_Azure_Instance_Metadata_Url=http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={0} + Global_API_Azure_DevOps_Version=api-version=6.0 + Global_Url_AZDO_Project=https://dev.azure.com/{0}/_apis/projects + Error_ManagedIdentity_RestApiCallFailed=Error. Failed to call the Azure Instance Metadata Service. Please ensure that the Azure Instance Metadata Service is available. Error Details: {0} + Error_Azure_Instance_Metadata_Service_Missing_Token=Error. Access token not returned from Azure Instance Metadata Service. Please ensure that the Azure Instance Metadata Service is available. + Error_Azure_API_Call_Generic=Error. Failed to call the Azure DevOps API. Details: {0} + Error_Azure_Get_AzManagedIdentity_Invalid_Caller=Error. Get-AzManagedIdentity can only be called from New-AzManagedIdentity or Update-AzManagedIdentity. +'@ + +} + +New-Variable -Name AzManagedIdentityLocalizedData -Value $AzManagedIdentityLocalizedData -Option ReadOnly -Scope Global -Force diff --git a/source/Classes/003.AzDevOpsDscResourceBase.ps1 b/source/Classes/003.AzDevOpsDscResourceBase.ps1 index ccfa36b98..81df0d005 100644 --- a/source/Classes/003.AzDevOpsDscResourceBase.ps1 +++ b/source/Classes/003.AzDevOpsDscResourceBase.ps1 @@ -11,6 +11,7 @@ class AzDevOpsDscResourceBase : AzDevOpsApiDscResourceBase [DscProperty()] [Alias('PersonalAccessToken')] + [Alias('AccessToken')] [System.String] $Pat diff --git a/source/Classes/004.ManagedIdentityToken.ps1 b/source/Classes/004.ManagedIdentityToken.ps1 new file mode 100644 index 000000000..c5a2c5d5c --- /dev/null +++ b/source/Classes/004.ManagedIdentityToken.ps1 @@ -0,0 +1,106 @@ + + +Class ManagedIdentityToken { + + [SecureString]$access_token + [DateTime]$expires_on + [Int]$expires_in + [String]$resource + [String]$token_type + hidden [bool]$linux = $IsLinux + + # Constructor + ManagedIdentityToken([PSCustomObject]$ManagedIdentityTokenObj) { + + # Validate that ManagedIdentityTokenObj is a HashTable and Contains the correct keys + if (-not $this.isValid($ManagedIdentityTokenObj)) { throw "The ManagedIdentityTokenObj is not valid." } + + $epochStart = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + + # Set the properties of the class + $this.access_token = $ManagedIdentityTokenObj.access_token | ConvertTo-SecureString -AsPlainText -Force + $this.expires_on = $epochStart.AddSeconds($ManagedIdentityTokenObj.expires_on) + $this.expires_in = $ManagedIdentityTokenObj.expires_in + $this.resource = $ManagedIdentityTokenObj.resource + $this.token_type = $ManagedIdentityTokenObj.token_type + + } + + # Function to validate the ManagedIdentityTokenObj + Hidden [Bool]isValid($ManagedIdentityTokenObj) { + + # Assuming these are the keys we expect in the hashtable + $expectedKeys = @('access_token', 'expires_on', 'expires_in', 'resource', 'token_type') + + # Check if all expected keys exist in the hashtable + foreach ($key in $expectedKeys) { + if (-not $ManagedIdentityTokenObj."$key") { + Write-Verbose "[ManagedIdentityToken] The hashtable does not contain the expected property: $key" + return $false + } + } + + # If all checks pass, return true + Write-Verbose "[ManagedIdentityToken] The hashtable is valid and contains all the expected keys." + return $true + } + + # Function to convert a SecureString to a String + hidden [String]ConvertFromSecureString([SecureString]$SecureString) { + # Convert a SecureString to a String + $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + $String = ($this.linux) ? [System.Runtime.InteropServices.Marshal]::PtrToStringUni($BSTR) : + [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) + return $String + } + + # Function to test the call stack + hidden [Bool]TestCallStack([String]$name) { + + $CallStack = Get-PSCallStack + + # Check if any of the callers in the call stack is Invoke-DSCResource + foreach ($stackFrame in $callStack) { + if ($stackFrame.Command -eq $name) { + Write-Verbose "[ManagedIdentityToken] The calling function is $name." + return $true + } + } + + return $false + + } + + [Bool]isExpired() { + # Remove 10 seconds from the expires_on time to account for clock skew. + if ($this.expires_on.AddSeconds(-10) -lt (Get-Date)) { return $true } + return $false + } + + # Return the access token + [String] Get() { + + # Prevent Execution and Writing to Files and Pipeline Variables. + + # Token can only be called within Invoke-AzDevOpsApiRestMethod. Test to see if the calling function is Invoke-AzDevOpsApiRestMethod + if (-not($this.TestCallStack('Invoke-AzDevOpsApiRestMethod'))) { throw "[ManagedIdentityToken] The Get() method can only be called within Invoke-AzDevOpsApiRestMethod." } + # Token cannot be returned within a Write-* function. Test to see if the calling function is Write-* + if ($this.TestCallStack('Write-')) { throw "[ManagedIdentityToken] The Get() method cannot be called within a Write-* function." } + # Token cannot be written to a file. Test to see if the calling function is Out-File + if ($this.TestCallStack('Out-File')) { throw "[ManagedIdentityToken] The Get() method cannot be called within Out-File." } + + # Return the access token + return ($this.ConvertFromSecureString($this.access_token)) + + } + +} + +# Function to create a new ManagedIdentityToken object +Function global:New-ManagedIdentityToken ([hashtable]$ManagedIdentityTokenObj) { + + # Create and return a new ManagedIdentityToken object + return [ManagedIdentityToken]::New($ManagedIdentityTokenObj) + +} diff --git a/source/Classes/005.APIRateLimit.ps1 b/source/Classes/005.APIRateLimit.ps1 new file mode 100644 index 000000000..5887f9be0 --- /dev/null +++ b/source/Classes/005.APIRateLimit.ps1 @@ -0,0 +1,51 @@ +Class APIRateLimit { + + [Int]$retryAfter = 0 + [Int]$xRateLimitRemaining = 0 + [Int]$xRateLimitReset = 0 + + # Constructor + APIRateLimit([HashTable]$APIRateLimitObj) { + + # Validate that APIRateLimitObj is a HashTable and Contains the correct keys + if (-not $this.isValid($APIRateLimitObj)) { throw "The APIRateLimitObj is not valid." } + + # Convert X-RateLimit-Reset from Unix Time to DateTime + $epochStart = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + + # Set the properties of the class + $this.retryAfter = [Int]($APIRateLimitObj.'Retry-After') + $this.XRateLimitRemaining = [Int]$APIRateLimitObj.'X-RateLimit-Remaining' + $this.XRateLimitReset = [Int]($APIRateLimitObj.'X-RateLimit-Reset') + + } + + # Constructor with retryAfter Parameters + APIRateLimit($retryAfter) { + + # Set the properties of the class + $this.retryAfter = [int]$retryAfter + + } + + Hidden [Bool]isValid($APIRateLimitObj) { + + # Assuming these are the keys we expect in the hashtable + $expectedKeys = @('Retry-After', 'X-RateLimit-Remaining', 'X-RateLimit-Reset') + + # Check if all expected keys exist in the hashtable + foreach ($key in $expectedKeys) { + if (-not $APIRateLimitObj.ContainsKey($key)) { + Write-Error "[APIRateLimit] The hashtable does not contain the expected key: $key" + return $false + } + } + + # If all checks pass, return true + Write-Verbose "[APIRateLimit] The hashtable is valid and contains all the expected keys." + return $true + + } + + +} diff --git a/source/Examples/Resources/AzDevOpsProject/4-AddProject-UsingManaged-Identity.ps1 b/source/Examples/Resources/AzDevOpsProject/4-AddProject-UsingManaged-Identity.ps1 new file mode 100644 index 000000000..3e140392d --- /dev/null +++ b/source/Examples/Resources/AzDevOpsProject/4-AddProject-UsingManaged-Identity.ps1 @@ -0,0 +1,43 @@ + +<# + .DESCRIPTION + This example shows how to ensure that the Azure DevOps project + called 'Test Project' exists (or is added if it does not exist). + This example uses Invoke-DSCResource to authenticate to Azure DevOps using a Managed Identity. +#> + +Configuration Example +{ + param + ( + [Parameter(Mandatory = $true)] + [string] + $ApiUri + ) + + Import-DscResource -ModuleName 'AzureDevOpsDsc' + + node localhost + { + AzDevOpsProject 'AddProject' + { + Ensure = 'Present' + + ApiUri = $ApiUri + Pat = $Pat + + ProjectName = 'Test Project' + ProjectDescription = 'A Test Project' + + SourceControlType = 'Git' + } + + } +} + +# Create a new Azure Managed Identity and store the token in a global variable +New-AzManagedIdentity -OrganizationName "Contoso" + +# Using Invoke-DSCResource, invoke the 'Test' method of the 'AzDevOpsProject' resource. +# The global variable will be used to authenticate to Azure DevOps. +Invoke-DscResource -Name Example -Method Test -Verbose diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzDevOpsApiVersion.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzDevOpsApiVersion.ps1 index 370c89943..f867af536 100644 --- a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzDevOpsApiVersion.ps1 +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzDevOpsApiVersion.ps1 @@ -19,15 +19,15 @@ function Get-AzDevOpsApiVersion $Default ) - [string]$defaultApiVersion = '6.0' + [string]$defaultApiVersion = '6.1' [string[]]$apiVersions = @( #'4.0', # Not supported #'5.0', # Not supported #'5.1', # Not supported - '6.0' - #'6.1', # Not supported + '6.0', + '6.1' ) diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzManagedIdentityToken.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzManagedIdentityToken.ps1 new file mode 100644 index 000000000..3f60ccd20 --- /dev/null +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Get-AzManagedIdentityToken.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS +Obtains a managed identity token from Azure AD. + +.DESCRIPTION +The Get-AzManagedIdentityToken function is used to obtain an access token from Azure AD using a managed identity. It can only be called from the New-AzManagedIdentity or Update-AzManagedIdentity functions. + +.PARAMETER OrganizationName +Specifies the name of the organization. + +.PARAMETER Verify +Specifies whether to verify the connection. If this switch is not set, the function returns the managed identity token. If the switch is set, the function tests the connection and returns the access token. + +.EXAMPLE +Get-AzManagedIdentityToken -OrganizationName "Contoso" -Verify +Obtains the access token for the managed identity associated with the organization "Contoso" and verifies the connection. + +.NOTES +This function does not require the Azure PowerShell module. +#> + +Function Get-AzManagedIdentityToken { + [CmdletBinding()] + param ( + # Organization Name + [Parameter(Mandatory)] + [String] + $OrganizationName, + + # Verify the Connection + [Parameter()] + [Switch] + $Verify + ) + + # Obtain the access token from Azure AD using the Managed Identity + + $ManagedIdentityParams = @{ + # Define the Azure instance metadata endpoint to get the access token + Uri = $AzManagedIdentityLocalizedData.Global_Url_Azure_Instance_Metadata_Url -f $AzManagedIdentityLocalizedData.Global_AzureDevOps_Resource_Id + Method = 'Get' + Headers = @{Metadata="true"} + ContentType = 'Application/json' + } + + # Invoke the RestAPI + try { $response = Invoke-AzDevOpsApiRestMethod @ManagedIdentityParams } catch { Throw $_ } + # Test the response + if ($null -eq $response.access_token) { throw $AzManagedIdentityLocalizedData.Error_Azure_Instance_Metadata_Service_Missing_Token } + + # TypeCast the response to a ManagedIdentityToken object + $ManagedIdentity = New-ManagedIdentityToken -ManagedIdentityTokenObj $response + # Null the response + $null = $response + + # Return the token if the verify switch is not set + if (-not($verify)) { return $ManagedIdentity } + + # Test the Connection + if (-not(Test-AzManagedIdentityToken $ManagedIdentity)) { throw $AzManagedIdentityLocalizedData.Error_Azure_API_Call_Generic } + + # Return the AccessToken + return ($ManagedIdentity) + +} diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.ps1 index d2b147534..2f1d458bd 100644 --- a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.ps1 +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.ps1 @@ -89,11 +89,12 @@ function Invoke-AzDevOpsApiRestMethod ) $invokeRestMethodParameters = @{ - Uri = $ApiUri - Method = $HttpMethod - Headers = $HttpHeaders - Body = $HttpBody - ContentType = $HttpContentType + Uri = $ApiUri + Method = $HttpMethod + Headers = $HttpHeaders + Body = $HttpBody + ContentType = $HttpContentType + ResponseHeadersVariable = 'responseHeaders' } # Remove the 'Body' and 'ContentType' if not relevant to request @@ -108,12 +109,79 @@ function Invoke-AzDevOpsApiRestMethod while ($CurrentNoOfRetryAttempts -lt $RetryAttempts) { + + # + # Slow down the retry attempts if the API resource is close to being overwelmed + + # If there are any retry attempts, wait for the specified number of seconds before retrying + if ($Global:DSCAZDO_APIRateLimit.retryAfter -ge 0) + { + Write-Verbose -Message ("[Invoke-AzDevOpsApiRestMethod] Waiting for {0} seconds before retrying." -f $Global:DSCAZDO_APIRateLimit.retryAfter) + Start-Sleep -Seconds $Global:DSCAZDO_APIRateLimit.retryAfter + } + + # If the API resouce is close to beig overwelmed, wait for the specified number of seconds before sending the request + if (($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -le 50) -and ($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -ge 5)) + { + Write-Verbose -Message "[Invoke-AzDevOpsApiRestMethod] Resource is close to being overwelmed. Waiting for $RetryIntervalMs seconds before sending the request." + Start-Sleep -Milliseconds $RetryIntervalMs + } + # If the API resouce is overwelmed, wait for the specified number of seconds before sending the request + elseif ($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -lt 5) + { + Write-Verbose -Message ("[Invoke-AzDevOpsApiRestMethod] Resource is overwelmed. Waiting for {0} seconds to reset the TSTUs." -f $Global:DSCAZDO_APIRateLimit.xRateLimitReset) + Start-Sleep -Milliseconds $RetryIntervalMs + } + + # + # Test if a Managed Identity Token is required and if so, add it to the HTTP Headers + if ($Global:DSCAZDO_ManagedIdentityToken -ne $null) + { + # Test if the Managed Identity Token has expired + if ($Global:DSCAZDO_ManagedIdentityToken.isExpired()) + { + # If so, get a new token + $Global:DSCAZDO_ManagedIdentityToken = Update-AzManagedIdentityToken -OrganizationName $Global:DSCAZDO_OrganizationName + } + + # Add the Managed Identity Token to the HTTP Headers + $invokeRestMethodParameters.Headers.Authorization = 'Bearer {0}' -f $Global:DSCAZDO_ManagedIdentityToken.Get() + } + + + # + # Invoke the REST method + try { - return Invoke-RestMethod @invokeRestMethodParameters + $result = Invoke-RestMethod @invokeRestMethodParameters + + # Update + $Global:DSCAZDO_APIRateLimit = $null + return $result + } catch { + + # Check to see if it is an HTTP 429 (Too Many Requests) error + if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::TooManyRequests) + { + # If so, wait for the specified number of seconds before retrying + $retryAfter = $_.Exception.Response.Headers.'Retry-After' + if ($retryAfter) + { + $retryAfter = [int]$retryAfter + Write-Verbose -Message "Received a 'Too Many Requests' response from the Azure DevOps API. Waiting for $retryAfter seconds before retrying." + $Global:DSCAZDO_APIRateLimit = [APIRateLimit]::New($_.Exception.Response.Headers) + } else { + # If the Retry-After header is not present, wait for the specified number of milliseconds before retrying + Write-Verbose -Message "Received a 'Too Many Requests' response from the Azure DevOps API. Waiting for $RetryIntervalMs milliseconds before retrying." + $Global:DSCAZDO_APIRateLimit = [APIRateLimit]::New($RetryIntervalMs) + } + + } + # Increment the number of retries attempted and obtain any exception message $CurrentNoOfRetryAttempts++ $restMethodExceptionMessage = $_.Exception.Message diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzDevOpsApiHttpRequestHeader.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzDevOpsApiHttpRequestHeader.ps1 index f52ff871b..531c39f37 100644 --- a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzDevOpsApiHttpRequestHeader.ps1 +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzDevOpsApiHttpRequestHeader.ps1 @@ -5,6 +5,8 @@ NOTE: Use of the '-IsValid' switch is required. + PAT Tokens and Managed Identity Tokens are allowed. + .PARAMETER HttpRequestHeader The 'HttpRequestHeader' to be tested/validated. @@ -36,7 +38,14 @@ function Test-AzDevOpsApiHttpRequestHeader $IsValid ) + # if Metadata is specifed within the header then it is a Managed Identity Token request + # and is valid. + + if ($HttpRequestHeader.Metadata) { return $true } + + # Otherwise, if the header is not valid, retrun false + return !($null -eq $HttpRequestHeader -or $null -eq $HttpRequestHeader.Authorization -or - $HttpRequestHeader.Authorization -inotlike 'Basic *') + $HttpRequestHeader.Authorization -match '^(Basic|Bearer):\s.+$') } diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzManagedIdentityToken.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzManagedIdentityToken.ps1 new file mode 100644 index 000000000..79c97b483 --- /dev/null +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Test-AzManagedIdentityToken.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Tests the Azure Managed Identity token for accessing Azure DevOps REST API. + +.DESCRIPTION + The Test-AzManagedIdentityToken function is used to test the Azure Managed Identity token for accessing the Azure DevOps REST API. + It calls the Azure DevOps REST API with the provided Managed Identity token and returns true if the token is valid, otherwise returns false. + +.PARAMETER ManagedIdentity + Specifies the Managed Identity token to be tested. + +.EXAMPLE + $token = Get-AzManagedIdentityToken -ResourceId 'https://management.azure.com' + $isValid = Test-AzManagedIdentityToken -ManagedIdentity $token + if ($isValid) { + Write-Host "Token is valid." + } else { + Write-Host "Token is invalid." + } +#> + +Function Test-AzManagedIdentityToken { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [Object] + $ManagedIdentity + ) + + # Define the Azure DevOps REST API endpoint to get the list of projects + $AZDOProjectUrl = $AzManagedIdentityLocalizedData.Global_Url_AZDO_Project -f $GLOBAL:DSCAZDO_OrganizationName + $FormattedUrl = "{0}?api-version=7.2-preview.4" -f $AZDOProjectUrl + + $params = @{ + Uri = $FormattedUrl + Method = 'Get' + Headers = @{ + Authorization ="Bearer {0}" -f $ManagedIdentity.Get() + } + } + + # Call the Azure DevOps REST API with the Managed Identity Bearer token + try { + $null = Invoke-AzDevOpsApiRestMethod @params + } catch { + return $false + } + + return $true + +} diff --git a/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Update-AzManagedIdentity.ps1 b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Update-AzManagedIdentity.ps1 new file mode 100644 index 000000000..4a7ae4333 --- /dev/null +++ b/source/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Update-AzManagedIdentity.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS +Updates the Azure Managed Identity. + +.DESCRIPTION +This function updates the Azure Managed Identity by refreshing the token. + +.PARAMETER OrganizationName +The name of the organization associated with the Managed Identity. + +.EXAMPLE +Update-AzManagedIdentity -OrganizationName "Contoso" + +This example updates the Azure Managed Identity for the organization named "Contoso". + +#> + +Function Update-AzManagedIdentity { + + # Test if the Global Var's Exist $Global:DSCAZDO_OrganizationName + if ($null -eq $Global:DSCAZDO_OrganizationName) { + Throw "[Update-AzManagedIdentity] Organization Name is not set. Please run 'New-AzManagedIdentity -OrganizationName '" + } + + # Clear the existing token. + $Global:DSCAZDO_ManagedIdentityToken = $null + + # Refresh the Token. + $Global:DSCAZDO_ManagedIdentityToken = Get-AzManagedIdentityToken -OrganizationName $Global:DSCAZDO_OrganizationName + +} diff --git a/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psd1 b/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psd1 index f10821fd2..cc2f6cc0e 100644 --- a/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psd1 +++ b/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psd1 @@ -22,6 +22,8 @@ # 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. FunctionsToExport = @( + 'New-AzManagedIdentity', + 'Get-AzDevOpsServicesUri', 'Get-AzDevOpsServicesApiUri', @@ -32,7 +34,9 @@ 'New-AzDevOpsProject', 'Set-AzDevOpsProject', 'Remove-AzDevOpsProject', - 'Test-AzDevOpsProject' + 'Test-AzDevOpsProject', + + 'New-AzManagedIdentity' ) # 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. diff --git a/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psm1 b/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psm1 index f47986a47..71e157ea2 100644 --- a/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psm1 +++ b/source/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.psm1 @@ -1,3 +1,4 @@ +#using module AzureDevOpsDsc # Setup/Import 'DscResource.Common' helper module #$script:resourceHelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Modules\DscResource.Common' #Import-Module -Name $script:resourceHelperModulePath diff --git a/source/Modules/AzureDevOpsDsc.Common/Resources/Functions/Public/New-AzManagedIdentity.ps1 b/source/Modules/AzureDevOpsDsc.Common/Resources/Functions/Public/New-AzManagedIdentity.ps1 new file mode 100644 index 000000000..c18ce9d77 --- /dev/null +++ b/source/Modules/AzureDevOpsDsc.Common/Resources/Functions/Public/New-AzManagedIdentity.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS +Creates a new Azure Managed Identity. + +.DESCRIPTION +The New-AzManagedIdentity function creates a new Azure Managed Identity for use in Azure DevOps DSC. + +.PARAMETER OrganizationName +Specifies the name of the organization associated with the Azure Managed Identity. + +.EXAMPLE +New-AzManagedIdentity -OrganizationName "Contoso" + +This example creates a new Azure Managed Identity for the organization named "Contoso". + +#> +Function New-AzManagedIdentity { + + [CmdletBinding()] + param ( + [Parameter()] + [Alias('OrgName')] + [String] + $OrganizationName + ) + + $Global:DSCAZDO_OrganizationName = $OrganizationName + $Global:DSCAZDO_ManagedIdentityToken = $null + + # If the Token is not Valid. Get a new Token. + $Global:DSCAZDO_ManagedIdentityToken = Get-AzManagedIdentityToken -OrganizationName $OrganizationName -Verify + +} diff --git a/tests/Unit/Classes/ManagedIdentity/APIRateLimit.tests.ps1 b/tests/Unit/Classes/ManagedIdentity/APIRateLimit.tests.ps1 new file mode 100644 index 000000000..8307e59d0 --- /dev/null +++ b/tests/Unit/Classes/ManagedIdentity/APIRateLimit.tests.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Test suite for the APIRateLimit class. + +.DESCRIPTION + This test suite validates the functionality of the APIRateLimit class, ensuring it properly handles valid and invalid inputs. +#> + +# Initialize tests for module function +. $PSScriptRoot\..\Classes.TestInitialization.ps1 + +InModuleScope 'AzureDevOpsDsc' { + + Describe "APIRateLimit Class Tests" { + + It "Throws an exception when initialized with an invalid HashTable (missing keys)" { + # Arrange + $invalidHashTable = @{ 'Retry-After' = 120 } + # Act / Assert + { [APIRateLimit]::new($invalidHashTable) } | Should -Throw "The APIRateLimitObj is not valid." + } + + It "Does not throw an exception when initialized with a valid HashTable" { + # Arrange + $validHashTable = @{ + 'Retry-After' = 120 + 'X-RateLimit-Remaining' = 10 + 'X-RateLimit-Reset' = 1583000000 + } + # Act / Assert + { [APIRateLimit]::new($validHashTable) } | Should -Not -Throw + } + + It "Correctly sets the properties when initialized with a valid HashTable" { + # Arrange + $validHashTable = @{ + 'Retry-After' = 120 + 'X-RateLimit-Remaining' = 10 + 'X-RateLimit-Reset' = 1583000000 + } + $expectedEpochTime = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc).AddSeconds(1583000000) + # Act + $apiRateLimit = [APIRateLimit]::new($validHashTable) + # Assert + $apiRateLimit.retryAfter | Should -Be 120 + $apiRateLimit.XRateLimitRemaining | Should -Be 10 + $apiRateLimit.XRateLimitReset | Should -Be 1583000000 + } + + It "Initializes with only retryAfter parameter correctly" { + # Arrange + $retryAfterValue = 300 + # Act + $apiRateLimit = [APIRateLimit]::new($retryAfterValue) + # Assert + $apiRateLimit.retryAfter | Should -Be $retryAfterValue + $apiRateLimit.XRateLimitRemaining | Should -Be 0 + $apiRateLimit.XRateLimitReset | Should -Be 0 + } + + It "isValid method returns false when HashTable is missing keys" { + # Arrange + $incompleteHashTable = @{ 'Retry-After' = 120 } + $apiRateLimit = [APIRateLimit]::new(0) + # Act + $result = $apiRateLimit.isValid($incompleteHashTable) + # Assert + $result | Should -Be $false + } + + It "isValid method returns true when HashTable has all required keys" { + # Arrange + $completeHashTable = @{ + 'Retry-After' = 120 + 'X-RateLimit-Remaining' = 10 + 'X-RateLimit-Reset' = 1583000000 + } + $apiRateLimit = [APIRateLimit]::new(0) + # Act + $result = $apiRateLimit.isValid($completeHashTable) + # Assert + $result | Should -Be $true + } + } + +} +# Note: To execute this test suite, save it to a file named 'APIRateLimit.Tests.ps1' and run it using Pester. diff --git a/tests/Unit/Classes/ManagedIdentity/ManagedIdentityToken.tests.ps1 b/tests/Unit/Classes/ManagedIdentity/ManagedIdentityToken.tests.ps1 new file mode 100644 index 000000000..9512b78d1 --- /dev/null +++ b/tests/Unit/Classes/ManagedIdentity/ManagedIdentityToken.tests.ps1 @@ -0,0 +1,96 @@ +# Initialize tests for module function + +. $PSScriptRoot\..\Classes.TestInitialization.ps1 + +<# +.SYNOPSIS + Test suite for the ManagedIdentityToken class. + +.DESCRIPTION + This test suite validates the functionality of the ManagedIdentityToken class, ensuring it handles various scenarios correctly. +#> + +InModuleScope 'AzureDevOpsDsc' { + + Describe "ManagedIdentityToken Class Tests" { + + It "Throws an exception when invalid token object is provided" { + { [ManagedIdentityToken]::new(@{}) } | Should -Throw "The ManagedIdentityTokenObj is not valid." + } + + It "Creates a ManagedIdentityToken object with valid properties" { + # Arrange + $tokenProperties = @{ + access_token = "test_access_token" + expires_on = 3600 # Assuming this is seconds since epoch + expires_in = 3600 + resource = "https://my.resource.com" + token_type = "Bearer" + } + # Act + $tokenObject = [ManagedIdentityToken]::new((New-Object PSCustomObject -Property $tokenProperties)) + # Assert + $tokenObject.access_token | Should -Not -BeNullOrEmpty + $tokenObject.expires_on | Should -BeOfType [DateTime] + $tokenObject.expires_in | Should -Be 3600 + $tokenObject.resource | Should -Be "https://my.resource.com" + $tokenObject.token_type | Should -Be "Bearer" + } + + It "Determines if a token is expired" { + # Arrange + $tokenProperties = @{ + access_token = "test_access_token" + expires_on = (Get-Date).AddSeconds(-20).ToUniversalTime().Subtract([datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalSeconds + expires_in = 3600 + resource = "https://my.resource.com" + token_type = "Bearer" + } + $tokenObject = [ManagedIdentityToken]::new((New-Object PSCustomObject -Property $tokenProperties)) + # Act / Assert + $tokenObject.isExpired() | Should -BeTrue + } + + It "Gets the access token when called from an allowed method" { + Mock Get-PSCallStack { + return @( + @{Command="Invoke-AzDevOpsApiRestMethod"} + ) + } + # Arrange + $tokenProperties = @{ + access_token = "test_access_token" + expires_on = 3600 # Assuming this is seconds since epoch + expires_in = 3600 + resource = "https://my.resource.com" + token_type = "Bearer" + } + $tokenObject = [ManagedIdentityToken]::new((New-Object PSCustomObject -Property $tokenProperties)) + # Act + $accessToken = $tokenObject.Get() + # Assert + $accessToken | Should -Be "test_access_token" + } + + It "Throws an exception when Get method is called from a disallowed method" { + Mock Get-PSCallStack { + return @( + @{Command="Write-Host"} + ) + } + # Arrange + $tokenProperties = @{ + access_token = "test_access_token" + expires_on = 3600 # Assuming this is seconds since epoch + expires_in = 3600 + resource = "https://my.resource.com" + token_type = "Bearer" + } + $tokenObject = [ManagedIdentityToken]::new((New-Object PSCustomObject -Property $tokenProperties)) + # Act / Assert + try { $tokenObject.Get() } catch { $exception = $_ } + $exception | Should -Be '[ManagedIdentityToken] The Get() method can only be called within Invoke-AzDevOpsApiRestMethod.' + } + } + +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.Tests.ps1 index 5ffa3bbc0..6d9a19585 100644 --- a/tests/Unit/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.Tests.ps1 +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Invoke-AzDevOpsApiRestMethod.Tests.ps1 @@ -194,3 +194,158 @@ InModuleScope 'AzureDevOpsDsc.Common' { } } } +# Existing code... + +Describe "$script:subModuleName\Api\Function\$script:commandName" -Tag $script:tag { + + # Existing code... + + Context 'When input parameters are valid' { + + # Existing code... + + Context 'When called just with mandatory, "ApiUri", "HttpMethod" and "HttpRequestHeader" parameters' { + + # Existing code... + + It 'Should not throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader } | Should -Not -Throw + } + + It 'Should output nothing/null - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + $output = Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader + + $output | Should -BeNullOrEmpty + } + + It 'Should invoke "Invoke-RestMethod" exactly once - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + Mock Invoke-RestMethod {} -Verifiable + + Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader + + Assert-MockCalled Invoke-RestMethod -Times 1 -Exactly -Scope 'It' + } + + Context 'When "Invoke-RestMethod" throws an exception on every retry' { + Mock Invoke-RestMethod { throw "Some exception" } + + It 'Should invoke "Invoke-RestMethod" number of times equal to "RetryAttempts" parameter value + 1 - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + Mock Invoke-RestMethod { throw "Some exception" } -Verifiable + Mock New-InvalidOperationException {} + + Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader + + Assert-MockCalled Invoke-RestMethod -Times $($defaultRetryAttempts+1) -Exactly -Scope 'It' + } + + + It 'Should invoke "Start-Sleep" number of times equal to "RetryAttempts" parameter value + 1 - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + Mock Start-Sleep { } -Verifiable + + Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader + + Assert-MockCalled Start-Sleep -Times $($defaultRetryAttempts+1) -Exactly -Scope 'It' + } + + + It 'Should invoke "New-InvalidOperationException" exactly once - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + Mock New-InvalidOperationException {} -Verifiable + + Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader + + Assert-MockCalled New-InvalidOperationException -Times 1 -Exactly -Scope 'It' + } + + } + + } + } + + + Context 'When input parameters are invalid' { + + # Existing code... + + Context 'When called without mandatory, "ApiUri" parameter' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $null -HttpMethod $HttpMethod -HttpRequestHeader $HttpRequestHeader } | Should -Throw + } + + } + + Context 'When called without mandatory, "HttpMethod" parameter' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $null -HttpRequestHeader $HttpRequestHeader } | Should -Throw + } + + } + + Context 'When called without mandatory, "ApiUri" and "HttpMethod" parameters' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $null -HttpMethod $null -HttpRequestHeader $HttpRequestHeader } | Should -Throw + } + + } + + Context 'When called without mandatory, "HttpRequestHeader" parameter' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $HttpMethod -HttpRequestHeader $null } | Should -Throw + } + + } + + Context 'When called without mandatory, "ApiUri" and "HttpRequestHeader" parameters' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $null -HttpMethod $HttpMethod -HttpRequestHeader $null } | Should -Throw + } + + } + + Context 'When called without mandatory, "HttpMethod" and "HttpRequestHeader" parameters' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $ApiUri -HttpMethod $null -HttpRequestHeader $null } | Should -Throw + } + + } + + Context 'When called without mandatory, "ApiUri", "HttpMethod" and "HttpRequestHeader" parameters' { + + It 'Should throw - "", "", ""' -TestCases $testCasesValidApiUriHttpMethodHttpRequestHeaders3 { + param ([System.String]$ApiUri, [System.String]$HttpMethod, [Hashtable]$HttpRequestHeader) + + { Invoke-AzDevOpsApiRestMethod -ApiUri $null -HttpMethod $null -HttpRequestHeader $null } | Should -Throw + } + + } + } +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.TestInitialization.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.TestInitialization.ps1 index aeea1dc63..8fda79eaa 100644 --- a/tests/Unit/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.TestInitialization.ps1 +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/AzureDevOpsDsc.Common.TestInitialization.ps1 @@ -3,20 +3,43 @@ Automated unit test for classes in AzureDevOpsDsc. #> -Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '\..\Modules\TestHelpers\CommonTestHelper.psm1') -Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '\..\Modules\TestHelpers\CommonTestCases.psm1') + +Function Split-RecurivePath { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $false)] + [int]$Times = 1 + ) + + 1 .. $Times | ForEach-Object { + $Path = Split-Path -Path $Path -Parent + } + + $Path +} + + +$script:RepositoryRoot = Split-RecurivePath $PSScriptRoot -Times 4 + +Import-Module -Name (Join-Path -Path $script:RepositoryRoot -ChildPath '/tests/Unit/Modules/TestHelpers/CommonTestCases.psm1') +Import-Module -Name (Join-Path -Path $script:RepositoryRoot -ChildPath '/tests/Unit/Modules/TestHelpers/CommonTestHelper.psm1') + +Set-OutputDirAsModulePath -RepositoryRoot $script:RepositoryRoot $script:dscModuleName = 'AzureDevOpsDsc' $script:dscModule = Get-Module -Name $script:dscModuleName -ListAvailable | Select-Object -First 1 $script:dscModuleFile = $($script:dscModule.ModuleBase +'\'+ $script:dscModuleName + ".psd1") + Get-Module -Name $script:dscModuleName -All | Remove-Module $script:dscModuleName -Force -ErrorAction SilentlyContinue $script:subModuleName = 'AzureDevOpsDsc.Common' -Import-Module -Name $script:dscModuleFile -Force +Import-Module -Name $script:dscModuleFile #-Force Get-Module -Name $script:subModuleName -All | Remove-Module -Force -ErrorAction SilentlyContinue $script:subModulesFolder = Join-Path -Path $script:dscModule.ModuleBase -ChildPath 'Modules' $script:subModuleFile = Join-Path $script:subModulesFolder "$($script:subModuleName)/$($script:subModuleName).psd1" -Import-Module -Name $script:subModuleFile -Force #-Verbose +Import-Module -Name $script:subModuleFile #-Force #-Verbose diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Get-AzManagedIdentityToken.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Get-AzManagedIdentityToken.Tests.ps1 new file mode 100644 index 000000000..9f2fe9b00 --- /dev/null +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Get-AzManagedIdentityToken.Tests.ps1 @@ -0,0 +1,72 @@ +<# +.SYNOPSIS + Test suite for the Get-AzManagedIdentityToken function. + +.DESCRIPTION + This test suite validates the functionality of the Get-AzManagedIdentityToken function, ensuring it handles various scenarios correctly. +#> + +. $PSScriptRoot\..\..\..\AzureDevOpsDsc.Common.TestInitialization.ps1 + +InModuleScope 'AzureDevOpsDsc.Common' { + + Describe "Get-AzManagedIdentityToken Function Tests" { + + BeforeAll { + + Import-Module 'AzureDevOpsDsc' + + Mock Invoke-AzDevOpsApiRestMethod { + return @{ + access_token = 'mock_access_token' + expires_on = 1000 + expires_in = 1000 + resource = "STRING" + token_type = "bearer" + } + } + + Mock Test-AzManagedIdentityToken { + return $true + } + + } + + It "Obtains a token without verifying if the Verify switch is not set" { + # Arrange + $organizationName = "Contoso" + # Act + $token = Get-AzManagedIdentityToken -OrganizationName $organizationName + # Assert + $token.access_token | Should -BeOfType [System.Security.SecureString] + } + + It "Verifies the connection and obtains a token when the Verify switch is set" { + # Arrange + $organizationName = "Contoso" + # Act / Assert + { Get-AzManagedIdentityToken -OrganizationName $organizationName -Verify } | Should -Not -Throw + } + + It "Throws an exception if the Invoke-AzDevOpsApiRestMethod does not return an access token" { + # Arrange + Mock Invoke-AzDevOpsApiRestMethod { + return @{} + } + $organizationName = "Contoso" + # Act / Assert + { Get-AzManagedIdentityToken -OrganizationName $organizationName } | Should -Throw + } + + It "Throws an exception if the Test-AzManagedIdentityToken returns false" { + # Arrange + Mock Test-AzManagedIdentityToken { + return $false + } + $organizationName = "Contoso" + # Act / Assert + { Get-AzManagedIdentityToken -OrganizationName $organizationName -Verify } | Should -Throw + } + } + +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzDevOpsApiResource.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzDevOpsApiResource.Tests.ps1 new file mode 100644 index 000000000..b5c06decb --- /dev/null +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzDevOpsApiResource.Tests.ps1 @@ -0,0 +1,9 @@ +Describe "New-AzDevOpsApiResource" { + Context "When ApiVersion parameter is provided" { + It "Should set the ApiVersion property" { + $apiVersion = "v1" + $resource = New-AzDevOpsApiResource -ApiVersion $apiVersion + $resource.ApiVersion | Should Be $apiVersion + } + } +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzManagedIdentity.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzManagedIdentity.Tests.ps1 new file mode 100644 index 000000000..bba7c3d70 --- /dev/null +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/New-AzManagedIdentity.Tests.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS + Test suite for the New-AzManagedIdentity function. + +.DESCRIPTION + This test suite validates the functionality of the New-AzManagedIdentity function, ensuring it sets global variables correctly and handles token acquisition. +#> + +# Initialize tests for module function +. $PSScriptRoot\..\..\..\AzureDevOpsDsc.Common.TestInitialization.ps1 + +InModuleScope 'AzureDevOpsDsc.Common' { + + Describe "New-AzManagedIdentity Function Tests" { + + BeforeAll { + + Mock Get-AzManagedIdentityToken { + return @{ + access_token = "mocked_access_token" + expires_on = (Get-Date).AddHours(1).ToUniversalTime().Subtract([datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalSeconds + expires_in = 3600 + resource = "https://management.azure.com/" + token_type = "Bearer" + } + } + } + + It "Sets the global organization name and managed identity token" { + # Arrange + $orgName = "TestOrganization" + + # Act + New-AzManagedIdentity -OrganizationName $orgName + + # Assert + $Global:DSCAZDO_OrganizationName | Should -Be $orgName + $Global:DSCAZDO_ManagedIdentityToken | Should -Not -Be $null + $Global:DSCAZDO_ManagedIdentityToken.access_token | Should -Be "mocked_access_token" + } + + It "Sets the global managed identity token to null if Get-AzManagedIdentityToken fails" { + # Arrange + Mock Get-AzManagedIdentityToken { throw "Failed to get token." } + $orgName = "TestOrganization" + + # Act / Assert + { New-AzManagedIdentity -OrganizationName $orgName } | Should -Throw "Failed to get token." + $Global:DSCAZDO_ManagedIdentityToken | Should -Be $null + } + } + +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Test-AzManagedIdentityToken.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Test-AzManagedIdentityToken.Tests.ps1 new file mode 100644 index 000000000..c38435312 --- /dev/null +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Test-AzManagedIdentityToken.Tests.ps1 @@ -0,0 +1,59 @@ +# Initialize tests for module function + +. $PSScriptRoot\..\..\..\AzureDevOpsDsc.Common.TestInitialization.ps1 + +<# +.SYNOPSIS + Test suite for the Test-AzManagedIdentityToken function. + +.DESCRIPTION + This test suite validates the functionality of the Test-AzManagedIdentityToken function, ensuring it properly tests the managed identity token. +#> + +InModuleScope 'AzureDevOpsDsc.Common' { + Describe "Test-AzManagedIdentityToken Function Tests" { + + Mock Invoke-AzDevOpsApiRestMethod { + return @{ + value = @("Project1", "Project2") + } + } + + BeforeAll { + # Define a mock Managed Identity object with a Get method + $mockManagedIdentity = New-Object -TypeName psobject + $mockManagedIdentity | Add-Member -MemberType ScriptMethod -Name Get -Value { return "mocked_access_token" } + + # Set up a global variable as expected by the function + $GLOBAL:DSCAZDO_OrganizationName = "MockOrganization" + } + + It "Returns true when the managed identity token is valid" { + # Arrange + $AzManagedIdentityLocalizedData = @{ + Global_Url_AZDO_Project = "https://dev.azure.com/{0}/_apis/projects" + } + + # Act + $result = Test-AzManagedIdentityToken -ManagedIdentity $mockManagedIdentity + + # Assert + $result | Should -Be $true + } + + It "Returns false when the managed identity token is not valid" { + # Arrange + Mock Invoke-AzDevOpsApiRestMethod { throw "Unauthorized access." } + $AzManagedIdentityLocalizedData = @{ + Global_Url_AZDO_Project = "https://dev.azure.com/{0}/_apis/projects" + } + + # Act + $result = Test-AzManagedIdentityToken -ManagedIdentity $mockManagedIdentity + + # Assert + $result | Should -Be $false + } + } + +} diff --git a/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Update-AzManagedIdentity.Tests.ps1 b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Update-AzManagedIdentity.Tests.ps1 new file mode 100644 index 000000000..6f4faecdf --- /dev/null +++ b/tests/Unit/Modules/AzureDevOpsDsc.Common/Connection/Functions/Private/Update-AzManagedIdentity.Tests.ps1 @@ -0,0 +1,28 @@ +# Initialize tests for module function +. $PSScriptRoot\..\..\..\AzureDevOpsDsc.Common.TestInitialization.ps1 + +InModuleScope 'AzureDevOpsDsc.Common' { + + Describe 'Update-AzManagedIdentity' { + + Context 'When OrganizationName is set' { + + It 'Should update the global variable DSCAZDO_ManagedIdentityToken' { + $organizationName = 'MyOrganization' + $global:DSCAZDO_OrganizationName = $organizationName + + Update-AzManagedIdentity + + $global:DSCAZDO_ManagedIdentityToken | Should -Not -Be $null + } + } + + Context 'When OrganizationName is not set' { + + It 'Should throw an error' { + { Update-AzManagedIdentity } | Should -Throw + } + } + } + +} diff --git a/tests/Unit/Modules/TestHelpers/CommonTestHelper.psm1 b/tests/Unit/Modules/TestHelpers/CommonTestHelper.psm1 index e809c4c45..a07d32939 100644 --- a/tests/Unit/Modules/TestHelpers/CommonTestHelper.psm1 +++ b/tests/Unit/Modules/TestHelpers/CommonTestHelper.psm1 @@ -249,3 +249,22 @@ function Get-CommandParameterSetParameter Where-Object { $_.Name -like $ParameterSetName}).Parameters } + + +function Set-OutputDirAsModulePath +{ + [CmdletBinding()] + param ( + [Parameter()] + [String] + $RepositoryRoot + ) + + # Set the module path if it is not already set + if ($ENV:PSModulePath -like "*$($RepositoryRoot)*") { return } + + $ModulePath = '{0}{1}\' -f (($IsLinux) ? ':' : ';'), $RepositoryRoot + $ENV:PSModulePath = "{0}{1}\output" -f $ENV:PSModulePath, $ModulePath + $ENV:PSModulePath = "{0}{1}\output\AzureDevOpsDsc\0.0.0\Modules" -f $ENV:PSModulePath, $ModulePath + +}