diff --git a/.github/PSModule.yml b/.github/PSModule.yml index 6d578178e..0e0770314 100644 --- a/.github/PSModule.yml +++ b/.github/PSModule.yml @@ -1,3 +1,17 @@ Test: CodeCoverage: PercentTarget: 50 +# TestResults: +# Skip: true +# SourceCode: +# Skip: true +# PSModule: +# Skip: true +# Module: +# Windows: +# Skip: true +# MacOS: +# Skip: true +# Build: +# Docs: +# Skip: true diff --git a/.github/workflows/Linter.yml b/.github/workflows/Linter.yml index 6c163eb41..8017f6334 100644 --- a/.github/workflows/Linter.yml +++ b/.github/workflows/Linter.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/README.md b/README.md index f8286fb13..c84fbb0f4 100644 --- a/README.md +++ b/README.md @@ -70,34 +70,7 @@ Press Enter to open github.com in your browser...: #-> Press enter and paste th After this you will need to install the GitHub App on the repos you want to manage. You can do this by visiting the [PowerShell for GitHub](https://github.com/apps/powershell-for-github) app page. -> Info: We will be looking to include this as a check in the module in the future. So it becomes a part of the regular sign in process. -Consecutive runs of the `Connect-GitHubAccount` will not require you to paste the code again unless you revoke the token -or you change the type of authentication you want to use. Instead, it checks the remaining duration of the access token and -uses the refresh token to get a new access token if its less than 4 hours remaining. - -```powershell -Connect-GitHubAccount -✓ Access token is still valid for 05:30:41 ... -✓ Logged in as octocat! -``` - -This is also happening automatically when you run a command that requires authentication. The validity of the token is checked before the command is executed. -If it is no longer valid, the token is refreshed and the command is executed. - -```powershell -Connect-GitHubAccount -⚠ Access token remaining validity 01:22:31. Refreshing access token... -✓ Logged in as octocat! -``` - -If the timer has gone out, we still have your back. It will just refresh as long as the refresh token is valid. - -```powershell -Connect-GitHubAccount -⚠ Access token expired. Refreshing access token... -✓ Logged in as octocat! -``` #### Personal authentication - User access tokens with OAuth app @@ -154,6 +127,48 @@ Connect-GitHubAccount -ClientID $ClientID -PrivateKey $PrivateKey Using this approach, the module will autogenerate a JWT every time you run a command. I.e. Get-GitHubApp. +#### Using a GitHub App with Azure Key Vault + +For enhanced security, you can store your GitHub App's keys in Azure Key Vault and use that as way to signing the JWTs. +This approach requires a pre-authenticated session with either Azure CLI or Azure PowerShell. + +**Prerequisites:** +- Azure CLI authenticated session (`az login`) or Azure PowerShell authenticated session (`Connect-AzAccount`) +- GitHub App private key stored as a key in Azure Key Vault, with 'Sign' as a permitted operation +- Appropriate permissions to read keys from the Key Vault, like 'Key Vault Crypto User' + +**Using Azure CLI authentication:** + +```powershell +# Ensure you're authenticated with Azure CLI +az login + +# Connect using Key Vault key reference (URI with or without version) +Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key' +✓ Logged in as my-github-app! +``` + +**Using Azure PowerShell authentication:** + +```powershell +# Ensure you're authenticated with Azure PowerShell +Connect-AzAccount + +# Connect using Key Vault key reference (URI with or without version) +Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key' +✓ Logged in as my-github-app! +``` + +**Using Key Vault key reference with version:** + +```powershell +# Connect using Key Vault key reference with specific version +Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key/abc123def456' +✓ Logged in as my-github-app! +``` + +This method ensures that your private key is securely stored in Azure Key Vault and never exposed in your scripts or configuration files. + #### Using a different host If you are using GitHub Enterprise, you can use the `-Host` (or `-HostName`) parameter to specify the host you want to connect to. @@ -181,6 +196,15 @@ Connect-GitHubAccount -Host 'https://msx.ghe.com' -ClientID 'lv123456789' ✓ Logged in as octocat! ``` +#### Automatic token renewal + +The module automatically manages short‑lived tokens for GitHub Apps: + +- User access tokens (when you authenticate via a GitHub App) are short‑lived and include a refresh token. The module refreshes them automatically before/when they expire—no extra steps are required. +- App JWTs (when the context is a GitHub App) are generated and rotated automatically per call as needed. You never need to create or renew the JWT yourself. + +Note: Long‑lived tokens like classic/fine‑grained PATs and provided installation tokens (GH_TOKEN/GITHUB_TOKEN) are not refreshed by the module. + ### Command Exploration Familiarize yourself with the available cmdlets using the module's comprehensive documentation or inline help. diff --git a/examples/Apps/AppManagement.ps1 b/examples/Apps/AppManagement.ps1 deleted file mode 100644 index 677f9e0c3..000000000 --- a/examples/Apps/AppManagement.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -# Install an app on the entire enterprise -$appIDs = @( - 'Iv1.f26b61bc99e69405' -) -$orgs = Get-GitHubOrganization -Enterprise 'msx' -foreach ($org in $orgs) { - foreach ($appID in $appIDs) { - Install-GitHubAppOnEnterpriseOrganization -Enterprise msx -Organization $org.login -ClientID $appID -RepositorySelection all - } -} diff --git a/examples/Apps/EnterpriseApps.ps1 b/examples/Apps/EnterpriseApps.ps1 index 2526b7b2d..eed2507df 100644 --- a/examples/Apps/EnterpriseApps.ps1 +++ b/examples/Apps/EnterpriseApps.ps1 @@ -1,40 +1,23 @@ -$appIDs = @( - 'qweqweqwe', - 'qweqweqweqwe' -) - -$organization = '*' -filter Install-GithubApp { - param( - [Parameter()] - [string] $Enterprise = 'msx', +$ClientID = '' +$PrivateKey = @' +-----BEGIN RSA PRIVATE KEY----- - [Parameter()] - [string] $Organization = '*', +-----END RSA PRIVATE KEY----- +'@ +Connect-GitHub -ClientID $ClientID -PrivateKey $PrivateKey +Connect-GitHubApp -Enterprise 'msx' - [Parameter( - Mandatory, - ValueFromPipeline - )] - [string] $AppID - ) +# The apps you want to install on orgs in the enterprise +$ClientIDs = @( + 'Iv1.f26b61bc99e69405' +) +$Enterprise = 'msx' +$Organization = '*' - process { - $installableOrgs = Get-GitHubOrganization -Enterprise $Enterprise - $orgs = $installableOrgs | Where-Object { $_.login -like $organization } - foreach ($org in $orgs) { - foreach ($appIDitem in $AppID) { - Install-GitHubApp -Enterprise $Enterprise -Organization $org.login -ClientID $appIDitem -RepositorySelection all | ForEach-Object { - [PSCustomObject]@{ - Organization = $org.login - AppID = $appIDitem - } - } - } - } +$installableOrgs = Get-GitHubOrganization -Enterprise $Enterprise +$orgs = $installableOrgs | Where-Object { $_.Name -like $Organization } +foreach ($org in $orgs) { + foreach ($ClientID in $ClientIDs) { + Install-GitHubApp -Enterprise $Enterprise -Organization $org.Name -ClientID $ClientID -RepositorySelection all } } - -$appIDs | Install-GitHubApp -Organization $organization - -$installation = Get-GitHubAppInstallation diff --git a/examples/Apps/UninstallingApps.ps1 b/examples/Apps/UninstallingApps.ps1 new file mode 100644 index 000000000..5b0c9995d --- /dev/null +++ b/examples/Apps/UninstallingApps.ps1 @@ -0,0 +1,77 @@ +## The uninstall function is context aware and will use the context to determine the type of uninstallation. +## We can uninstall either as a GitHub App or as an Enterprise installation. +## Examples assume that the GitHub App that is performing the uninstalls is the current context. +## See 'Connection' examples to find how to connect as a GitHub App. + + +## +## As a GitHub App you can uninstall the app from any target where it is currently installed. +## + +# First get info about the app installations for the authenticated app. +$installations = Get-GitHubAppInstallation +$installations + +# Uninstall the app installation by name. This will string match with the target of the installation. +Uninstall-GitHubApp -Target 'msx' # Enterprise +Uninstall-GitHubApp -Target 'org-123' # Organization +Uninstall-GitHubApp -Target 'octocat' # User + +# Uninstall the app installation by ID. This will do an exact match with the installation ID. +Uninstall-GitHubApp -Target 12345 + +# Uninstall the app using the installation objects from the pipeline. +Get-GitHubAppInstallation | Uninstall-GitHubApp + +# Uninstall the app from all Users using an installation object array. +$installations = Get-GitHubAppInstallation | Where-Object Type -EQ 'User' +$installations | Uninstall-GitHubApp + +### +### Full example, uninstalling an app from deleted organizations in an enterprise. +### +# Get the installations for all organizations where the app is installed. +$orgInstallations = Get-GitHubAppInstallation | Where-Object Type -EQ 'Organization' + +# Connect to the enterprise using a management app that can manage installations to get the available organizations in the enterprise. +$enterpriseContext = Connect-GitHubApp -Enterprise 'msx' -PassThru +$orgs = Get-GitHubOrganization -Enterprise 'msx' -Context $enterpriseContext + +# Uninstall the app from all organizations that are not in the list of available organizations. +$orgInstallations | Where-Object { $_.Target -notin $orgs.Name } | Uninstall-GitHubApp + +## +## As an enterprise installation, you can uninstall any app that is installed on an organization in the enterprise. +## + +# Get the installations for the organizations in the enterprise that we can manage. +$orgInstallations = Get-GitHubAppInstallation | Where-Object Type -EQ 'Organization' + +# Connect to the enterprise using a management app that can manage installations and store it in a variable. +$enterpriseContext = Connect-GitHubApp -Enterprise 'msx' -PassThru + +# Get the available organizations in the enterprise. +$orgs = Get-GitHubOrganization -Enterprise 'msx' -Context $enterpriseContext + +# Lets say we want to uninstall a specific app from all organizations in the enterprise. +# We can do this by iterating over the installations that we manage and uninstall the app. +$appToUninstall = 'psmodule-enterprise-app' +foreach ($managedOrg in $orgInstallations) { + Uninstall-GitHubApp -Target $managedOrg.Target -AppSlug $appToUninstall -Context $enterpriseContext +} + +# Uninstall an app installation by name. +Uninstall-GitHubApp -Target 'msx' -AppName 'my-app' + +$installations | Uninstall-GitHubApp -Target 'enterprise-name' + +# Uninstall an app installation by object. +$installations | Uninstall-GitHubApp + + +# Uninstall an app installation by ID. +Uninstall-GitHubApp -Target 12345 + + +Uninstall-GitHubApp -Target 'fnxsd' -ID 1234567890 +Uninstall-GitHubApp -Target 'fnxsd' -Slug 'my-app-slug' diff --git a/examples/Connecting.ps1 b/examples/Connecting.ps1 index 810b6d05a..005786ce0 100644 --- a/examples/Connecting.ps1 +++ b/examples/Connecting.ps1 @@ -7,22 +7,30 @@ Connect-GitHub # Log on to a specific instance of GitHub (enterprise) Connect-GitHub -Host 'msx.ghe.com' -Get-GitHubRepository -Context 'msx.ghe.com/MariusStorhaug' # Contexts should be selectable/overrideable on any call +Get-GitHubRepository -Context 'msx.ghe.com/MariusStorhaug' # Contexts are selectable/overrideable on any call -# Connect to GitHub interactively using OAuth App and Device Flow (should not use this, should we even support it?) +# Connect to GitHub interactively using OAuth App and Device Flow. Connect-GitHub -Mode 'OAuthApp' -Scope 'gist read:org repo workflow' -# Connect to GitHub interactively using less desired PAT flow +# Connect to GitHub interactively using less desired PAT flow, supports both fine-grained and classic PATs Connect-GitHub -UseAccessToken +# Connect to GitHub programatically (GitHub App Installation Access Token or PAT) +Connect-GitHub -Token *********** + # Connect to GitHub programatically (GitHub Actions) Connect-GitHub # Looks for the GITHUB_TOKEN variable -# Connect to GitHub programatically (GitHub App, for GitHub Actions or external applications, JWT login) +# Connect using a GitHub App and its private key (local signing of JWT) Connect-GitHub -ClientID '' -PrivateKey '' -# Connect to GitHub programatically (GitHub App Installation Access Token or PAT) -Connect-GitHub -Token *********** +# Connect using a GitHub App and the Key vault for signing the JWT. +# Prereq: The private key is stored in an Azure Key Vault and the shell has an authenticated Azure PowerShell or Azure CLI session +$ClientID = 'Iv23lieHcDQDwVV3alK1' +$KeyVaultKeyReference = 'https://psmodule-test-vault.vault.azure.net/keys/psmodule-ent-app' +Connect-GitHub -ClientID $ClientID -KeyVaultKeyReference $KeyVaultKeyReference +Connect-GitHubApp -Organization 'dnb-tooling' + ### ### Contexts / Profiles @@ -37,14 +45,12 @@ Get-GitHubContext -ListAvailable # Returns a specific context, autocomplete the name. Get-GitHubContext -Context 'msx.ghe.com/MariusStorhaug' -# Take a name dynamically from Get-GitHubContext? Autocomplete the name +# Take a name dynamically from Get-GitHubContext? tab-complete the name Switch-GitHubContext -Context 'msx.ghe.com/MariusStorhaug' # Set a specific context as the default context using pipeline 'msx.ghe.com/MariusStorhaug' | Switch-GitHubContext -Get-GitHubContext -Context 'github.com/MariusStorhaug' | Switch-GitHubContext - # Abstraction layers on GitHubContexts Get-GitHubContext -Context 'msx.ghe.com/MariusStorhaug' diff --git a/src/classes/public/Artifacts/GitHubArtifact.ps1 b/src/classes/public/Artifacts/GitHubArtifact.ps1 index 988c09931..08a498899 100644 --- a/src/classes/public/Artifacts/GitHubArtifact.ps1 +++ b/src/classes/public/Artifacts/GitHubArtifact.ps1 @@ -9,7 +9,7 @@ [string] $Repository # The size of the artifact in bytes. - [int64] $Size + [uint64] $Size # The API URL for accessing the artifact. [string] $Url @@ -43,7 +43,9 @@ $this.Name = $Object.name $this.Owner = $Owner $this.Repository = $Repository - $this.Size = $Object.size_in_bytes + if ($null -ne $Object.size_in_bytes) { + $this.Size = [uint64]$Object.size_in_bytes + } $this.Url = "https://$($Context.HostName)/$Owner/$Repository/actions/runs/$($Object.workflow_run.id)/artifacts/$($Object.id)" $this.ArchiveDownloadUrl = $Object.archive_download_url $this.Expired = $Object.expired diff --git a/src/classes/public/Config/GitHubConfig.ps1 b/src/classes/public/Config/GitHubConfig.ps1 index ce745b2de..ac8ffd8b8 100644 --- a/src/classes/public/Config/GitHubConfig.ps1 +++ b/src/classes/public/Config/GitHubConfig.ps1 @@ -35,9 +35,6 @@ # The default value for retry interval in seconds. [System.Nullable[int]] $RetryInterval - # The tolerance time in seconds for JWT token validation. - [System.Nullable[int]] $JwtTimeTolerance - # The environment type, which is used to determine the context of the GitHub API calls. [string] $EnvironmentType diff --git a/src/classes/public/Context/GitHubContext.ps1 b/src/classes/public/Context/GitHubContext.ps1 index 8788d051b..7d437c6de 100644 --- a/src/classes/public/Context/GitHubContext.ps1 +++ b/src/classes/public/Context/GitHubContext.ps1 @@ -82,6 +82,7 @@ $this.UserName = $Object.UserName $this.Token = $Object.Token $this.TokenType = $Object.TokenType + $this.TokenExpiresAt = $Object.TokenExpiresAt $this.Enterprise = $Object.Enterprise $this.Owner = $Object.Owner $this.Repository = $Object.Repository diff --git a/src/classes/public/Context/GitHubContext/GitHubAppContext.ps1 b/src/classes/public/Context/GitHubContext/GitHubAppContext.ps1 index 6f9995e5b..02cc68a67 100644 --- a/src/classes/public/Context/GitHubContext/GitHubAppContext.ps1 +++ b/src/classes/public/Context/GitHubContext/GitHubAppContext.ps1 @@ -5,6 +5,9 @@ # The private key for the app. [securestring] $PrivateKey + # Azure Key Vault key reference for JWT signing (alternative to PrivateKey). + [string] $KeyVaultKeyReference + # Owner of the GitHub App [string] $OwnerName @@ -41,6 +44,7 @@ $this.PerPage = $Object.PerPage $this.ClientID = $Object.ClientID $this.PrivateKey = $Object.PrivateKey + $this.KeyVaultKeyReference = $Object.KeyVaultKeyReference $this.OwnerName = $Object.OwnerName $this.OwnerType = $Object.OwnerType $this.Permissions = $Object.Permissions diff --git a/src/classes/public/GitHubFormatter.ps1 b/src/classes/public/GitHubFormatter.ps1 index ce5f49087..1fa06d0c5 100644 --- a/src/classes/public/GitHubFormatter.ps1 +++ b/src/classes/public/GitHubFormatter.ps1 @@ -20,13 +20,14 @@ return "$color$Text$reset" } - static [string] FormatFileSize([long]$size) { + static [string] FormatFileSize([object]$size) { switch ($size) { + { $_ -ge 1PB } { return '{0:N2} PB' -f ($size / 1PB) } { $_ -ge 1TB } { return '{0:N2} TB' -f ($size / 1TB) } { $_ -ge 1GB } { return '{0:N2} GB' -f ($size / 1GB) } { $_ -ge 1MB } { return '{0:N2} MB' -f ($size / 1MB) } { $_ -ge 1KB } { return '{0:N2} KB' -f ($size / 1KB) } } - return "$size B" + return "$size B" } } diff --git a/src/classes/public/GitHubJWTComponent.ps1 b/src/classes/public/GitHubJWTComponent.ps1 new file mode 100644 index 000000000..2f24a1e55 --- /dev/null +++ b/src/classes/public/GitHubJWTComponent.ps1 @@ -0,0 +1,15 @@ +class GitHubJWTComponent { + static [string] ToBase64UrlString([hashtable] $Data) { + return [GitHubJWTComponent]::ConvertToBase64UrlFormat( + [System.Convert]::ToBase64String( + [System.Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $Data) + ) + ) + ) + } + + static [string] ConvertToBase64UrlFormat([string] $Base64String) { + return $Base64String.TrimEnd('=').Replace('+', '-').Replace('/', '_') + } +} diff --git a/src/classes/public/Owner/GitHubOwner/GitHubOrganization.ps1 b/src/classes/public/Owner/GitHubOwner/GitHubOrganization.ps1 index a99927110..cda352a18 100644 --- a/src/classes/public/Owner/GitHubOwner/GitHubOrganization.ps1 +++ b/src/classes/public/Owner/GitHubOwner/GitHubOrganization.ps1 @@ -39,9 +39,9 @@ # Example: 100 [System.Nullable[uint]] $OwnedPrivateRepos - # The disk usage in kilobytes. - # Example: 10000 - [System.Nullable[uint]] $DiskUsage + # The size of the organization's repositories, in bytes. + # Example: 10240000 + [System.Nullable[uint64]] $Size # The number of collaborators on private repositories. # Example: 8 @@ -209,7 +209,9 @@ $this.PrivateGists = $Object.total_private_gists $this.TotalPrivateRepos = $Object.total_private_repos $this.OwnedPrivateRepos = $Object.owned_private_repos - $this.DiskUsage = $Object.disk_usage + if ($null -ne $Object.disk_usage) { + $this.Size = [uint64]($Object.disk_usage * 1KB) + } $this.Collaborators = $Object.collaborators $this.IsVerified = $Object.is_verified ?? $Object.isVerified $this.HasOrganizationProjects = $Object.has_organization_projects diff --git a/src/classes/public/Repositories/GitHubRepository.ps1 b/src/classes/public/Repositories/GitHubRepository.ps1 index fa8df1bc9..11ae0a21e 100644 --- a/src/classes/public/Repositories/GitHubRepository.ps1 +++ b/src/classes/public/Repositories/GitHubRepository.ps1 @@ -39,9 +39,9 @@ # Example: https://github.com [string] $Homepage - # The size of the repository, in kilobytes. - # Example: 108 - [System.Nullable[uint]] $Size + # The size of the repository, in bytes. + # Example: 110592 + [System.Nullable[uint64]] $Size # The primary language of the repository. # Example: null @@ -263,7 +263,9 @@ $this.Description = $Object.description $this.Homepage = $Object.homepage $this.Url = $Object.html_url - $this.Size = $Object.size + if ($null -ne $Object.size) { + $this.Size = [uint64]($Object.size * 1KB) + } $this.Language = [GitHubRepositoryLanguage]::new($Object.language) $this.IsFork = $Object.fork $this.IsArchived = $Object.archived @@ -317,7 +319,9 @@ $this.PushedAt = $Object.pushedAt $this.ArchivedAt = $Object.archivedAt $this.Homepage = $Object.homepageUrl - $this.Size = $Object.diskUsage + if ($null -ne $Object.diskUsage) { + $this.Size = [uint64]($Object.diskUsage * 1KB) + } $this.Language = [GitHubRepositoryLanguage]::new($Object.primaryLanguage) $this.HasIssues = $Object.hasIssuesEnabled $this.HasProjects = $Object.hasProjectsEnabled diff --git a/src/classes/public/Workflows/GitHubWorkflowRun.ps1 b/src/classes/public/Workflows/GitHubWorkflowRun.ps1 index 6a6e60e18..223434062 100644 --- a/src/classes/public/Workflows/GitHubWorkflowRun.ps1 +++ b/src/classes/public/Workflows/GitHubWorkflowRun.ps1 @@ -85,7 +85,7 @@ # The start time of the latest run. Resets on re-run. # Example: "2023-01-01T12:01:00Z" - [System.Nullable[datetime]] $RunStartedAt + [System.Nullable[datetime]] $StartedAt # The head commit details. # Example: (nullable-simple-commit object) @@ -126,7 +126,7 @@ $this.PullRequests = $_.pull_requests $this.CreatedAt = $_.created_at $this.UpdatedAt = $_.updated_at - $this.RunStartedAt = $_.run_started_at + $this.StartedAt = $_.run_started_at $this.Actor = [GitHubUser]::new($_.actor) $this.TriggeringActor = [GitHubUser]::new($_.triggering_actor) $this.HeadCommit = $_.head_commit diff --git a/src/formats/GitHubArtifact.Format.ps1xml b/src/formats/GitHubArtifact.Format.ps1xml index 1f6d8796a..f32e8862c 100644 --- a/src/formats/GitHubArtifact.Format.ps1xml +++ b/src/formats/GitHubArtifact.Format.ps1xml @@ -15,7 +15,7 @@ - + @@ -44,7 +44,7 @@ - '{0:F2}' -f ([math]::Round($_.Size / 1KB, 2)) + [GitHubFormatter]::FormatFileSize($_.Size) Right diff --git a/src/formats/GitHubConfig.Format.ps1xml b/src/formats/GitHubConfig.Format.ps1xml index 51fb2c86b..cd556ceb4 100644 --- a/src/formats/GitHubConfig.Format.ps1xml +++ b/src/formats/GitHubConfig.Format.ps1xml @@ -31,9 +31,6 @@ AccessTokenGracePeriodInHours - - JwtTimeTolerance - ApiVersion diff --git a/src/formats/GitHubContext.Format.ps1xml b/src/formats/GitHubContext.Format.ps1xml index 90ae28648..45756fbb5 100644 --- a/src/formats/GitHubContext.Format.ps1xml +++ b/src/formats/GitHubContext.Format.ps1xml @@ -58,7 +58,7 @@ return } - if ($_.TokenExpiresIn -lt 0) { + if ($_.TokenExpiresIn -le 0) { $text = 'Expired' } else { $text = $_.TokenExpiresIn.ToString('hh\:mm\:ss') @@ -73,6 +73,9 @@ 'IAT' { $maxValue = [TimeSpan]::FromHours(1) } + 'APP' { + $maxValue = [TimeSpan]::FromMinutes(10) + } } $ratio = [Math]::Min(($_.TokenExpiresIn / $maxValue), 1) [GitHubFormatter]::FormatColorByRatio($ratio, $text) @@ -172,6 +175,12 @@ TokenType + + TokenExpiresAt + + + TokenExpiresIn + HostName @@ -238,22 +247,22 @@ TokenType - HostName + TokenExpiresAt - UserName + TokenExpiresIn - ClientID + HostName - InstallationID + UserName - TokenExpiresAt + ClientID - TokenExpiresIn + InstallationID InstallationType @@ -311,6 +320,12 @@ TokenType + + TokenExpiresAt + + + TokenExpiresIn + HostName @@ -326,12 +341,6 @@ Scope - - TokenExpiresAt - - - TokenExpiresIn - RefreshTokenExpiresAt diff --git a/src/formats/GitHubRepository.Format.ps1xml b/src/formats/GitHubRepository.Format.ps1xml index d36a17f3f..141183f27 100644 --- a/src/formats/GitHubRepository.Format.ps1xml +++ b/src/formats/GitHubRepository.Format.ps1xml @@ -18,7 +18,7 @@ - + @@ -41,7 +41,7 @@ Visibility - '{0:F2}' -f ([math]::Round($_.Size / 1KB, 2)) + [GitHubFormatter]::FormatFileSize($_.Size) Right diff --git a/src/functions/private/Apps/GitHub Apps/Add-GitHubKeyVaultJWTSignature.ps1 b/src/functions/private/Apps/GitHub Apps/Add-GitHubKeyVaultJWTSignature.ps1 new file mode 100644 index 000000000..1665a309c --- /dev/null +++ b/src/functions/private/Apps/GitHub Apps/Add-GitHubKeyVaultJWTSignature.ps1 @@ -0,0 +1,101 @@ +function Add-GitHubKeyVaultJWTSignature { + <# + .SYNOPSIS + Adds a JWT signature using Azure Key Vault. + + .DESCRIPTION + Signs an unsigned JWT (header.payload) using a key stored in Azure Key Vault. + The function supports authentication via Azure CLI or Az PowerShell module and returns the signed JWT as a secure string. + + .EXAMPLE + Add-GitHubKeyVaultJWTSignature -UnsignedJWT 'header.payload' -KeyVaultKeyReference 'https://myvault.vault.azure.net/keys/mykey' + + Output: + ```powershell + System.Security.SecureString + ``` + + Signs the provided JWT (`header.payload`) using the specified Azure Key Vault key, returning a secure string containing the signed JWT. + + .OUTPUTS + System.Security.SecureString + + .NOTES + The function returns a secure string containing the fully signed JWT (header.payload.signature). + Ensure Azure CLI or Az PowerShell is installed and authenticated before running this function. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'Used to handle secure string private keys.' + )] + [CmdletBinding()] + param ( + # The unsigned JWT (header.payload) to sign. + [Parameter(Mandatory)] + [string] $UnsignedJWT, + + # The Azure Key Vault key URL used for signing. + [Parameter(Mandatory)] + [string] $KeyVaultKeyReference + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + } + + process { + if (Test-GitHubAzureCLI) { + try { + $accessToken = (az account get-access-token --resource 'https://vault.azure.net/' --output json | ConvertFrom-Json).accessToken + } catch { + Write-Error "Failed to get access token from Azure CLI: $_" + return + } + } elseif (Test-GitHubAzPowerShell) { + try { + $accessToken = (Get-AzAccessToken -ResourceUrl 'https://vault.azure.net/').Token + } catch { + Write-Error "Failed to get access token from Az PowerShell: $_" + return + } + } else { + Write-Error 'Azure authentication is required. Please ensure you are logged in using either Azure CLI or Az PowerShell.' + return + } + + if ($accessToken -isnot [securestring]) { + $accessToken = ConvertTo-SecureString -String $accessToken -AsPlainText + } + + $hash64url = [GitHubJWTComponent]::ConvertToBase64UrlFormat( + [System.Convert]::ToBase64String( + [System.Security.Cryptography.SHA256]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT) + ) + ) + ) + + $KeyVaultKeyReference = $KeyVaultKeyReference.TrimEnd('/') + + $params = @{ + Method = 'POST' + URI = "$KeyVaultKeyReference/sign?api-version=7.4" + Body = @{ + alg = 'RS256' + value = $hash64url + } | ConvertTo-Json + ContentType = 'application/json' + Authentication = 'Bearer' + Token = $accessToken + } + + $result = Invoke-RestMethod @params + $signature = $result.value + return (ConvertTo-SecureString -String "$UnsignedJWT.$signature" -AsPlainText) + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Apps/GitHub Apps/Add-GitHubJWTSignature.ps1 b/src/functions/private/Apps/GitHub Apps/Add-GitHubLocalJWTSignature.ps1 similarity index 64% rename from src/functions/private/Apps/GitHub Apps/Add-GitHubJWTSignature.ps1 rename to src/functions/private/Apps/GitHub Apps/Add-GitHubLocalJWTSignature.ps1 index f00d160da..1c14e89f3 100644 --- a/src/functions/private/Apps/GitHub Apps/Add-GitHubJWTSignature.ps1 +++ b/src/functions/private/Apps/GitHub Apps/Add-GitHubLocalJWTSignature.ps1 @@ -1,4 +1,4 @@ -function Add-GitHubJWTSignature { +function Add-GitHubLocalJWTSignature { <# .SYNOPSIS Signs a JSON Web Token (JWT) using a local RSA private key. @@ -8,26 +8,25 @@ function Add-GitHubJWTSignature { This function handles the RSA signing process and returns the complete signed JWT. .EXAMPLE - Add-GitHubJWTSignature -UnsignedJWT 'eyJ0eXAiOi...' -PrivateKey '--- BEGIN RSA PRIVATE KEY --- ... --- END RSA PRIVATE KEY ---' + Add-GitHubLocalJWTSignature -UnsignedJWT 'eyJ0eXAiOi...' -PrivateKey '--- BEGIN RSA PRIVATE KEY --- ... --- END RSA PRIVATE KEY ---' Adds a signature to the unsigned JWT using the provided private key. .OUTPUTS - String + securestring .NOTES This function isolates the signing logic to enable support for multiple signing methods. .LINK - https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Add-GitHubJWTSignature + https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Add-GitHubLocalJWTSignature #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', - '', + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Used to handle secure string private keys.' )] [CmdletBinding()] - [OutputType([string])] + [OutputType([securestring])] param( # The unsigned JWT (header.payload) to sign. [Parameter(Mandatory)] @@ -52,14 +51,16 @@ function Add-GitHubJWTSignature { $rsa.ImportFromPem($PrivateKey) try { - $signature = [Convert]::ToBase64String( - $rsa.SignData( - [System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT), - [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + $signature = [GitHubJWTComponent]::ConvertToBase64UrlFormat( + [System.Convert]::ToBase64String( + $rsa.SignData( + [System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT), + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) ) - ).TrimEnd('=').Replace('+', '-').Replace('/', '_') - return "$UnsignedJWT.$signature" + ) + return (ConvertTo-SecureString -String "$UnsignedJWT.$signature" -AsPlainText) } finally { if ($rsa) { $rsa.Dispose() diff --git a/src/functions/private/Apps/GitHub Apps/New-GitHubUnsignedJWT.ps1 b/src/functions/private/Apps/GitHub Apps/New-GitHubUnsignedJWT.ps1 index 5332505c5..16d3264cc 100644 --- a/src/functions/private/Apps/GitHub Apps/New-GitHubUnsignedJWT.ps1 +++ b/src/functions/private/Apps/GitHub Apps/New-GitHubUnsignedJWT.ps1 @@ -1,4 +1,4 @@ -function New-GitHubUnsignedJWT { +function New-GitHubUnsignedJWT { <# .SYNOPSIS Creates an unsigned JSON Web Token (JWT) for a GitHub App. @@ -38,34 +38,26 @@ function New-GitHubUnsignedJWT { } process { - $header = [Convert]::ToBase64String( - [System.Text.Encoding]::UTF8.GetBytes( - ( - ConvertTo-Json -InputObject @{ - alg = 'RS256' - typ = 'JWT' - } - ) - ) - ).TrimEnd('=').Replace('+', '-').Replace('/', '_') - $now = [System.DateTimeOffset]::UtcNow - $iat = $now.AddSeconds(-$script:GitHub.Config.JwtTimeTolerance) - $exp = $now.AddSeconds($script:GitHub.Config.JwtTimeTolerance) - $payload = [Convert]::ToBase64String( - [System.Text.Encoding]::UTF8.GetBytes( - ( - ConvertTo-Json -InputObject @{ - iat = $iat.ToUnixTimeSeconds() - exp = $exp.ToUnixTimeSeconds() - iss = $ClientID - } - ) - ) - ).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $header = [GitHubJWTComponent]::ToBase64UrlString( + @{ + alg = 'RS256' + typ = 'JWT' + } + ) + $nowUtc = [System.DateTimeOffset]::UtcNow + $iat = $nowUtc.AddMinutes(-10) + $exp = $nowUtc.AddMinutes(10) + $payload = [GitHubJWTComponent]::ToBase64UrlString( + @{ + iat = $iat.ToUnixTimeSeconds() + exp = $exp.ToUnixTimeSeconds() + iss = $ClientID + } + ) [pscustomobject]@{ Base = "$header.$payload" - IssuedAt = $iat.DateTime - ExpiresAt = $exp.DateTime + IssuedAt = $iat.LocalDateTime + ExpiresAt = $exp.LocalDateTime Issuer = $ClientID } } diff --git a/src/functions/private/Apps/GitHub Apps/Test-GitHubJWTRefreshRequired.ps1 b/src/functions/private/Apps/GitHub Apps/Test-GitHubJWTRefreshRequired.ps1 new file mode 100644 index 000000000..4db614e1f --- /dev/null +++ b/src/functions/private/Apps/GitHub Apps/Test-GitHubJWTRefreshRequired.ps1 @@ -0,0 +1,43 @@ +function Test-GitHubJWTRefreshRequired { + <# + .SYNOPSIS + Test if the GitHub JWT should be refreshed. + + .DESCRIPTION + Test if the GitHub JWT should be refreshed. JWTs are refreshed when they have 150 seconds or less remaining before expiration. + + .EXAMPLE + Test-GitHubJWTRefreshRequired -Context $Context + + This will test if the GitHub JWT should be refreshed for the specified context. + + .NOTES + JWTs are short-lived tokens (typically 10 minutes) and need to be refreshed more frequently than user access tokens. + The refresh threshold is set to 150 seconds (2.5 minutes) to ensure the JWT doesn't expire during API operations. + #> + [OutputType([bool])] + [CmdletBinding()] + param( + # The context to run the command in. Used to get the details for the API call. + # Can be either a string or a GitHubContext object. + [Parameter(Mandatory)] + [object] $Context + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + } + + process { + try { + ($Context.TokenExpiresAt - [datetime]::Now).TotalSeconds -le 60 + } catch { + return $true + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 new file mode 100644 index 000000000..2b6defff8 --- /dev/null +++ b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 @@ -0,0 +1,59 @@ +function Uninstall-GitHubAppAsApp { + <# + .SYNOPSIS + Delete an installation for the authenticated app. + + .DESCRIPTION + Deletes a GitHub App installation using the authenticated App context. + + .EXAMPLE + Uninstall-GitHubAppAsApp -ID 123456 -Context $appContext + + Deletes the installation with ID 123456 for the authenticated app. + + .NOTES + [Delete an installation for the authenticated app](https://docs.github.com/rest/apps/installations#delete-an-installation-for-the-authenticated-app) + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Contains a long link.' + )] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + # The installation ID to remove. + [Parameter(Mandatory)] + [Alias('InstallationID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $ID, + + # The context to run the command in. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + Assert-GitHubContext -Context $Context -AuthType APP + } + + process { + Write-Verbose "Uninstalling GitHub App Installation: $ID" + + $apiParams = @{ + Method = 'DELETE' + APIEndpoint = "/app/installations/$ID" + Context = $Context + } + + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $ID", 'Uninstall')) { + $null = Invoke-GitHubAPI @apiParams + Write-Verbose "Successfully removed installation: $ID" + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 index cfea6ff04..60dab447f 100644 --- a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 +++ b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 @@ -1,34 +1,46 @@ function Uninstall-GitHubAppOnEnterpriseOrganization { <# .SYNOPSIS - Uninstall a GitHub App from an organization. + Uninstall a GitHub App from an enterprise-owned organization. .DESCRIPTION - Uninstall a GitHub App from an organization. + Uninstall a GitHub App from an enterprise-owned organization. The authenticated GitHub App must be installed on the enterprise and be granted the Enterprise/organization_installations (write) permission. .EXAMPLE - Uninstall-GitHubAppOnEnterpriseOrganization -Enterprise 'github' -Organization 'octokit' -InstallationID '123456' + Uninstall-GitHubAppOnEnterpriseOrganization -Enterprise 'github' -Organization 'octokit' -ID '123456' Uninstall the GitHub App with the installation ID `123456` from the organization `octokit` in the enterprise `github`. + + .NOTES + [Uninstall a GitHub App from an enterprise-owned organization](https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#uninstall-a-github-app-from-an-enterprise-owned-organization) #> - [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Contains a long link.' + )] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( # The enterprise slug or ID. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Enterprise, # The organization name. The name is not case sensitive. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Organization, - # The client ID of the GitHub App to install. + # The ID of the GitHub App installation to uninstall. [Parameter(Mandatory)] - [string] $ID, + [Alias('InstallationID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $ID, # The context to run the command in. Used to get the details for the API call. [Parameter(Mandatory)] + [ValidateNotNull()] [object] $Context ) @@ -40,14 +52,16 @@ } process { + Write-Verbose "Uninstalling GitHub App [$Enterprise/$Organization/$ID]" $apiParams = @{ Method = 'DELETE' APIEndpoint = "/enterprises/$Enterprise/apps/organizations/$Organization/installations/$ID" Context = $Context } - Invoke-GitHubAPI @apiParams | ForEach-Object { - Write-Output $_.Response + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $Enterprise/$Organization/$ID", 'Uninstall')) { + $null = Invoke-GitHubAPI @apiParams + Write-Verbose "Successfully removed installation: $Enterprise/$Organization/$ID" } } diff --git a/src/functions/private/Apps/GitHub Apps/Update-GitHubAppJWT.ps1 b/src/functions/private/Apps/GitHub Apps/Update-GitHubAppJWT.ps1 index de4ce05d4..e9f6892e4 100644 --- a/src/functions/private/Apps/GitHub Apps/Update-GitHubAppJWT.ps1 +++ b/src/functions/private/Apps/GitHub Apps/Update-GitHubAppJWT.ps1 @@ -4,15 +4,26 @@ Updates a JSON Web Token (JWT) for a GitHub App context. .DESCRIPTION - Updates a JSON Web Token (JWT) for a GitHub App context. + Updates a JSON Web Token (JWT) for a GitHub App context. If the JWT has half or less of its remaining duration before expiration, + it will be refreshed. This function implements mutex-based locking to prevent concurrent refreshes. .EXAMPLE Update-GitHubAppJWT -Context $Context Updates the JSON Web Token (JWT) for a GitHub App using the specified context. + .EXAMPLE + Update-GitHubAppJWT -Context $Context -PassThru + + This will update the GitHub App JWT for the specified context and return the updated context. + + .EXAMPLE + Update-GitHubAppJWT -Context $Context -Silent + + This will update the GitHub App JWT for the specified context without displaying progress messages. + .OUTPUTS - securestring + object .NOTES [Generating a JSON Web Token (JWT) for a GitHub App | GitHub Docs](https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#example-using-powershell-to-generate-a-jwt) @@ -20,6 +31,8 @@ .LINK https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Update-GitHubAppJWT #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([object])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidLongLines', '', Justification = 'Contains a long link.' @@ -28,12 +41,14 @@ 'PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Generated JWT is a plaintext string.' )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Is the CLI part of the module.' + )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Function creates a JWT without modifying system state' )] - [CmdletBinding()] - [OutputType([object])] param( # The context to run the command in. Used to get the details for the API call. # Can be either a string or a GitHubContext object. @@ -42,7 +57,11 @@ # Return the updated context. [Parameter()] - [switch] $PassThru + [switch] $PassThru, + + # Timeout in milliseconds for waiting on mutex. Default is 30 seconds. + [Parameter()] + [int] $TimeoutMs = 30000 ) begin { @@ -51,15 +70,67 @@ } process { - $unsignedJWT = New-GitHubUnsignedJWT -ClientId $Context.ClientID - $jwt = Add-GitHubJWTSignature -UnsignedJWT $unsignedJWT.Base -PrivateKey $Context.PrivateKey - $Context.Token = ConvertTo-SecureString -String $jwt -AsPlainText - $Context.TokenExpiresAt = $unsignedJWT.ExpiresAt - if ($Context.ID) { - $Context = Set-Context -Context $Context -Vault $script:GitHub.ContextVault -PassThru + if (Test-GitHubJWTRefreshRequired -Context $Context) { + $lockName = "PSModule.GitHub-$($Context.ID)".Replace('/', '-') + $lock = $null + try { + $lock = [System.Threading.Mutex]::new($false, $lockName) + $acquiredLock = $lock.WaitOne(0) + + if ($acquiredLock) { + try { + Write-Debug '⚠ JWT token nearing expiration. Refreshing JWT...' + $unsignedJWT = New-GitHubUnsignedJWT -ClientId $Context.ClientID + + if ($Context.KeyVaultKeyReference) { + Write-Debug "Using KeyVault Key Reference: $($Context.KeyVaultKeyReference)" + $Context.Token = Add-GitHubKeyVaultJWTSignature -UnsignedJWT $unsignedJWT.Base -KeyVaultKeyReference $Context.KeyVaultKeyReference + } elseif ($Context.PrivateKey) { + Write-Debug 'Using Private Key from context.' + $Context.Token = Add-GitHubLocalJWTSignature -UnsignedJWT $unsignedJWT.Base -PrivateKey $Context.PrivateKey + } else { + throw 'No Private Key or KeyVault Key Reference provided in the context.' + } + + $expiresAt = $unsignedJWT.ExpiresAt + if ($expiresAt.Kind -eq [DateTimeKind]::Utc) { + $expiresAt = $expiresAt.ToLocalTime() + } + $Context.TokenExpiresAt = $expiresAt + + if ($Context.ID) { + if ($PSCmdlet.ShouldProcess('JWT token', 'Update/refresh')) { + Set-Context -Context $Context -Vault $script:GitHub.ContextVault + } + } + } finally { + $lock.ReleaseMutex() + } + } else { + Write-Verbose "JWT token is being updated by another process. Waiting for mutex to be released (timeout: $($TimeoutMs)ms)..." + try { + if ($lock.WaitOne($TimeoutMs)) { + $Context = Resolve-GitHubContext -Context $Context.ID + $lock.ReleaseMutex() + } else { + Write-Warning 'Timeout waiting for JWT token update. Proceeding with current token state.' + } + } catch [System.Threading.AbandonedMutexException] { + Write-Debug 'Mutex was abandoned by another process. Re-checking JWT token state...' + $Context = Resolve-GitHubContext -Context $Context.ID + } + } + } finally { + if ($lock) { + $lock.Dispose() + } + } + } else { + Write-Debug 'JWT is still valid, no refresh needed' } + if ($PassThru) { - $Context + return $Context } } diff --git a/src/functions/private/Auth/Context/Remove-GitHubContext.ps1 b/src/functions/private/Auth/Context/Remove-GitHubContext.ps1 index 9b6f810d8..6186b4807 100644 --- a/src/functions/private/Auth/Context/Remove-GitHubContext.ps1 +++ b/src/functions/private/Auth/Context/Remove-GitHubContext.ps1 @@ -42,4 +42,4 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } diff --git a/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 b/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 index a089f6add..a28e871a6 100644 --- a/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 +++ b/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 @@ -41,7 +41,8 @@ } process { - Write-Verbose "Context: [$Context]" + Write-Verbose "Context:" + $Context | Out-String -Stream | ForEach-Object { Write-Verbose $_ } Write-Verbose "Anonymous: [$Anonymous]" if ($Anonymous -or $Context -eq 'Anonymous') { Write-Verbose 'Returning Anonymous context.' diff --git a/src/functions/private/Auth/Context/Set-GitHubContext.ps1 b/src/functions/private/Auth/Context/Set-GitHubContext.ps1 index 068bafa99..c20ea4281 100644 --- a/src/functions/private/Auth/Context/Set-GitHubContext.ps1 +++ b/src/functions/private/Auth/Context/Set-GitHubContext.ps1 @@ -168,4 +168,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/private/Auth/DeviceFlow/Test-GitHubAccessTokenRefreshRequired.ps1 b/src/functions/private/Auth/DeviceFlow/Test-GitHubAccessTokenRefreshRequired.ps1 index d9320a85d..292ded46a 100644 --- a/src/functions/private/Auth/DeviceFlow/Test-GitHubAccessTokenRefreshRequired.ps1 +++ b/src/functions/private/Auth/DeviceFlow/Test-GitHubAccessTokenRefreshRequired.ps1 @@ -26,8 +26,11 @@ } process { - $updateToken = ($Context.TokenExpiresAt - [datetime]::Now).TotalHours -lt $script:GitHub.Config.AccessTokenGracePeriodInHours - $updateToken + try { + ($Context.TokenExpiresAt - [datetime]::Now).TotalHours -lt $script:GitHub.Config.AccessTokenGracePeriodInHours + } catch { + return $true + } } end { diff --git a/src/functions/private/Auth/DeviceFlow/Update-GitHubUserAccessToken.ps1 b/src/functions/private/Auth/DeviceFlow/Update-GitHubUserAccessToken.ps1 index 4f4ce2ffe..ab5183e7e 100644 --- a/src/functions/private/Auth/DeviceFlow/Update-GitHubUserAccessToken.ps1 +++ b/src/functions/private/Auth/DeviceFlow/Update-GitHubUserAccessToken.ps1 @@ -43,10 +43,6 @@ [Parameter()] [switch] $PassThru, - # Suppress output messages. - [Parameter()] - [switch] $Silent, - # Timeout in milliseconds for waiting on mutex. Default is 30 seconds. [Parameter()] [int] $TimeoutMs = 30000 @@ -55,12 +51,11 @@ begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Start" - Assert-GitHubContext -Context $Context -AuthType UAT } process { if (Test-GitHubAccessTokenRefreshRequired -Context $Context) { - $lockName = "PSModule.GitHub/$($Context.ID)" + $lockName = "PSModule.GitHub-$($Context.ID)".Replace('/', '-') $lock = $null try { $lock = [System.Threading.Mutex]::new($false, $lockName) @@ -71,10 +66,7 @@ $refreshTokenValidity = [datetime]($Context.RefreshTokenExpiresAt) - [datetime]::Now $refreshTokenIsValid = $refreshTokenValidity.TotalSeconds -gt 0 if ($refreshTokenIsValid) { - if (-not $Silent) { - Write-Host '⚠ ' -ForegroundColor Yellow -NoNewline - Write-Host 'Access token expired. Refreshing access token...' - } + Write-Debug '⚠ Access token expired. Refreshing access token...' $tokenResponse = Invoke-GitHubDeviceFlowLogin -ClientID $Context.AuthClientID -RefreshToken $Context.RefreshToken -HostName $Context.HostName } else { Write-Verbose "Using $($Context.DeviceFlowType) authentication..." diff --git a/src/functions/private/Config/Initialize-GitHubConfig.ps1 b/src/functions/private/Config/Initialize-GitHubConfig.ps1 index 8c513a8f5..651445856 100644 --- a/src/functions/private/Config/Initialize-GitHubConfig.ps1 +++ b/src/functions/private/Config/Initialize-GitHubConfig.ps1 @@ -75,4 +75,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/private/Utilities/PowerShell/Test-GitHubAzPowerShell.ps1 b/src/functions/private/Utilities/PowerShell/Test-GitHubAzPowerShell.ps1 new file mode 100644 index 000000000..54aa9cf2c --- /dev/null +++ b/src/functions/private/Utilities/PowerShell/Test-GitHubAzPowerShell.ps1 @@ -0,0 +1,66 @@ +function Test-GitHubAzPowerShell { + <# + .SYNOPSIS + Tests if Azure PowerShell module is installed and authenticated. + + .DESCRIPTION + This function checks if the Azure PowerShell module (Az) is installed and the user is authenticated. + It verifies both the availability of the module and the authentication status. + + .EXAMPLE + Test-GitHubAzPowerShell + + Returns $true if Azure PowerShell module is installed and authenticated, $false otherwise. + + .OUTPUTS + [bool] + Returns $true if Azure PowerShell module is installed and authenticated, $false otherwise. + + .NOTES + This function is used internally by other GitHub module functions that require Azure PowerShell authentication, + such as Azure Key Vault operations for GitHub App JWT signing. + #> + [OutputType([bool])] + [CmdletBinding()] + param() + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + } + + process { + try { + # Check if Azure PowerShell module is installed + $azModule = Get-Module -Name 'Az.Accounts' -ListAvailable -ErrorAction SilentlyContinue + if (-not $azModule) { + Write-Debug "[$stackPath] - Azure PowerShell module (Az.Accounts) not found" + return $false + } + + # Check if the module is imported + $importedModule = Get-Module -Name 'Az.Accounts' -ErrorAction SilentlyContinue + if (-not $importedModule) { + Write-Debug "[$stackPath] - Attempting to import Az.Accounts module" + Import-Module -Name 'Az.Accounts' -ErrorAction SilentlyContinue + } + + # Check if user is authenticated by trying to get current context + $context = Get-AzContext -ErrorAction SilentlyContinue + if (-not $context -or [string]::IsNullOrEmpty($context.Account)) { + Write-Debug "[$stackPath] - Azure PowerShell authentication failed or no account logged in" + return $false + } + + Write-Debug "[$stackPath] - Azure PowerShell is installed and authenticated (Account: $($context.Account.Id))" + return $true + } catch { + Write-Debug "[$stackPath] - Error checking Azure PowerShell: $($_.Exception.Message)" + return $false + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Utilities/PowerShell/Test-GitHubAzureCLI.ps1 b/src/functions/private/Utilities/PowerShell/Test-GitHubAzureCLI.ps1 new file mode 100644 index 000000000..19a6db1f1 --- /dev/null +++ b/src/functions/private/Utilities/PowerShell/Test-GitHubAzureCLI.ps1 @@ -0,0 +1,65 @@ +function Test-GitHubAzureCLI { + <# + .SYNOPSIS + Tests if Azure CLI is installed and authenticated. + + .DESCRIPTION + This function checks if Azure CLI (az) is installed and the user is authenticated. + It verifies both the availability of the CLI tool and the authentication status. + + .EXAMPLE + Test-GitHubAzureCLI + + Returns $true if Azure CLI is installed and authenticated, $false otherwise. + + .OUTPUTS + bool + + .NOTES + This function is used internally by other GitHub module functions that require Azure CLI authentication, + such as Azure Key Vault operations for GitHub App JWT signing. + #> + [OutputType([bool])] + [CmdletBinding()] + param() + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + } + + process { + try { + # Check if Azure CLI is installed + $azCommand = Get-Command -Name 'az' -ErrorAction SilentlyContinue + if (-not $azCommand) { + Write-Debug "[$stackPath] - Azure CLI (az) command not found" + return $false + } + + # Check if user is authenticated by trying to get account info + $accountInfo = az account show --output json 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($accountInfo)) { + Write-Debug "[$stackPath] - Azure CLI authentication failed or no account logged in" + return $false + } + + # Parse the account info to ensure it's valid + $account = $accountInfo | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $account -or [string]::IsNullOrEmpty($account.id)) { + Write-Debug "[$stackPath] - Azure CLI account information is invalid" + return $false + } + + Write-Debug "[$stackPath] - Azure CLI is installed and authenticated (Account: $($account.id))" + return $true + } catch { + Write-Debug "[$stackPath] - Error checking Azure CLI: $($_.Exception.Message)" + return $false + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 b/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 index 5e0dda333..656230a74 100644 --- a/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 +++ b/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 @@ -4,44 +4,72 @@ Uninstall a GitHub App. .DESCRIPTION - Uninstalls the provided GitHub App on the specified target. + Uninstalls a GitHub App installation. Works in two modes: + - As the authenticated App (APP context): remove installations by target name, ID, or pipeline objects. + - As an enterprise installation (IAT/UAT context with Enterprise): remove an app from an organization by InstallationID or AppSlug. .EXAMPLE - Uninstall-GitHubApp -Enterprise 'msx' -Organization 'org' -InstallationID '123456' + Uninstall-GitHubApp -Target 'octocat' + Uninstall-GitHubApp -Target 12345 - Uninstall the GitHub App with the installation ID '123456' from the organization 'org' in the enterprise 'msx'. + As an App: uninstall by target name (enterprise/org/user) or by exact installation ID + + .EXAMPLE + Get-GitHubAppInstallation | Uninstall-GitHubApp + + As an App: uninstall using pipeline objects + + .EXAMPLE + Uninstall-GitHubApp -Organization 'org' -InstallationID 123456 -Context (Connect-GitHubApp -Enterprise 'msx' -PassThru) + + As an enterprise installation: uninstall by installation ID in an org + + .EXAMPLE + Uninstall-GitHubApp -Organization 'org' -AppSlug 'my-app' -Context (Connect-GitHubApp -Enterprise 'msx' -PassThru) + + As an enterprise installation: uninstall by app slug in an org .LINK https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Uninstall-GitHubApp #> - [CmdletBinding(DefaultParameterSetName = '__AllParameterSets')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains a long link.')] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'App-ByTarget')] param( - # The enterprise slug or ID. - [Parameter( - Mandatory, - ParameterSetName = 'EnterpriseOrganization', - ValueFromPipelineByPropertyName - )] - [string] $Enterprise, + # As APP: target to uninstall. Accepts a name (enterprise/org/user) or an installation ID. + [Parameter(Mandatory, ParameterSetName = 'App-ByTarget', ValueFromPipelineByPropertyName)] + [Parameter(ParameterSetName = 'App-ByObject')] + [Alias('Name')] + [object] $Target, + + # As APP via pipeline: installation objects. + [Parameter(Mandatory, ParameterSetName = 'App-ByObject', ValueFromPipeline)] + [GitHubAppInstallation[]] $InstallationObject, - # The organization name. The name is not case sensitive. - [Parameter( - Mandatory, - ParameterSetName = 'EnterpriseOrganization', - ValueFromPipelineByPropertyName - )] + # As Enterprise (IAT/UAT): organization where the app is installed. + [Parameter(Mandatory, ParameterSetName = 'Enterprise-ByID')] + [Parameter(Mandatory, ParameterSetName = 'Enterprise-BySlug')] + [ValidateNotNullOrEmpty()] [string] $Organization, - # The client ID of the GitHub App to install. - [Parameter( - Mandatory, - ValueFromPipelineByPropertyName - )] - [Alias('installation_id', 'InstallationID')] - [string] $ID, + # As Enterprise (IAT/UAT): enterprise slug or ID. Optional if the context already has Enterprise set. + [Parameter(ParameterSetName = 'Enterprise-ByID')] + [Parameter(ParameterSetName = 'Enterprise-BySlug')] + [ValidateNotNullOrEmpty()] + [string] $Enterprise, + + # As Enterprise (IAT/UAT): installation ID to remove. + [Parameter(Mandatory, ParameterSetName = 'Enterprise-ByID')] + [Alias('ID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $InstallationID, + + # As Enterprise (IAT/UAT): app slug to uninstall (when the installation ID is unknown). + [Parameter(Mandatory, ParameterSetName = 'Enterprise-BySlug')] + [Alias('Slug', 'AppName')] + [ValidateNotNullOrEmpty()] + [string] $AppSlug, - # The context to run the command in. Used to get the details for the API call. - # Can be either a string or a GitHubContext object. + # Common: explicit context (APP for app mode; IAT/UAT with Enterprise for enterprise mode) [Parameter()] [object] $Context ) @@ -50,18 +78,78 @@ $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Start" $Context = Resolve-GitHubContext -Context $Context + Assert-GitHubContext -Context $Context -AuthType APP, IAT, UAT } process { switch ($PSCmdlet.ParameterSetName) { - 'EnterpriseOrganization' { + 'App-ByTarget' { + if ($Context.AuthType -ne 'APP') { + throw 'App-ByTarget requires APP authentication. Provide an App context or connect as an App.' + } + + $id = $null + if ($Target -is [int] -or $Target -is [long] -or $Target -is [uint64]) { $id = [uint64]$Target } + elseif ($Target -is [string] -and ($Target -as [uint64])) { $id = [uint64]$Target } + + if ($id) { + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $id", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $id -Context $Context -Confirm:$false + } + return + } + + $installations = Get-GitHubAppInstallation -Context $Context + $instMatches = $installations | Where-Object { $_.Target.Name -like "*$Target*" } + if (-not $instMatches) { throw "No installations found matching target '$Target'." } + foreach ($inst in $instMatches) { + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $($inst.ID) [$($inst.Target.Type)/$($inst.Target.Name)]", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $inst.ID -Context $Context -Confirm:$false + } + } + } + + 'App-ByObject' { + if ($Context.AuthType -ne 'APP') { + throw 'App-ByObject requires APP authentication. Provide an App context or connect as an App.' + } + foreach ($inst in $InstallationObject) { + if (-not $inst.ID) { continue } + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $($inst.ID) [$($inst.Target.Type)/$($inst.Target.Name)]", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $inst.ID -Context $Context -Confirm:$false + } + } + } + + 'Enterprise-ByID' { + $effectiveEnterprise = if ($Enterprise) { $Enterprise } else { $Context.Enterprise } + if (-not $effectiveEnterprise) { throw 'Enterprise-ByID requires an enterprise to be specified (via -Enterprise or Context.Enterprise).' } $params = @{ - Enterprise = $Enterprise + Enterprise = $effectiveEnterprise Organization = $Organization - ID = $ID + ID = $InstallationID Context = $Context } - Uninstall-GitHubAppOnEnterpriseOrganization @params + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $effectiveEnterprise/$Organization/$InstallationID", 'Uninstall')) { + Uninstall-GitHubAppOnEnterpriseOrganization @params -Confirm:$false + } + } + + 'Enterprise-BySlug' { + $effectiveEnterprise = if ($Enterprise) { $Enterprise } else { $Context.Enterprise } + if (-not $effectiveEnterprise) { throw 'Enterprise-BySlug requires an enterprise to be specified (via -Enterprise or Context.Enterprise).' } + $inst = Get-GitHubEnterpriseOrganizationAppInstallation -Enterprise $effectiveEnterprise -Organization $Organization -Context $Context | + Where-Object { $_.App.Slug -eq $AppSlug } | Select-Object -First 1 + if (-not $inst) { throw "No installation found for app slug '$AppSlug' in org '$Organization'." } + $params = @{ + Enterprise = $effectiveEnterprise + Organization = $Organization + ID = $inst.ID + Context = $Context + } + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $effectiveEnterprise/$Organization/$($inst.ID) (app '$AppSlug')", 'Uninstall')) { + Uninstall-GitHubAppOnEnterpriseOrganization @params -Confirm:$false + } } } } @@ -70,5 +158,3 @@ Write-Debug "[$stackPath] - End" } } - -#SkipTest:FunctionTest:Will add a test for this function in a future PR diff --git a/src/functions/public/Auth/Connect-GitHubAccount.ps1 b/src/functions/public/Auth/Connect-GitHubAccount.ps1 index c96affaa8..20816ce7d 100644 --- a/src/functions/public/Auth/Connect-GitHubAccount.ps1 +++ b/src/functions/public/Auth/Connect-GitHubAccount.ps1 @@ -48,9 +48,18 @@ #> [Alias('Connect-GitHub')] [OutputType([void])] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Long links for documentation.')] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Is the CLI part of the module.')] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The tokens are received as clear text. Mitigating exposure by removing variables and performing garbage collection.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Long links for documentation.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Is the CLI part of the module.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The tokens are received as clear text. Mitigating exposure by removing variables and performing garbage collection.' + )] [CmdletBinding(DefaultParameterSetName = 'UAT')] param( # Choose between authentication methods, either OAuthApp or GitHubApp. @@ -69,41 +78,39 @@ # The user will be prompted to enter the token. - [Parameter( - Mandatory, - ParameterSetName = 'PAT' - )] + [Parameter(Mandatory, ParameterSetName = 'PAT')] [switch] $UseAccessToken, # An access token to use for authentication. Can be both a string or a SecureString. # Supports both personal access tokens (PAT) and GitHub App installation access tokens (IAT). # Example: 'ghp_1234567890abcdef' # Example: 'ghs_1234567890abcdef' - [Parameter( - Mandatory, - ParameterSetName = 'Token' - )] + [Parameter(Mandatory, ParameterSetName = 'Token')] [object] $Token, # The client ID for the GitHub App to use for authentication. [Parameter(ParameterSetName = 'UAT')] - [Parameter( - Mandatory, - ParameterSetName = 'App' - )] + [Parameter(Mandatory, ParameterSetName = 'GitHub App using a PrivateKey')] + [Parameter(Mandatory, ParameterSetName = 'GitHub App using a KeyVault Key Reference')] [string] $ClientID, - # The private key for the GitHub App when authenticating as a GitHub App. - [Parameter( - Mandatory, - ParameterSetName = 'App' - )] + # The private key that is used to sign JWTs for the GitHub App. + [Parameter(Mandatory, ParameterSetName = 'GitHub App using a PrivateKey')] [object] $PrivateKey, + # The KeyVault Key Reference that can sign JWTs for the GitHub App. + [Parameter(Mandatory, ParameterSetName = 'GitHub App using a KeyVault Key Reference')] + [ValidateScript({ + if ($_ -notlike 'https://*.vault.azure.net/keys/*') { + throw "Invalid Key Vault key reference format: $_" + } + return $true + })] + [string] $KeyVaultKeyReference, + # Automatically load installations for the GitHub App. - [Parameter( - ParameterSetName = 'App' - )] + [Parameter(ParameterSetName = 'GitHub App using a PrivateKey')] + [Parameter(ParameterSetName = 'GitHub App using a KeyVault Key Reference')] [switch] $AutoloadInstallations, # The default enterprise to use in commands. @@ -160,10 +167,9 @@ $ApiVersion = $script:GitHub.Config.ApiVersion $HostName = $HostName -replace '^https?://' $ApiBaseUri = "https://api.$HostName" - $authType = $PSCmdlet.ParameterSetName # If running on GitHub Actions and no access token is provided, use the GitHub token. - if (($env:GITHUB_ACTIONS -eq 'true') -and $PSCmdlet.ParameterSetName -ne 'App') { + if ($script:IsGitHubActions -and $PSCmdlet.ParameterSetName -notin @('GitHub App using a PrivateKey', 'GitHub App using a KeyVault Key Reference')) { $customTokenProvided = -not [string]::IsNullOrEmpty($Token) $gitHubTokenPresent = Test-GitHubToken Write-Verbose "A token was provided: [$customTokenProvided]" @@ -181,7 +187,6 @@ HostName = [string]$HostName HttpVersion = [string]$httpVersion PerPage = [int]$perPage - AuthType = [string]$authType Enterprise = [string]$Enterprise Owner = [string]$Owner Repository = [string]$Repository @@ -189,7 +194,7 @@ $context | Format-Table | Out-String -Stream | ForEach-Object { Write-Verbose $_ } - switch ($authType) { + switch ($PSCmdlet.ParameterSetName) { 'UAT' { Write-Verbose 'Logging in using device flow...' if (-not [string]::IsNullOrEmpty($ClientID)) { @@ -218,6 +223,7 @@ switch ($Mode) { 'GitHubApp' { $context += @{ + AuthType = 'UAT' Token = ConvertTo-SecureString -AsPlainText $tokenResponse.access_token TokenExpiresAt = ([DateTime]::Now).AddSeconds($tokenResponse.expires_in) TokenType = $tokenResponse.access_token -replace $script:GitHub.TokenPrefixPattern @@ -230,6 +236,7 @@ } 'OAuthApp' { $context += @{ + AuthType = 'UAT' Token = ConvertTo-SecureString -AsPlainText $tokenResponse.access_token TokenType = $tokenResponse.access_token -replace $script:GitHub.TokenPrefixPattern AuthClientID = $authClientID @@ -244,17 +251,27 @@ } } } - 'App' { - Write-Verbose 'Logging in as a GitHub App...' + 'GitHub App using a PrivateKey' { + Write-Verbose 'Logging in as a GitHub App using PrivateKey...' if (-not($PrivateKey -is [System.Security.SecureString])) { $PrivateKey = $PrivateKey | ConvertTo-SecureString -AsPlainText } $context += @{ + AuthType = 'APP' PrivateKey = $PrivateKey TokenType = 'JWT' ClientID = $ClientID } } + 'GitHub App using a KeyVault Key Reference' { + Write-Verbose 'Logging in as a GitHub App using KeyVault Key Reference...' + $context += @{ + AuthType = 'APP' + KeyVaultKeyReference = $KeyVaultKeyReference + TokenType = 'JWT' + ClientID = $ClientID + } + } 'PAT' { Write-Debug "UseAccessToken is set to [$UseAccessToken]. Using provided access token..." Write-Verbose 'Logging in using personal access token...' @@ -264,6 +281,7 @@ $Token = ConvertFrom-SecureString $accessTokenValue -AsPlainText $tokenType = $Token -replace $script:GitHub.TokenPrefixPattern $context += @{ + AuthType = 'PAT' Token = ConvertTo-SecureString -AsPlainText $Token TokenType = $tokenType } @@ -294,6 +312,7 @@ } } } + default {} } $contextObj = Set-GitHubContext -Context $context -Default:(!$NotDefault) -PassThru $contextObj | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose $_ } diff --git a/src/functions/public/Auth/Context/Get-GitHubContext.ps1 b/src/functions/public/Auth/Context/Get-GitHubContext.ps1 index 083bf74ec..9f0015551 100644 --- a/src/functions/public/Auth/Context/Get-GitHubContext.ps1 +++ b/src/functions/public/Auth/Context/Get-GitHubContext.ps1 @@ -92,4 +92,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 b/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 index aa27959e0..32ce7a61a 100644 --- a/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 +++ b/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 @@ -59,9 +59,19 @@ $contextItem = Resolve-GitHubContext -Context $contextItem $contextToken = Get-GitHubAccessToken -Context $contextItem -AsPlainText - $isGitHubToken = $contextToken -eq (Get-GitHubToken | ConvertFrom-SecureString -AsPlainText) - if (-not $isGitHubToken -and $contextItem.AuthType -eq 'IAT') { - Revoke-GitHubAppInstallationAccessToken -Context $contextItem + $isNotGitHubToken = -not ($contextToken -eq (Get-GitHubToken | ConvertFrom-SecureString -AsPlainText)) + $isIATAuthType = $contextItem.AuthType -eq 'IAT' + $isNotExpired = $contextItem.TokenExpiresIn -gt 0 + Write-Debug "isNotGitHubToken: $isNotGitHubToken" + Write-Debug "isIATAuthType: $isIATAuthType" + Write-Debug "isNotExpired: $isNotExpired" + if ($isNotGitHubToken -and $isIATAuthType -and $isNotExpired) { + try { + Revoke-GitHubAppInstallationAccessToken -Context $contextItem + } catch { + Write-Debug "[$stackPath] - Failed to revoke token:" + Write-Debug $_ + } } Remove-GitHubContext -Context $contextItem.ID diff --git a/src/functions/public/Config/Get-GitHubConfig.ps1 b/src/functions/public/Config/Get-GitHubConfig.ps1 index 9a2a30922..9471de437 100644 --- a/src/functions/public/Config/Get-GitHubConfig.ps1 +++ b/src/functions/public/Config/Get-GitHubConfig.ps1 @@ -47,4 +47,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/public/Config/Remove-GitHubConfig.ps1 b/src/functions/public/Config/Remove-GitHubConfig.ps1 index 148e3f264..8ae72d979 100644 --- a/src/functions/public/Config/Remove-GitHubConfig.ps1 +++ b/src/functions/public/Config/Remove-GitHubConfig.ps1 @@ -44,4 +44,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/public/Config/Set-GitHubConfig.ps1 b/src/functions/public/Config/Set-GitHubConfig.ps1 index 029b5640b..648ab97a7 100644 --- a/src/functions/public/Config/Set-GitHubConfig.ps1 +++ b/src/functions/public/Config/Set-GitHubConfig.ps1 @@ -54,4 +54,5 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.0' } +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.3' } + diff --git a/src/functions/public/Secrets/Set-GitHubSecret.ps1 b/src/functions/public/Secrets/Set-GitHubSecret.ps1 index a92474e0e..c1da2a007 100644 --- a/src/functions/public/Secrets/Set-GitHubSecret.ps1 +++ b/src/functions/public/Secrets/Set-GitHubSecret.ps1 @@ -143,4 +143,4 @@ Write-Debug "[$stackPath] - End" } } -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.0'} +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2'} diff --git a/src/functions/public/Webhooks/Test-GitHubWebhookSignature.ps1 b/src/functions/public/Webhooks/Test-GitHubWebhookSignature.ps1 index 71b040663..a7e53b49a 100644 --- a/src/functions/public/Webhooks/Test-GitHubWebhookSignature.ps1 +++ b/src/functions/public/Webhooks/Test-GitHubWebhookSignature.ps1 @@ -5,9 +5,9 @@ .DESCRIPTION This function validates the integrity and authenticity of a GitHub webhook request by comparing - the received HMAC SHA-256 signature against a computed hash of the payload using a shared secret. - It uses a constant-time comparison to mitigate timing attacks and returns a boolean indicating - whether the signature is valid. + the received HMAC signature against a computed hash of the payload using a shared secret. + It uses the SHA-256 algorithm and employs a constant-time comparison to mitigate + timing attacks. The function returns a boolean indicating whether the signature is valid. .EXAMPLE Test-GitHubWebhookSignature -Secret $env:WEBHOOK_SECRET -Body $Request.RawBody -Signature $Request.Headers['X-Hub-Signature-256'] @@ -19,6 +19,16 @@ Validates the provided webhook payload against the HMAC SHA-256 signature using the given secret. + .EXAMPLE + Test-GitHubWebhookSignature -Secret $env:WEBHOOK_SECRET -Request $Request + + Output: + ```powershell + True + ``` + + Validates the webhook request using the entire request object, automatically extracting the body and signature. + .OUTPUTS bool @@ -29,11 +39,12 @@ .LINK https://psmodule.io/GitHub/Functions/Webhooks/Test-GitHubWebhookSignature - .LINK - https://docs.github.com/webhooks/using-webhooks/validating-webhook-deliveries + .NOTES + [Validating Webhook Deliveries | GitHub Docs](https://docs.github.com/webhooks/using-webhooks/validating-webhook-deliveries) + [Webhook events and payloads | GitHub Docs](https://docs.github.com/en/webhooks/webhook-events-and-payloads) #> [OutputType([bool])] - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'ByBody')] param ( # The secret key used to compute the HMAC hash. # Example: 'mysecret' @@ -43,25 +54,59 @@ # The JSON body of the GitHub webhook request. # This must be the compressed JSON payload received from GitHub. # Example: '{"action":"opened"}' - [Parameter(Mandatory)] + [Parameter(Mandatory, ParameterSetName = 'ByBody')] [string] $Body, # The signature received from GitHub to compare against. # Example: 'sha256=abc123...' - [Parameter(Mandatory)] - [string] $Signature + [Parameter(Mandatory, ParameterSetName = 'ByBody')] + [string] $Signature, + + # The entire request object containing RawBody and Headers. + # Used in Azure Function Apps or similar environments. + [Parameter(Mandatory, ParameterSetName = 'ByRequest')] + [PSObject] $Request ) - $keyBytes = [Text.Encoding]::UTF8.GetBytes($Secret) - $payloadBytes = [Text.Encoding]::UTF8.GetBytes($Body) + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + } - $hmac = [System.Security.Cryptography.HMACSHA256]::new() - $hmac.Key = $keyBytes - $hashBytes = $hmac.ComputeHash($payloadBytes) - $computedSignature = 'sha256=' + (($hashBytes | ForEach-Object { $_.ToString('x2') }) -join '') + process { + # Handle parameter sets + if ($PSCmdlet.ParameterSetName -eq 'ByRequest') { + $Body = $Request.RawBody + $Signature = $Request.Headers['X-Hub-Signature-256'] - [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals( - [Text.Encoding]::UTF8.GetBytes($computedSignature), - [Text.Encoding]::UTF8.GetBytes($Signature) - ) + # If signature not found, throw an error + if (-not $Signature) { + throw "No webhook signature found in request headers. Expected 'X-Hub-Signature-256' for SHA256 algorithm." + } + } + + $keyBytes = [Text.Encoding]::UTF8.GetBytes($Secret) + $payloadBytes = [Text.Encoding]::UTF8.GetBytes($Body) + + # Create HMAC SHA256 object + $hmac = [System.Security.Cryptography.HMACSHA256]::new() + $algorithmPrefix = 'sha256=' + + $hmac.Key = $keyBytes + $hashBytes = $hmac.ComputeHash($payloadBytes) + $computedSignature = $algorithmPrefix + (($hashBytes | ForEach-Object { $_.ToString('x2') }) -join '') + + # Dispose of the HMAC object + $hmac.Dispose() + + [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals( + [Text.Encoding]::UTF8.GetBytes($computedSignature), + [Text.Encoding]::UTF8.GetBytes($Signature) + ) + } + + end { + Write-Debug "[$stackPath] - End" + } } + diff --git a/src/types/GitHubContext.Types.ps1xml b/src/types/GitHubContext.Types.ps1xml index 2e78158cb..d30c41a11 100644 --- a/src/types/GitHubContext.Types.ps1xml +++ b/src/types/GitHubContext.Types.ps1xml @@ -6,23 +6,15 @@ TokenExpiresIn - if ($null -eq $this.TokenExpiresAt) { return } - $timeRemaining = $this.TokenExpiresAt - [DateTime]::Now - if ($timeRemaining.TotalSeconds -lt 0) { - return [TimeSpan]::Zero - } - return $timeRemaining + if ($null -eq $this.TokenExpiresAt) { return [TimeSpan]::Zero } + $this.TokenExpiresAt - [DateTime]::Now RefreshTokenExpiresIn - if ($null -eq $this.RefreshTokenExpiresAt) { return } - $timeRemaining = $this.RefreshTokenExpiresAt - [DateTime]::Now - if ($timeRemaining.TotalSeconds -lt 0) { - return [TimeSpan]::Zero - } - return $timeRemaining + if ($null -eq $this.RefreshTokenExpiresAt) { return [TimeSpan]::Zero } + $this.RefreshTokenExpiresAt - [DateTime]::Now @@ -33,12 +25,20 @@ TokenExpiresIn - if ($null -eq $this.TokenExpiresAt) { return } - $timeRemaining = $this.TokenExpiresAt - [DateTime]::Now - if ($timeRemaining.TotalSeconds -lt 0) { - return [TimeSpan]::Zero - } - return $timeRemaining + if ($null -eq $this.TokenExpiresAt) { return [TimeSpan]::Zero } + $this.TokenExpiresAt - [DateTime]::Now + + + + + + GitHubAppContext + + + TokenExpiresIn + + if ($null -eq $this.TokenExpiresAt) { return [TimeSpan]::Zero } + $this.TokenExpiresAt - [DateTime]::Now diff --git a/src/variables/private/GitHub.ps1 b/src/variables/private/GitHub.ps1 index 883105756..3891118b3 100644 --- a/src/variables/private/GitHub.ps1 +++ b/src/variables/private/GitHub.ps1 @@ -1,5 +1,5 @@ $script:IsGitHubActions = $env:GITHUB_ACTIONS -eq 'true' -$script:IsFunctionApp = -not [string]::IsNullOrEmpty($env:WEBSITE_PLATFORM_VERSION) +$script:IsFunctionApp = $env:FUNCTIONS_WORKER_RUNTIME -eq 'powershell' $script:IsLocal = -not ($script:IsGitHubActions -or $script:IsFunctionApp) $script:GitHub = [pscustomobject]@{ ContextVault = 'PSModule.GitHub' @@ -18,7 +18,6 @@ $script:GitHub = [pscustomobject]@{ PerPage = 100 RetryCount = 0 RetryInterval = 1 - JwtTimeTolerance = 300 EnvironmentType = Get-GitHubEnvironmentType } Config = $null diff --git a/tests/Apps.Tests.ps1 b/tests/Apps.Tests.ps1 index 8904b0a8c..44803729b 100644 --- a/tests/Apps.Tests.ps1 +++ b/tests/Apps.Tests.ps1 @@ -24,8 +24,8 @@ Describe 'Apps' { Context 'As using on ' -ForEach $authCases { BeforeAll { - $context = Connect-GitHubAccount @connectParams -PassThru -Silent LogGroup 'Context' { + $context = Connect-GitHubAccount @connectParams -PassThru -Silent Write-Host ($context | Format-List | Out-String) } } diff --git a/tests/Artifacts.Tests.ps1 b/tests/Artifacts.Tests.ps1 index eafdbe1b8..e9142a967 100644 --- a/tests/Artifacts.Tests.ps1 +++ b/tests/Artifacts.Tests.ps1 @@ -183,6 +183,24 @@ Describe 'Artifacts' { $result | Should -BeOfType [GitHubArtifact] } + It 'GitHubArtifact.Size - Stores size in bytes (nullable UInt64)' { + $params = @{ + Owner = $Owner + Repository = $Repository + WorkflowRunId = $WorkflowRunId + Name = $ArtifactName + } + $artifact = Get-GitHubArtifact @params + LogGroup 'Artifact Size Test' { + Write-Host "$($artifact | Format-Table -AutoSize | Out-String)" + } + if ($null -ne $artifact.Size) { + $artifact.Size | Should -BeOfType [System.UInt64] + } else { + $artifact.Size | Should -BeNullOrEmpty + } + } + It 'Save-GitHubArtifact - Saves the artifact to disk' { $params = @{ Owner = $Owner diff --git a/tests/Emojis.Tests.ps1 b/tests/Emojis.Tests.ps1 new file mode 100644 index 000000000..ff3100a2e --- /dev/null +++ b/tests/Emojis.Tests.ps1 @@ -0,0 +1,67 @@ +#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '5.7.1' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester grouping syntax: known issue.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'Used to create a secure string for testing.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Log outputs to GitHub Actions logs.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Long test descriptions and skip switches' +)] +[CmdletBinding()] +param() + +Describe 'Emojis' { + $authCases = . "$PSScriptRoot/Data/AuthCases.ps1" + + Context 'As using on ' -ForEach $authCases { + BeforeAll { + $context = Connect-GitHubAccount @connectParams -PassThru -Silent + LogGroup 'Context' { + Write-Host ($context | Format-List | Out-String) + } + } + AfterAll { + Get-GitHubContext -ListAvailable | Disconnect-GitHubAccount -Silent + Write-Host ('-' * 60) + } + + # Tests for APP goes here + if ($AuthType -eq 'APP') { + It 'Connect-GitHubApp - Connects as a GitHub App to ' { + $context = Connect-GitHubApp @connectAppParams -PassThru -Default -Silent + LogGroup 'Context' { + Write-Host ($context | Format-List | Out-String) + } + } + } + + # Tests for runners goes here + if ($Type -eq 'GitHub Actions') {} + + # Tests for IAT UAT and PAT goes here + It 'Get-GitHubEmoji - Gets a list of all emojis' { + $emojis = Get-GitHubEmoji + LogGroup 'emojis' { + Write-Host ($emojis | Format-Table | Out-String) + } + $emojis | Should -Not -BeNullOrEmpty + } + It 'Get-GitHubEmoji - Downloads all emojis' { + Get-GitHubEmoji -Path $Home + LogGroup 'emojis' { + $emojis = Get-ChildItem -Path $Home -File + Write-Host ($emojis | Format-Table | Out-String) + } + $emojis | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/GitHub.Tests.ps1 b/tests/GitHub.Tests.ps1 index 474160fbf..53b9e347e 100644 --- a/tests/GitHub.Tests.ps1 +++ b/tests/GitHub.Tests.ps1 @@ -79,6 +79,9 @@ Describe 'Auth' { Write-Host ($context | Format-List | Out-String) } $context | Should -Not -BeNullOrEmpty + $context | Should -BeOfType [GitHubContext] + $context.TokenExpiresAt | Should -BeOfType [DateTime] + $context.TokenExpiresIn | Should -BeOfType [TimeSpan] } It 'Connect-GitHubApp - Connects as a GitHub App to ' -Skip:($AuthType -ne 'APP') { @@ -106,6 +109,8 @@ Describe 'Auth' { LogGroup 'Connect-GithubApp' { $context } + $context.TokenExpiresAt | Should -BeOfType [DateTime] + $context.TokenExpiresIn | Should -BeOfType [TimeSpan] LogGroup 'Context' { Write-Host ($context | Format-List | Out-String) } @@ -179,6 +184,37 @@ Describe 'Auth' { } } +Describe 'Anonymous - Functions that can run anonymously' { + It 'Get-GithubRateLimit - Using -Anonymous' { + $rateLimit = Get-GitHubRateLimit -Anonymous + LogGroup 'Rate Limit' { + Write-Host ($rateLimit | Format-Table | Out-String) + } + $rateLimit | Should -Not -BeNullOrEmpty + } + It 'Invoke-GitHubAPI - Using -Anonymous' { + $rateLimit = Invoke-GitHubAPI -ApiEndpoint '/rate_limit' -Anonymous | Select-Object -ExpandProperty Response + LogGroup 'Rate Limit' { + Write-Host ($rateLimit | Format-Table | Out-String) + } + $rateLimit | Should -Not -BeNullOrEmpty + } + It 'Get-GithubRateLimit - Using -Context Anonymous' { + $rateLimit = Get-GitHubRateLimit -Context Anonymous + LogGroup 'Rate Limit' { + Write-Host ($rateLimit | Format-List | Out-String) + } + $rateLimit | Should -Not -BeNullOrEmpty + } + It 'Invoke-GitHubAPI - Using -Context Anonymous' { + $rateLimit = Invoke-GitHubAPI -ApiEndpoint '/rate_limit' -Context Anonymous | Select-Object -ExpandProperty Response + LogGroup 'Rate Limit' { + Write-Host ($rateLimit | Format-Table | Out-String) + } + $rateLimit | Should -Not -BeNullOrEmpty + } +} + Describe 'GitHub' { Context 'Config' { It 'Get-GitHubConfig - Gets the module configuration' { @@ -732,90 +768,41 @@ Describe 'API' { } } -Describe 'Emojis' { - $authCases = . "$PSScriptRoot/Data/AuthCases.ps1" - - Context 'As using on ' -ForEach $authCases { - BeforeAll { - $context = Connect-GitHubAccount @connectParams -PassThru -Silent - LogGroup 'Context' { - Write-Host ($context | Format-List | Out-String) - } - } - AfterAll { - Get-GitHubContext -ListAvailable | Disconnect-GitHubAccount -Silent - Write-Host ('-' * 60) - } - - # Tests for APP goes here - if ($AuthType -eq 'APP') { - It 'Connect-GitHubApp - Connects as a GitHub App to ' { - $context = Connect-GitHubApp @connectAppParams -PassThru -Default -Silent - LogGroup 'Context' { - Write-Host ($context | Format-List | Out-String) - } - } - } - - # Tests for runners goes here - if ($Type -eq 'GitHub Actions') {} - - # Tests for IAT UAT and PAT goes here - It 'Get-GitHubEmoji - Gets a list of all emojis' { - $emojis = Get-GitHubEmoji - LogGroup 'emojis' { - Write-Host ($emojis | Format-Table | Out-String) - } - $emojis | Should -Not -BeNullOrEmpty - } - It 'Get-GitHubEmoji - Downloads all emojis' { - Get-GitHubEmoji -Path $Home - LogGroup 'emojis' { - $emojis = Get-ChildItem -Path $Home -File - Write-Host ($emojis | Format-Table | Out-String) - } - $emojis | Should -Not -BeNullOrEmpty - } - } -} - Describe 'Webhooks' { - It 'Test-GitHubWebhookSignature - Validates the webhook payload using known correct signature' { + BeforeAll { $secret = "It's a Secret to Everybody" $payload = 'Hello, World!' $signature = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17' + } + + It 'Test-GitHubWebhookSignature - Validates the webhook payload using known correct signature (SHA256)' { $result = Test-GitHubWebhookSignature -Secret $secret -Body $payload -Signature $signature $result | Should -Be $true } -} -Describe 'Anonymous - Functions that can run anonymously' { - It 'Get-GithubRateLimit - Using -Anonymous' { - $rateLimit = Get-GitHubRateLimit -Anonymous - LogGroup 'Rate Limit' { - Write-Host ($rateLimit | Format-Table | Out-String) - } - $rateLimit | Should -Not -BeNullOrEmpty - } - It 'Invoke-GitHubAPI - Using -Anonymous' { - $rateLimit = Invoke-GitHubAPI -ApiEndpoint '/rate_limit' -Anonymous | Select-Object -ExpandProperty Response - LogGroup 'Rate Limit' { - Write-Host ($rateLimit | Format-Table | Out-String) + It 'Test-GitHubWebhookSignature - Validates the webhook using Request object' { + $mockRequest = [PSCustomObject]@{ + RawBody = $payload + Headers = @{ + 'X-Hub-Signature-256' = $signature + } } - $rateLimit | Should -Not -BeNullOrEmpty + $result = Test-GitHubWebhookSignature -Secret $secret -Request $mockRequest + $result | Should -Be $true } - It 'Get-GithubRateLimit - Using -Context Anonymous' { - $rateLimit = Get-GitHubRateLimit -Context Anonymous - LogGroup 'Rate Limit' { - Write-Host ($rateLimit | Format-List | Out-String) - } - $rateLimit | Should -Not -BeNullOrEmpty + + It 'Test-GitHubWebhookSignature - Should fail with invalid signature' { + $invalidSignature = 'sha256=invalid' + $result = Test-GitHubWebhookSignature -Secret $secret -Body $payload -Signature $invalidSignature + $result | Should -Be $false } - It 'Invoke-GitHubAPI - Using -Context Anonymous' { - $rateLimit = Invoke-GitHubAPI -ApiEndpoint '/rate_limit' -Context Anonymous | Select-Object -ExpandProperty Response - LogGroup 'Rate Limit' { - Write-Host ($rateLimit | Format-Table | Out-String) + + It 'Test-GitHubWebhookSignature - Should throw when signature header is missing from request' { + $mockRequest = [PSCustomObject]@{ + RawBody = $payload + Headers = @{} } - $rateLimit | Should -Not -BeNullOrEmpty + + { Test-GitHubWebhookSignature -Secret $secret -Request $mockRequest } | Should -Throw } } diff --git a/tests/GitHubFormatter.Tests.ps1 b/tests/GitHubFormatter.Tests.ps1 new file mode 100644 index 000000000..63f55c7c3 --- /dev/null +++ b/tests/GitHubFormatter.Tests.ps1 @@ -0,0 +1,97 @@ +#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '5.7.1' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester grouping syntax: known issue.' +)] +[CmdletBinding()] +param() + +# This test file validates size property standardization across GitHub classes +# It focuses on unit conversion and formatting expectations rather than live API calls + +Describe 'Size Property Standardization Tests' { + + Context 'Unit Conversion Logic' { + It 'Validates KB to Bytes conversion formula' { + # Test the conversion used in GitHubRepository and GitHubOrganization + $apiValueKB = 108 # API returns this in KB + $expectedBytes = $apiValueKB * 1KB # 110,592 bytes + $expectedBytes | Should -Be 110592 + + $apiValueKB = 10000 # API returns this in KB + $expectedBytes = $apiValueKB * 1KB # 10,240,000 bytes + $expectedBytes | Should -Be 10240000 + } + + It 'Validates that size values are stored as expected types' { + # Verify that our expected byte values fit within UInt32 range + $maxReasonableSize = 4GB - 1 # Max reasonable repository size (just under 4GB) + $maxReasonableSize | Should -BeLessOrEqual ([System.UInt32]::MaxValue) + + # Test boundary cases + $zeroBytes = 0 * 1KB + $zeroBytes | Should -Be 0 + $zeroBytes | Should -BeOfType [System.Int32] + + $smallSize = 1 * 1KB + $smallSize | Should -Be 1024 + $smallSize | Should -BeOfType [System.Int32] + } + } + + Context 'Expected Format Output Patterns' { + It 'Validates expected format patterns for size display' { + # These tests verify the expected output patterns without requiring the actual formatter + # They document what the GitHubFormatter::FormatFileSize method should produce + + $testCases = @( + @{ Bytes = 0; ExpectedPattern = '\d+\s+B' } # "0 B" + @{ Bytes = 512; ExpectedPattern = '\d+\s+B' } # "512 B" + @{ Bytes = 1024; ExpectedPattern = '\d+\.\d{2} KB' } # "1.00 KB" + @{ Bytes = 1048576; ExpectedPattern = '\d+\.\d{2} MB' } # "1.00 MB" + @{ Bytes = 1073741824; ExpectedPattern = '\d+\.\d{2} GB' } # "1.00 GB" + @{ Bytes = 110592; ExpectedPattern = '\d+\.\d{2} KB' } # "108.00 KB" + ) + + foreach ($case in $testCases) { + # Document expected pattern - actual formatting tested in integration tests + $case.ExpectedPattern | Should -Match '\w+' # Verify pattern is non-empty + } + } + } + + Context 'Conversion Scenarios Documentation' { + It 'Documents the standardization changes made' { + # This test documents the before/after behavior for size properties + + # GitHubRepository: Before stored KB, now stores bytes + $beforeValue = 108 # KB from API + $afterValue = $beforeValue * 1KB # bytes (110,592) + $afterValue | Should -Be 110592 + $afterValue | Should -BeGreaterThan $beforeValue # Verify conversion increases value + + # GitHubOrganization: Before had DiskUsage in KB, now has Size in bytes with DiskUsage alias + $orgBeforeValue = 10000 # KB from API + $orgAfterValue = $orgBeforeValue * 1KB # bytes (10,240,000) + $orgAfterValue | Should -Be 10240000 + $orgAfterValue | Should -BeGreaterThan $orgBeforeValue + + # GitHubArtifact: Was already in bytes, now uses standardized formatter + # No conversion needed, just formatting change + $artifactSize = 2048576 # Already in bytes + $artifactSize | Should -BeGreaterThan 1MB # Verify it's a reasonable size + } + + It 'Verifies that byte storage allows for consistent formatting' { + # All classes now store in bytes, enabling consistent formatting + $sizes = @(110592, 10240000, 2048576) # Example sizes from Repository, Organization, Artifact + + foreach ($size in $sizes) { + $size | Should -BeOfType [System.Int32] + $size | Should -BeGreaterThan 0 + # All can be formatted with the same GitHubFormatter::FormatFileSize method + } + } + } +} diff --git a/tests/Organizations.Tests.ps1 b/tests/Organizations.Tests.ps1 index 7d1740e5c..41e6d7221 100644 --- a/tests/Organizations.Tests.ps1 +++ b/tests/Organizations.Tests.ps1 @@ -38,12 +38,18 @@ Describe 'Organizations' { $orgName = "$orgPrefix$number" if ($AuthType -eq 'APP') { + LogGroup 'Pre-test Cleanup - App Installations' { + Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -like "$orgPrefix*" } | + Uninstall-GitHubApp -Confirm:$false + } + $installationContext = Connect-GitHubApp @connectAppParams -PassThru -Default -Silent LogGroup 'Context - Installation' { Write-Host ($installationContext | Select-Object * | Out-String) } } } + AfterAll { Get-GitHubContext -ListAvailable | Disconnect-GitHubAccount -Silent Write-Host ('-' * 60) @@ -55,6 +61,19 @@ Describe 'Organizations' { Write-Host ($organization | Select-Object * | Out-String) } $organization | Should -Not -BeNullOrEmpty + $organization | Should -BeOfType 'GitHubOrganization' + } + + It 'GitHubOrganization.Size - Stores size in bytes (nullable UInt64)' -Skip:($OwnerType -ne 'organization') { + $organization = Get-GitHubOrganization -Name $Owner + LogGroup 'Organization' { + Write-Host "$($organization | Select-Object * | Out-String)" + } + if ($null -ne $organization.Size) { + $organization.Size | Should -BeOfType [System.UInt64] + } else { + $organization.Size | Should -BeNullOrEmpty + } } It "Get-GitHubOrganization - List public organizations for the user 'psmodule-user'" { @@ -147,6 +166,23 @@ Describe 'Organizations' { Remove-GitHubOrganization -Name $orgName -Confirm:$false -Context $orgContext } + It 'Uninstall-GitHubApp - Removes app installation after organization deletion' -Skip:($OwnerType -ne 'enterprise') { + LogGroup 'Post-deletion Cleanup - App Installations' { + try { + $installations = Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -eq $orgName } + foreach ($installation in $installations) { + Write-Host "Removing app installation ID: $($installation.ID) for deleted organization: $($installation.Target.Name)" + Uninstall-GitHubApp -Target $orgName -Context $context -Confirm:$false + } + $remainingInstallations = Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -eq $orgName } + $remainingInstallations | Should -BeNullOrEmpty + } catch { + Write-Host "Failed to clean up app installations after organization deletion: $($_.Exception.Message)" + throw + } + } + } + Context 'Invitations' -Skip:($Owner -notin 'psmodule-test-org', 'psmodule-test-org2') { It 'New-GitHubOrganizationInvitation - Invites a user to an organization' { { diff --git a/tests/Repositories.Tests.ps1 b/tests/Repositories.Tests.ps1 index 85573feaf..402b65418 100644 --- a/tests/Repositories.Tests.ps1 +++ b/tests/Repositories.Tests.ps1 @@ -283,6 +283,26 @@ Describe 'Repositories' { $repo.IsArchived | Should -Be $false } } + + It 'GitHubRepository.Size - Stores size in bytes (nullable UInt64)' -Skip:($OwnerType -in ('repository', 'enterprise')) { + LogGroup 'Repository Size Test' { + switch ($OwnerType) { + 'user' { + $repo = Get-GitHubRepository -Name $repoName + } + 'organization' { + $repo = Get-GitHubRepository -Owner $owner -Name $repoName + } + } + Write-Host "$($repo | Format-Table -AutoSize | Out-String)" + } + if ($null -ne $repo.Size) { + $repo.Size | Should -BeOfType [System.UInt64] + } else { + $repo.Size | Should -BeNullOrEmpty + } + } + Context 'Permissions' -Skip:($OwnerType -ne 'Organization') { It 'Set-GitHubRepositoryPermission - Sets the repository permissions - Admin' { $permission = 'admin' diff --git a/tests/Secrets.Tests.ps1 b/tests/Secrets.Tests.ps1 index 2384187ab..48bff8af7 100644 --- a/tests/Secrets.Tests.ps1 +++ b/tests/Secrets.Tests.ps1 @@ -129,6 +129,7 @@ Describe 'Secrets' { Write-Host ($org | Format-List | Out-String) } } + Context 'PublicKey' { It 'Get-GitHubPublicKey - Action' { $result = Get-GitHubPublicKey @scope @@ -139,18 +140,13 @@ Describe 'Secrets' { } It 'Get-GitHubPublicKey - Codespaces' { - switch ($org.plan.name) { - 'free' { - { Get-GitHubPublicKey @scope -Type codespaces } | Should -Throw - } - default { - $result = Get-GitHubPublicKey @scope -Type codespaces - LogGroup 'PublicKey' { - Write-Host "$($result | Select-Object * | Format-Table -AutoSize | Out-String)" - } - $result | Should -Not -BeNullOrEmpty - } + $plan = $org.plan.name + Write-Host "Running with plan [$plan]" + $result = Get-GitHubPublicKey @scope -Type codespaces + LogGroup 'PublicKey' { + Write-Host "$($result | Select-Object * | Format-Table -AutoSize | Out-String)" } + $result | Should -Not -BeNullOrEmpty } }