tests/functions/Connect-MtGitHub.Tests.ps1
|
BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force } Describe 'Connect-MtGitHub' { BeforeEach { $script:savedMaesterGitHubToken = $env:MAESTER_GITHUB_TOKEN $script:savedGhToken = $env:GH_TOKEN $env:MAESTER_GITHUB_TOKEN = $null $env:GH_TOKEN = $null InModuleScope Maester { $__MtSession.GitHubConnection = $null $__MtSession.GitHubAuthHeader = $null $__MtSession.GitHubCache = @{} # Pre-load an empty config so the lazy-load path is skipped in most tests. # Tests that exercise lazy-load explicitly reset this to $null. $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{} } } } AfterEach { $env:MAESTER_GITHUB_TOKEN = $script:savedMaesterGitHubToken $env:GH_TOKEN = $script:savedGhToken InModuleScope Maester { $__MtSession.GitHubConnection = $null $__MtSession.GitHubAuthHeader = $null $__MtSession.GitHubCache = @{} $__MtSession.MaesterConfig = $null } } Context 'Failure: NotConfigured' { It 'Sets FailureReason = NotConfigured when no org and config has no GitHubOrganization' { Connect-MtGitHub InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'NotConfigured' } } } Context 'Failure: NoToken' { It 'Sets FailureReason = NoToken when org is given but no token is available' { Mock Get-MtUserInteractive -ModuleName Maester { $false } # Token env vars are cleared in BeforeEach; no -Token param; non-interactive sessions cannot device-auth. Connect-MtGitHub -Organization 'myorg' InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'NoToken' } } } Context 'Maester GitHub App device flow' { BeforeEach { Mock Get-MtUserInteractive -ModuleName Maester { $true } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}' } } } It 'Uses the Maester GitHub App client ID when no token is supplied' { $expiresAt = [datetime]'2030-01-01T00:00:00Z' Mock Get-MtGitHubAppDeviceToken -ModuleName Maester -ParameterFilter { $ClientId -eq 'Iv23liV3mw0hSq0gn957' } { [PSCustomObject]@{ AccessToken = 'ghu_device'; ExpiresAt = $expiresAt; FailureReason = $null } } Connect-MtGitHub -Organization 'myorg' 3>$null Should -Invoke Get-MtGitHubAppDeviceToken -ModuleName Maester -Times 1 -Exactly -ParameterFilter { $ClientId -eq 'Iv23liV3mw0hSq0gn957' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 4 -ParameterFilter { $Headers['Authorization'] -eq 'Bearer ghu_device' } InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.AuthenticationType | Should -Be 'GitHubAppDeviceFlow' $__MtSession.GitHubConnection.TokenExpiresAt | Should -Be ([datetime]'2030-01-01T00:00:00Z') $__MtSession.GitHubAuthHeader.Authorization | Should -Be 'Bearer ghu_device' } } It 'Records the device flow failure reason and does not retain an auth header' { Mock Get-MtGitHubAppDeviceToken -ModuleName Maester { [PSCustomObject]@{ AccessToken = $null; ExpiresAt = $null; FailureReason = 'GitHubDeviceFlowDenied' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'GitHubDeviceFlowDenied' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } } Context 'Maester GitHub App organization install retry' { BeforeEach { $script:membershipProbeCount = 0 Mock Get-MtUserInteractive -ModuleName Maester { $true } Mock Get-MtGitHubAppDeviceToken -ModuleName Maester { [PSCustomObject]@{ AccessToken = 'ghu_device'; ExpiresAt = $null; FailureReason = $null } } Mock Request-MtGitHubAppOrganizationInstall -ModuleName Maester { $true } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { $script:membershipProbeCount++ if ($script:membershipProbeCount -eq 1) { $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } $ex = [System.Exception]::new('You do not have access to this organization membership.') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp throw $ex } [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } } It 'Prompts for org app install once and retries the membership probe' { Connect-MtGitHub -Organization 'myorg' 3>$null Should -Invoke Request-MtGitHubAppOrganizationInstall -ModuleName Maester -Times 1 -Exactly -ParameterFilter { $Organization -eq 'myorg' -and $InstallUrl -eq 'https://github.com/apps/maester-cli/installations/new' -and $Reason -match 'organization membership' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 2 -Exactly -ParameterFilter { $Uri -eq 'https://api.github.com/user/memberships/orgs/myorg' } InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.AuthenticationType | Should -Be 'GitHubAppDeviceFlow' $__MtSession.GitHubAuthHeader.Authorization | Should -Be 'Bearer ghu_device' } } } Context 'Failure: TokenInvalid' { It 'Sets FailureReason = TokenInvalid on HTTP 401 from /user' { $env:MAESTER_GITHUB_TOKEN = 'bad-token' $fakeResp = [PSCustomObject]@{ StatusCode = 401; Headers = @{} } $ex = [System.Exception]::new('Unauthorized') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'TokenInvalid' } } } Context 'Failure: ApiBaseUriFailed' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' } It 'Sets FailureReason = ApiBaseUriFailed when /user throws with no Response/StatusCode (DNS/TLS/transport)' { # Plain exception with no Response — Get-MtGitHubErrorStatusCode returns $null, # which models DNS failure, TLS handshake failure, connection refused, or an # unreachable GHE base URI. Must not be classified as TokenInvalid. Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw [System.Exception]::new('No such host is known') } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiBaseUriFailed' $c.FailureReason | Should -Not -Be 'TokenInvalid' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } } Context 'Failure: ApiUnavailable' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' } It 'Sets FailureReason = ApiUnavailable when /user returns HTTP 500 (server error, URI is fine)' { # 5xx means GitHub responded — the base URI resolved and TLS succeeded — but the # service itself is failing. Must not be conflated with TokenInvalid or ApiBaseUriFailed. $fakeResp = [PSCustomObject]@{ StatusCode = 500; Headers = @{} } $ex = [System.Exception]::new('Internal Server Error') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 500 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Internal Server Error' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiUnavailable' $c.FailureReason | Should -Not -Be 'TokenInvalid' $c.FailureReason | Should -Not -Be 'ApiBaseUriFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Sets FailureReason = ApiUnavailable when /user returns HTTP 503' { $fakeResp = [PSCustomObject]@{ StatusCode = 503; Headers = @{} } $ex = [System.Exception]::new('Service Unavailable') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 503 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Service Unavailable' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiUnavailable' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } } Context 'Failure: OrgAccessFailed' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } } It 'Sets FailureReason = OrgAccessFailed on HTTP 403 from /orgs/{org}' { $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'OrgAccessFailed' } } It 'Sets FailureReason = OrgAccessFailed on HTTP 404 from /orgs/{org}' { $fakeResp = [PSCustomObject]@{ StatusCode = 404; Headers = @{} } $ex = [System.Exception]::new('Not Found') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeFalse $__MtSession.GitHubConnection.FailureReason | Should -Be 'OrgAccessFailed' } } It 'Sets FailureReason = ApiBaseUriFailed when /orgs/{org} throws transport exception (no Response)' { # /user succeeded, then DNS/TLS fails on the second probe. Must not be classified # as OrgAccessFailed — the org access can't be determined when there's no HTTP response. Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw [System.Exception]::new('Connection reset by peer') } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiBaseUriFailed' $c.FailureReason | Should -Not -Be 'OrgAccessFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Sets FailureReason = ApiUnavailable when /orgs/{org} returns HTTP 500' { $fakeResp = [PSCustomObject]@{ StatusCode = 500; Headers = @{} } $ex = [System.Exception]::new('Internal Server Error') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 500 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Internal Server Error' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiUnavailable' $c.FailureReason | Should -Not -Be 'OrgAccessFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } } Context 'Successful connection' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}' } } } It 'Sets Connected = $true and stores GitHubAuthHeader' { Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' $__MtSession.GitHubConnection.TokenLogin | Should -Be 'testuser' $__MtSession.GitHubConnection.AdministrationPermissionVerified | Should -BeTrue $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty $__MtSession.GitHubAuthHeader['Authorization'] | Should -Match '^Bearer ' } } It 'All four probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.myco.ghe.com' -ApiVersion '2024-01-01' 3>$null Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 4 -ParameterFilter { $Uri -match 'api\.myco\.ghe\.com' -and $Headers['X-GitHub-Api-Version'] -eq '2024-01-01' } } It 'GHE.com data residency: all four endpoint paths target the configured base URI' { Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.octocorp.ghe.com' 3>$null Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.octocorp.ghe.com/user' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.octocorp.ghe.com/orgs/myorg' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.octocorp.ghe.com/user/memberships/orgs/myorg' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.octocorp.ghe.com/orgs/myorg/actions/permissions' } } } Context 'Role probe' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } } It 'admin + active: no warning, Role=admin, RoleVerified=$true, RoleState=active' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null $warns.Count | Should -Be 0 InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.Role | Should -Be 'admin' $c.RoleState | Should -Be 'active' $c.RoleVerified | Should -BeTrue $c.RoleVerificationFailureReason | Should -BeNullOrEmpty $c.AdministrationPermissionVerified | Should -BeTrue } } It 'admin + pending: fails connection with FailureReason = OrgMembershipPending and clears auth header' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"pending","role":"admin"}' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipPending' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'member + pending: fails connection with FailureReason = OrgMembershipPending' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"pending","role":"member"}' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipPending' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'member + active: warning matches admin/owner phrasing (not "owner role"); Role=member, RoleVerified=$true' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"member"}' } } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null $warns.Count | Should -BeGreaterOrEqual 1 ($warns -join ' ') | Should -Match 'admin/owner|full CIS coverage' ($warns -join ' ') | Should -Not -Match 'owner role' InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.Role | Should -Be 'member' $c.RoleVerified | Should -BeTrue } } It 'unexpected role string: warning emitted; fields populated as returned' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"billing_manager"}' } } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null $warns.Count | Should -BeGreaterOrEqual 1 InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.Role | Should -Be 'billing_manager' $c.RoleState | Should -Be 'active' $c.RoleVerified | Should -BeTrue } } } Context 'Administration permission probe' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } } It 'admin + active + admin probe 200: Connected=true, AdministrationPermissionVerified=true, no admin warning, four IWR calls' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null ($warns -join ' ') | Should -Not -Match 'administration API access' InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.AdministrationPermissionVerified | Should -BeTrue $c.AdministrationPermissionFailureReason | Should -BeNullOrEmpty $c.AdministrationPermissionStatusCode | Should -Be 200 } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 4 } It 'admin + active + admin probe 403: Connected=true, FailureReason=null, AdministrationPermissionVerified=false, warning mentions both permission models' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{ 'x-accepted-github-permissions' = 'administration=read' } } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Resource not accessible by personal access token' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { throw $ex } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null $combined = $warns -join ' ' $combined | Should -Match 'admin:org' $combined | Should -Match 'Administration: read' InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.FailureReason | Should -BeNullOrEmpty $c.Role | Should -Be 'admin' $c.AdministrationPermissionVerified | Should -BeFalse $c.AdministrationPermissionStatusCode | Should -Be 403 $c.AdministrationPermissionFailureReason | Should -Match 'HTTP 403' $c.AdministrationPermissionAcceptedPermissions | Should -Be 'administration=read' } } It 'member + active + admin probe 403: emits both role and admin-permission warnings' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"member"}' } } $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Resource not accessible' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { throw $ex } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null $warns.Count | Should -BeGreaterOrEqual 2 $combined = $warns -join ' ' $combined | Should -Match 'admin/owner|full CIS coverage' $combined | Should -Match 'administration API access|Administration: read' InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.Role | Should -Be 'member' $c.AdministrationPermissionVerified | Should -BeFalse } } } Context 'Failure: OrgMembershipFailed' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } } It 'Membership HTTP 403 fails connection with FailureReason = OrgMembershipFailed' { $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Insufficient permissions to read membership.' } Mock Request-MtGitHubAppOrganizationInstall -ModuleName Maester { throw 'Token-based auth must not prompt for GitHub App installation.' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipFailed' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Membership HTTP 404 fails connection with FailureReason = OrgMembershipFailed' { $fakeResp = [PSCustomObject]@{ StatusCode = 404; Headers = @{} } $ex = [System.Exception]::new('Not Found') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 404 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Not Found' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipFailed' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It '200 with malformed JSON body fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = 'not-json{' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipFailed' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It '200 with valid JSON missing role field fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active"}' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipFailed' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It '200 with valid JSON missing state field fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"role":"admin"}' } } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'OrgMembershipFailed' # Security property: failed connection must not leave a Bearer header in session. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Sets FailureReason = ApiBaseUriFailed when /memberships/ throws transport exception (no Response)' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw [System.Exception]::new('No such host is known') } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiBaseUriFailed' $c.FailureReason | Should -Not -Be 'OrgMembershipFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Sets FailureReason = ApiUnavailable when /memberships/ returns HTTP 503' { $fakeResp = [PSCustomObject]@{ StatusCode = 503; Headers = @{} } $ex = [System.Exception]::new('Service Unavailable') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 503 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Service Unavailable' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'ApiUnavailable' $c.FailureReason | Should -Not -Be 'OrgMembershipFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } } Context 'Failure: RateLimited' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' } It '/user 403 with x-ratelimit-remaining=0 sets FailureReason=RateLimited and clears auth header' { $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'RateLimited' $c.FailureReason | Should -Not -Be 'TokenInvalid' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It '/orgs/{org} 429 with retry-after sets FailureReason=RateLimited (not OrgAccessFailed)' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } $fakeResp = [PSCustomObject]@{ StatusCode = 429 Headers = @{ 'retry-after' = '60' } } $ex = [System.Exception]::new('Too Many Requests') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'RateLimited' $c.FailureReason | Should -Not -Be 'OrgAccessFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It '/memberships/ 403 with x-ratelimit-remaining=0 sets FailureReason=RateLimited (not OrgMembershipFailed)' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'RateLimited' $c.FailureReason | Should -Not -Be 'OrgMembershipFailed' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Admin probe 429 with retry-after: Connected=true, FailureReason=null, admin permission marked rate-limited, warning mentions rate limit' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } $fakeResp = [PSCustomObject]@{ StatusCode = 429 Headers = @{ 'retry-after' = '90' } } $ex = [System.Exception]::new('Too Many Requests') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { throw $ex } $warns = @() Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.FailureReason | Should -BeNullOrEmpty $c.AdministrationPermissionVerified | Should -BeFalse $c.AdministrationPermissionStatusCode | Should -Be 429 $c.AdministrationPermissionFailureReason | Should -Match 'rate limit' } ($warns -join ' ') | Should -Match 'rate limit' } } Context 'Failure: InvalidApiBaseUri' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' } It 'Config http:// URI fails before any web request' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiBaseUri = 'http://api.example.com' } } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' # Security property: invalid URI must short-circuit before headers are stored. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Config non-URI value fails before any web request' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiBaseUri = 'not-a-uri' } } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' # Security property: invalid URI must short-circuit before headers are stored. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Parameter -ApiBaseUri http:// fails before any web request and clears prior session state' { # Pre-seed a stale session to prove the parameter validation path no longer throws # before the session-clear step at the top of Connect-MtGitHub runs. InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'http://api.example.com' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' # Stale session state must be cleared even when the parameter value is rejected. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Parameter -ApiBaseUri "not-a-uri" fails before any web request and clears prior session state' { InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'not-a-uri' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Parameter https URI with trailing slash still works and is trimmed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com/' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiBaseUri | Should -Be 'https://api.github.com' } } It 'api.github.com (SaaS) passes the host allowlist' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.ApiBaseUri | Should -Be 'https://api.github.com' } } It 'api.<subdomain>.ghe.com (GHE.com data residency) passes the host allowlist' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.octocorp.ghe.com' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.ApiBaseUri | Should -Be 'https://api.octocorp.ghe.com' } } It 'Non-allowlisted https host fails before any web request and clears prior session state' { # Pre-seed a stale session to confirm session-clear runs before host validation rejects the URI. InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri host is not allowlisted' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://evil.example.com' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' # Security property: GitHub tokens must never be sent to non-GitHub hosts; auth header must be cleared. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Non-allowlisted https host with /api/v3 path (GHES on-prem shape) is rejected' { Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri host is not allowlisted' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://github.example.com/api/v3' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Allowlisted host with a query string is rejected before any web request' { Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri has a query string' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com?foo=bar' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Allowlisted host with a fragment is rejected before any web request' { Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri has a fragment' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com#frag' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Allowlisted host with non-default port is rejected before any web request' { Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri uses a non-default port' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com:8443' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Allowlisted host with extra path component is rejected' { # api.github.com/api/v3 has the right host but a non-root path; reject so the GitHub token isn't # sent to an unexpected endpoint that could be a proxy or path-rewriting middleware. Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri has a non-root path' } Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com/api/v3' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiBaseUri' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } } Context 'Config fallback: pre-loaded MaesterConfig' { It 'Resolves org from pre-loaded MaesterConfig without calling Get-MtMaesterConfig' { $env:MAESTER_GITHUB_TOKEN = 'valid-token' InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubOrganization = 'config-org' } } } Mock Get-MtMaesterConfig -ModuleName Maester { throw 'Get-MtMaesterConfig must not be called when config is pre-loaded' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"config-org"}' } } Connect-MtGitHub 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'config-org' } } } Context 'Config fallback: lazy-load when MaesterConfig is null' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' # Reset to null so the lazy-load path fires InModuleScope Maester { $__MtSession.MaesterConfig = $null } $fakeConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubOrganization = 'lazy-org' GitHubApiBaseUri = 'https://api.lazy.ghe.com' GitHubApiVersion = '2024-06-01' } } Mock Get-MtMaesterConfig -ModuleName Maester { $fakeConfig } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"lazy-org"}' } } } It 'Lazy-loads config and resolves org when MaesterConfig is null and no -Organization supplied' { Connect-MtGitHub 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'lazy-org' # $__MtSession.MaesterConfig is now set; real Get-MtMaesterConfigGlobalSetting reads it $__MtSession.MaesterConfig.GlobalSettings.GitHubOrganization | Should -Be 'lazy-org' } } It 'Lazy-loads config for ApiBaseUri and ApiVersion when -Organization is supplied but others are omitted' { Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.ApiBaseUri | Should -Be 'https://api.lazy.ghe.com' $__MtSession.GitHubConnection.ApiVersion | Should -Be '2024-06-01' } } It 'All three config-backed values (org, ApiBaseUri, ApiVersion) are resolved from lazy-loaded config' { Connect-MtGitHub 3>$null InModuleScope Maester { $conn = $__MtSession.GitHubConnection $conn.Organization | Should -Be 'lazy-org' $conn.ApiBaseUri | Should -Be 'https://api.lazy.ghe.com' $conn.ApiVersion | Should -Be '2024-06-01' } } } Context 'Failure: InvalidApiVersion' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' } It 'Config GitHubApiVersion = "latest" fails before any web request' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiVersion = 'latest' } } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiVersion is invalid' } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiVersion' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'Config GitHubApiVersion with valid format but /user returns 410: InvalidApiVersion (not TokenInvalid)' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiVersion = '2020-01-01' } } } $fakeResp = [PSCustomObject]@{ StatusCode = 410; Headers = @{} } $ex = [System.Exception]::new('Gone') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 410 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'API version is not supported' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiVersion' $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } It 'Parameter -ApiVersion "latest" sets InvalidApiVersion and clears prior session state' { # Pre-seed a stale session to prove the function clears it before failing. InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } } Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiVersion is invalid' } Connect-MtGitHub -Organization 'myorg' -ApiVersion 'latest' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiVersion' # Stale auth header must be cleared even though the failure path runs early. $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It '/user 400 with "Not a supported version" message maps to InvalidApiVersion (not TokenInvalid)' { $fakeResp = [PSCustomObject]@{ StatusCode = 400; Headers = @{} } $ex = [System.Exception]::new('Bad Request') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 400 } Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Not a supported version' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } Connect-MtGitHub -Organization 'myorg' -ApiVersion '2024-01-01' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeFalse $c.FailureReason | Should -Be 'InvalidApiVersion' } } It 'Parameter -ApiVersion "2024-01-01" passes local format validation and reaches /user header' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } Connect-MtGitHub -Organization 'myorg' -ApiVersion '2024-01-01' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiVersion | Should -Be '2024-01-01' $__MtSession.GitHubAuthHeader['X-GitHub-Api-Version'] | Should -Be '2024-01-01' } } } Context 'Whitespace trimming on resolved values' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '^https?://[^/]+/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg"}' } } } It 'Trims surrounding whitespace from -Organization parameter' { Connect-MtGitHub -Organization " myorg`t" 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' } # Probe URIs must use the trimmed org, not URL-encoded whitespace. Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.github.com/orgs/myorg' } } It 'Trims surrounding whitespace from config-supplied GitHubOrganization' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubOrganization = " myorg `n" } } } Connect-MtGitHub 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.github.com/orgs/myorg' } } It 'Trims surrounding whitespace from -ApiVersion parameter before validation' { Connect-MtGitHub -Organization 'myorg' -ApiVersion " 2024-01-01`t" 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiVersion | Should -Be '2024-01-01' $__MtSession.GitHubAuthHeader['X-GitHub-Api-Version'] | Should -Be '2024-01-01' } } It 'Trims surrounding whitespace from config-supplied GitHubApiVersion before validation' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiVersion = " 2024-06-01 " } } } Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiVersion | Should -Be '2024-06-01' } } It 'Trims surrounding whitespace from -ApiBaseUri parameter before validation' { Connect-MtGitHub -Organization 'myorg' -ApiBaseUri ' https://api.github.com ' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiBaseUri | Should -Be 'https://api.github.com' } # Probe URI must be clean — no URL-encoded whitespace prefix/suffix that would 404. Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.github.com/user' } } It 'Trims surrounding whitespace from -ApiBaseUri parameter combined with trailing slash' { Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.github.com/ ' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiBaseUri | Should -Be 'https://api.github.com' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.github.com/user' } } It 'Trims surrounding whitespace from config-supplied GitHubApiBaseUri before validation' { InModuleScope Maester { $__MtSession.MaesterConfig = [PSCustomObject]@{ GlobalSettings = [PSCustomObject]@{ GitHubApiBaseUri = ' https://api.github.com/ ' } } } Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection $c.Connected | Should -BeTrue $c.ApiBaseUri | Should -Be 'https://api.github.com' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { $Uri -eq 'https://api.github.com/user' } } } } |