Tests/GitEasy.AuthHardening.Tests.ps1

BeforeAll {
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ModulePath  = Join-Path $ProjectRoot 'GitEasy.psd1'

# Helpers mirror GitEasy.SaveWork.Tests.ps1. These tests cover the
# credential-surface hardening (F-01 embedded-cred leak, F-02 host parse,
# F-03 credential-output in logs). They must NEVER reach a real
# `git credential reject` / cmdkey call — that would mutate the operator's
# real credential store. Every Reset-Login path exercised here either
# throws before the credential step or is short-circuited by -WhatIf.
function Invoke-TestGit {
    <#
    .DESCRIPTION
    Test helper. Runs git with the given arguments and returns exit code and output.
    Steps:
    1. Run git capturing combined output.
    2. Throw on non-zero exit unless -AllowFailure is set.
    3. Return a result object with the exit code and output array.
    #>

    param(
        [Parameter(Mandatory)]
        [string[]]$ArgumentList,
        [switch]$AllowFailure
    )
    $oldPreference = $ErrorActionPreference
    try {
        $ErrorActionPreference = 'Continue'
        $output   = & git @ArgumentList 2>&1
        $exitCode = $LASTEXITCODE
    }
    finally {
        $ErrorActionPreference = $oldPreference
    }
    if (($exitCode -ne 0) -and (-not $AllowFailure)) {
        throw "Git failed: git $($ArgumentList -join ' ')`n$($output -join [Environment]::NewLine)"
    }
    return [PSCustomObject]@{ ExitCode = $exitCode; Output = @($output) }
}

function New-TestRepository {
    <#
    .DESCRIPTION
    Test helper. Creates a minimal git repository for test isolation.
    Steps:
    1. Create the target directory.
    2. Initialize a new repository, then set test-safe user name and email.
    #>

    param([Parameter(Mandatory)] [string]$Path)
    New-Item -Path $Path -ItemType Directory -Force | Out-Null
    Push-Location -LiteralPath $Path
    try {
        Invoke-TestGit -ArgumentList @('init') | Out-Null
        Invoke-TestGit -ArgumentList @('config', 'user.name', 'GitEasy Pester') | Out-Null
        Invoke-TestGit -ArgumentList @('config', 'user.email', 'giteasy-pester@example.invalid') | Out-Null
    }
    finally {
        Pop-Location
    }
}
}

Describe 'Credential-surface hardening' {

    BeforeAll {
        Remove-Module GitEasy -Force -ErrorAction SilentlyContinue
        Import-Module $ModulePath -Force
    }

    # -----------------------------------------------------------------------
    # F-01 unit: Format-GESafeUrl strips embedded credentials, leaves
    # everything else (clean URLs, scp-like SSH) intact.
    # -----------------------------------------------------------------------
    Context 'Format-GESafeUrl (F-01 redaction helper)' {

        It 'strips user:token@ from an https authority' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://x-access-token:ghp_REALSECRET@github.com/o/r.git' }
            $r | Should -Be 'https://github.com/o/r.git'
        }

        It 'strips a bare user@ from an https authority' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://alice@github.com/o/r.git' }
            $r | Should -Be 'https://github.com/o/r.git'
        }

        It 'leaves a clean https URL unchanged' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://github.com/o/r.git' }
            $r | Should -Be 'https://github.com/o/r.git'
        }

        It 'leaves scp-like SSH (git@host:path) unchanged - that git@ is not a secret' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'git@github.com:o/r.git' }
            $r | Should -Be 'git@github.com:o/r.git'
        }

        It 'does not strip an @ that appears later in the path' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://github.com/o/a@b.git' }
            $r | Should -Be 'https://github.com/o/a@b.git'
        }

        It 'passes through empty/whitespace unchanged' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url ' ' }
            $r | Should -Be ' '
        }

        It 'the redacted result never contains the secret token' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://u:ghp_DEADBEEF@example.com/x.git' }
            $r | Should -Not -Match 'ghp_DEADBEEF'
        }
    }

    # -----------------------------------------------------------------------
    # F-03 unit: Format-GESafeLogLine redacts credential-bearing lines but
    # keeps the key and leaves protocol lines intact.
    # -----------------------------------------------------------------------
    Context 'Format-GESafeLogLine (F-03 log sanitiser)' {

        It 'redacts a password= value but keeps the key' {
            $r = InModuleScope GitEasy { 'password=ghp_SECRET' | Format-GESafeLogLine }
            $r | Should -Be 'password=[redacted]'
        }

        It 'redacts secret=, token=, bearer=, and Authorization:' {
            $r = InModuleScope GitEasy {
                @('secret=abc','token=def','bearer=ghi','Authorization: Bearer zzz') | Format-GESafeLogLine
            }
            ($r -join '|') | Should -Be 'secret=[redacted]|token=[redacted]|bearer=[redacted]|Authorization: [redacted]'
        }

        It 'is case-insensitive on the key' {
            $r = InModuleScope GitEasy { 'PASSWORD=ghp_X' | Format-GESafeLogLine }
            $r | Should -Be 'PASSWORD=[redacted]'
        }

        It 'leaves non-sensitive protocol lines unchanged' {
            $r = InModuleScope GitEasy { @('protocol=https','host=github.com') | Format-GESafeLogLine }
            ($r -join '|') | Should -Be 'protocol=https|host=github.com'
        }

        It 'the sanitised output never contains the secret value' {
            $r = InModuleScope GitEasy { 'password=ghp_DEADBEEF' | Format-GESafeLogLine }
            $r | Should -Not -Match 'ghp_DEADBEEF'
        }
    }

    # -----------------------------------------------------------------------
    # F-01 kill-test at the display boundary: a pre-existing embedded-cred
    # remote URL in .git/config must not surface through Show-Remote.
    # Show-Remote performs no credential operations, so this is safe.
    # -----------------------------------------------------------------------
    Context 'Show-Remote does not leak embedded credentials (F-01 kill-test)' {

        BeforeEach {
            $script:Stem = [guid]::NewGuid().ToString('N').Substring(0, 8)
            $script:Repo = Join-Path ([IO.Path]::GetTempPath()) "GitEasy_CS_$script:Stem"
            New-TestRepository -Path $script:Repo
            Push-Location -LiteralPath $script:Repo
        }

        AfterEach {
            Pop-Location
            Remove-Item -LiteralPath $script:Repo -Recurse -Force -ErrorAction SilentlyContinue
        }

        It 'redacts an embedded token from the reported Url' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'https://x-access-token:ghp_REALSECRET@github.com/o/r.git') | Out-Null

            $r = @(Show-Remote)

            $r.Count -gt 0 | Should -Be $true
            foreach ($entry in $r) {
                $entry.Url | Should -Not -Match 'ghp_REALSECRET'
                $entry.Url | Should -Not -Match 'x-access-token'
                $entry.Url | Should -Be 'https://github.com/o/r.git'
            }
        }

        It 'still classifies the provider correctly after redaction' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'https://u:tok@github.com/o/r.git') | Out-Null

            $r = @(Show-Remote)

            @($r | Where-Object { $_.Provider -eq 'GitHub' }).Count -gt 0 | Should -Be $true
        }

        It 'reports Provider None when no remote is configured' {
            $r = Show-Remote
            $r.Provider | Should -Be 'None'
            $r.Url | Should -BeNullOrEmpty
        }

        It 'returns one object per fetch/push entry for a clean remote' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'https://github.com/o/r.git') | Out-Null

            $r = @(Show-Remote)

            $r.Count | Should -Be 2
            @($r | Where-Object { $_.Purpose -eq 'fetch' }).Count | Should -Be 1
            @($r | Where-Object { $_.Purpose -eq 'push' }).Count  | Should -Be 1
        }
    }

    # -----------------------------------------------------------------------
    # F-01 / F-02 kill-test on Reset-Login. Only the throw-before-credential
    # path and the -WhatIf short-circuit are exercised, so no real
    # credential store is ever touched.
    # -----------------------------------------------------------------------
    Context 'Reset-Login does not leak credentials (F-01/F-02 kill-test)' {

        BeforeEach {
            $script:Stem = [guid]::NewGuid().ToString('N').Substring(0, 8)
            $script:Repo = Join-Path ([IO.Path]::GetTempPath()) "GitEasy_RL_$script:Stem"
            $script:Logs = Join-Path ([IO.Path]::GetTempPath()) "GitEasy_RL_$($script:Stem)_logs"
            New-TestRepository -Path $script:Repo
            New-Item -Path $script:Logs -ItemType Directory -Force | Out-Null
            $env:GITEASY_LOG_PATH = $script:Logs
            Push-Location -LiteralPath $script:Repo
        }

        AfterEach {
            Pop-Location
            Remove-Item Env:\GITEASY_LOG_PATH -ErrorAction SilentlyContinue
            Remove-Item -LiteralPath $script:Repo -Recurse -Force -ErrorAction SilentlyContinue
            Remove-Item -LiteralPath $script:Logs -Recurse -Force -ErrorAction SilentlyContinue
        }

        It 'a non-HTTPS embedded-credential remote is rejected with a redacted message (F-01)' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'http://user:ghp_REALSECRET@github.com/o/r.git') | Out-Null

            $thrown = $null
            try { Reset-Login } catch { $thrown = $_ }

            $thrown | Should -Not -BeNullOrEmpty
            $thrown.Exception.Message | Should -Match '(?i)HTTPS'
            $thrown.Exception.Message | Should -Not -Match 'ghp_REALSECRET'
            $thrown.Exception.Message | Should -Not -Match 'user:'
        }

        It 'the failure log for a non-HTTPS embedded-cred remote contains no secret (F-01)' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'http://user:ghp_REALSECRET@github.com/o/r.git') | Out-Null

            try { Reset-Login } catch { }

            $logs = @(Get-ChildItem -LiteralPath $script:Logs -Filter 'Reset-Login-*.log' -File)
            $logs.Count -gt 0 | Should -Be $true
            $body = Get-Content -LiteralPath $logs[-1].FullName -Raw
            $body | Should -Not -Match 'ghp_REALSECRET'
        }

        It 'the [uri] parse the F-02 fix relies on yields the bare host, not user:token@host' {
            # Reset-Login replaced the regex ^https://(?<Host>[^/]+)/ (which
            # captured "user:tok@host" as the host - leaking the secret into
            # the log and clearing the wrong entry) with [uri].Host. This
            # locks the .NET behaviour the fix depends on. Executing Reset-Login
            # past the parse would hit the real credential store, so the
            # host *value* on the success path is review-verified, not run here.
            $parsed = InModuleScope GitEasy { 'https://user:ghp_REALSECRET@github.example/o/r.git' -as [uri] }

            $parsed.Scheme | Should -Be 'https'
            $parsed.Host   | Should -Be 'github.example'
            $parsed.Host   | Should -Not -Match 'ghp_REALSECRET'
            $parsed.Host   | Should -Not -Match 'user:'
        }

        It 'plainly reports when no remote is configured' {
            $thrown = $null
            try { Reset-Login } catch { $thrown = $_ }

            $thrown | Should -Not -BeNullOrEmpty
            $thrown.Exception.Message | Should -Not -Match '(?i)\bgit\b'
        }
    }

    # -----------------------------------------------------------------------
    # F-01 / F-05 edges: Format-GESafeUrl now handles URLs embedded mid-string
    # (the F-05 fix dropped the `^` anchor so a creds URL quoted inside an
    # error message is sanitised) plus alt-scheme URLs (ssh://, git+ssh://)
    # and IPv6 literal hosts.
    # -----------------------------------------------------------------------
    Context 'Format-GESafeUrl mid-string and alt-scheme (F-05 + F-01 edges)' {

        It 'strips a creds URL embedded in a git-style error message' {
            $r = InModuleScope GitEasy {
                Format-GESafeUrl -Url "fatal: unable to access 'https://x:tok@github.com/o/r.git/': SSL"
            }
            $r | Should -Be "fatal: unable to access 'https://github.com/o/r.git/': SSL"
            $r | Should -Not -Match 'x:tok'
        }

        It 'strips userinfo from an ssh:// scheme URL' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'ssh://user:pw@host/path' }
            $r | Should -Be 'ssh://host/path'
        }

        It 'strips userinfo from a git+ssh:// scheme URL' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'git+ssh://user:pw@host/path' }
            $r | Should -Be 'git+ssh://host/path'
        }

        It 'leaves an IPv6 literal host untouched when no userinfo is present' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://[::1]/o/r.git' }
            $r | Should -Be 'https://[::1]/o/r.git'
        }

        It 'strips userinfo from an IPv6 literal host URL' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://u:tok@[::1]/o/r.git' }
            $r | Should -Be 'https://[::1]/o/r.git'
        }

        It 'handles multiple URLs in the same string' {
            $r = InModuleScope GitEasy {
                Format-GESafeUrl -Url 'a https://u:t@x.com b https://u:t@y.com c'
            }
            $r | Should -Be 'a https://x.com b https://y.com c'
        }

        It 'leaves "%40" alone (percent-encoded @ is data, not the auth boundary)' {
            $r = InModuleScope GitEasy { Format-GESafeUrl -Url 'https://github.com/o/a%40b.git' }
            $r | Should -Be 'https://github.com/o/a%40b.git'
        }
    }

    # -----------------------------------------------------------------------
    # F-03 edges: Format-GESafeLogLine now also redacts Proxy-Authorization
    # headers (the regex previously only matched the bare "authorization"
    # alternation). The line-anchor remains by design — a literal
    # "password=foo" mid-sentence in narrative text is left alone.
    # -----------------------------------------------------------------------
    Context 'Format-GESafeLogLine edges (F-03)' {

        It 'redacts a Proxy-Authorization header' {
            $r = InModuleScope GitEasy { 'Proxy-Authorization: Bearer abc' | Format-GESafeLogLine }
            $r | Should -Be 'Proxy-Authorization: [redacted]'
        }

        It 'redacts Proxy-Authorization case-insensitively' {
            $r = InModuleScope GitEasy { 'PROXY-AUTHORIZATION: Bearer xyz' | Format-GESafeLogLine }
            $r | Should -Be 'PROXY-AUTHORIZATION: [redacted]'
        }

        It 'leaves a mid-sentence "password=" alone (line-shape intent lock)' {
            $r = InModuleScope GitEasy { 'Operator said password=foo on the phone' | Format-GESafeLogLine }
            $r | Should -Be 'Operator said password=foo on the phone'
        }
    }

    # -----------------------------------------------------------------------
    # F-04 kill-test: Convert-GERemoteToSsh now uses [uri] parsing and
    # refuses URLs whose authority embeds userinfo. The old regex
    # ^https://(?<Host>[^/]+)/ would greedily capture "user:tok@host" as
    # the host, the same shape as the F-02 Reset-Login bug.
    # -----------------------------------------------------------------------
    Context 'Convert-GERemoteToSsh refuses embedded credentials (F-04 kill-test)' {

        It 'converts a clean HTTPS URL to its SSH form' {
            $r = InModuleScope GitEasy { Convert-GERemoteToSsh -RemoteUrl 'https://github.com/o/r.git' }
            $r | Should -Be 'git@github.com:o/r.git'
        }

        It 'returns an already-SSH URL unchanged' {
            $r = InModuleScope GitEasy { Convert-GERemoteToSsh -RemoteUrl 'git@github.com:o/r.git' }
            $r | Should -Be 'git@github.com:o/r.git'
        }

        It 'throws when the URL embeds credentials in userinfo' {
            $thrown = $null
            try {
                InModuleScope GitEasy { Convert-GERemoteToSsh -RemoteUrl 'https://x:ghp_REALSECRET@github.com/o/r.git' }
            } catch { $thrown = $_ }
            $thrown | Should -Not -BeNullOrEmpty
            $thrown.Exception.Message | Should -Match '(?i)embed|password|token'
            $thrown.Exception.Message | Should -Not -Match 'ghp_REALSECRET'
        }

        It 'throws on a non-HTTPS URL' {
            $thrown = $null
            try {
                InModuleScope GitEasy { Convert-GERemoteToSsh -RemoteUrl 'ftp://host/path' }
            } catch { $thrown = $_ }
            $thrown | Should -Not -BeNullOrEmpty
        }

        It 'the [uri] parse the F-04 fix relies on isolates UserInfo from Host' {
            $parsed = InModuleScope GitEasy { 'https://x:ghp_REALSECRET@github.example/o/r.git' -as [uri] }
            $parsed.Host | Should -Be 'github.example'
            $parsed.UserInfo | Should -Be 'x:ghp_REALSECRET'
        }
    }

    # -----------------------------------------------------------------------
    # F-02 edges: Reset-Login relies on [uri].Host for the credential reject
    # target. Lock the .NET behaviour for hosts that have caused regex
    # parsers to misbehave elsewhere: IDN punycode, IPv6 literals, ports in
    # authority.
    # -----------------------------------------------------------------------
    Context '[uri].Host edge cases for Reset-Login (F-02 edges)' {

        It 'punycode IDN host is preserved verbatim' {
            $parsed = 'https://xn--80akhbyknj4f.example/o/r.git' -as [uri]
            $parsed.Host | Should -Be 'xn--80akhbyknj4f.example'
        }

        It 'IPv6 literal host is preserved as a bracketed literal' {
            # PS 7 returns '[::1]'; PS 5.1's older .NET returns the expanded
            # '[0000:0000:0000:0000:0000:0000:0000:0001]'. Both are valid
            # IPv6 representations of the same address and both round-trip
            # through .NET Uri unambiguously, so the assertion accepts both.
            $parsed = 'https://[::1]/o/r.git' -as [uri]
            $parsed.Host | Should -Match '^\[[0-9a-f:]+\]$'
        }

        It 'port in authority is split out from .Host' {
            $parsed = 'https://github.example:8443/o/r.git' -as [uri]
            $parsed.Host | Should -Be 'github.example'
            $parsed.Port | Should -Be 8443
        }
    }

    # -----------------------------------------------------------------------
    # F-05 kill-test: Test-Login must not surface an embedded credential
    # through its returned object, no matter what is sitting in .git/config.
    # Test-Login will return Passed=false here (ls-remote cannot reach an
    # invented host), but the Url field must be sanitised regardless.
    # -----------------------------------------------------------------------
    Context 'Test-Login does not leak embedded credentials (F-05 kill-test)' {

        BeforeEach {
            $script:Stem = [guid]::NewGuid().ToString('N').Substring(0, 8)
            $script:Repo = Join-Path ([IO.Path]::GetTempPath()) "GitEasy_TL_$script:Stem"
            New-TestRepository -Path $script:Repo
            Push-Location -LiteralPath $script:Repo
        }

        AfterEach {
            Pop-Location
            Remove-Item -LiteralPath $script:Repo -Recurse -Force -ErrorAction SilentlyContinue
        }

        It 'redacts an embedded token from the Url field of the returned object' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'https://x-access-token:ghp_REALSECRET@github.invalid/o/r.git') | Out-Null

            $r = Test-Login

            $r.Url | Should -Not -Match 'ghp_REALSECRET'
            $r.Url | Should -Not -Match 'x-access-token'
            $r.Url | Should -Be 'https://github.invalid/o/r.git'
        }

        It 'redacts an embedded token from any URL quoted in the Message field' {
            Invoke-TestGit -ArgumentList @('remote', 'add', 'origin', 'https://x-access-token:ghp_REALSECRET@github.invalid/o/r.git') | Out-Null

            $r = Test-Login

            $r.Passed | Should -Be $false
            $r.Message | Should -Not -Match 'ghp_REALSECRET'
        }
    }

    # -----------------------------------------------------------------------
    # F-06 kill-test: Invoke-GEGit sanitises URL-shaped args before they
    # land in the log step header or the thrown error message. A bogus
    # `remote set-url` against a non-existent remote fails fast (no
    # network), so the throw path is exercised cleanly.
    # -----------------------------------------------------------------------
    Context 'Invoke-GEGit sanitises URL-shaped args (F-06 kill-test)' {

        BeforeEach {
            $script:Stem = [guid]::NewGuid().ToString('N').Substring(0, 8)
            $script:Repo = Join-Path ([IO.Path]::GetTempPath()) "GitEasy_IGS_$script:Stem"
            New-TestRepository -Path $script:Repo
            Push-Location -LiteralPath $script:Repo
        }

        AfterEach {
            Pop-Location
            Remove-Item -LiteralPath $script:Repo -Recurse -Force -ErrorAction SilentlyContinue
        }

        It 'redacts a creds-URL arg in the thrown error message' {
            $thrown = $null
            $repoPath = $script:Repo
            try {
                & (Get-Module GitEasy) {
                    param($wd)
                    Invoke-GEGit -ArgumentList @('remote','set-url','noSuchRemote','https://x:ghp_REALSECRET@host.invalid/r.git') -WorkingDirectory $wd
                } $repoPath
            } catch { $thrown = $_ }

            $thrown | Should -Not -BeNullOrEmpty
            $thrown.Exception.Message | Should -Not -Match 'ghp_REALSECRET'
            $thrown.Exception.Message | Should -Not -Match 'x:'
        }
    }

    # -----------------------------------------------------------------------
    # Test-GERemoteUrlSafe accept/reject matrix. Previously covered only
    # transitively through Set-Token / Set-Ssh; this locks the helper's
    # contract directly.
    # -----------------------------------------------------------------------
    Context 'Test-GERemoteUrlSafe accept/reject matrix' {

        It 'accepts a clean https URL' {
            $r = InModuleScope GitEasy { Test-GERemoteUrlSafe -RemoteUrl 'https://github.com/o/r.git' }
            $r | Should -Be $true
        }

        It 'accepts the scp-style SSH form' {
            $r = InModuleScope GitEasy { Test-GERemoteUrlSafe -RemoteUrl 'git@github.com:o/r.git' }
            $r | Should -Be $true
        }

        It 'rejects an embedded-cred https URL' {
            $thrown = $null
            try {
                InModuleScope GitEasy { Test-GERemoteUrlSafe -RemoteUrl 'https://x:tok@github.com/o/r.git' }
            } catch { $thrown = $_ }
            $thrown | Should -Not -BeNullOrEmpty
            $thrown.Exception.Message | Should -Match '(?i)embed|password|token'
        }

        It 'rejects a non-HTTPS, non-SSH URL' {
            $thrown = $null
            try {
                InModuleScope GitEasy { Test-GERemoteUrlSafe -RemoteUrl 'ftp://host/path' }
            } catch { $thrown = $_ }
            $thrown | Should -Not -BeNullOrEmpty
        }

        It 'rejects empty/whitespace input' {
            $thrown = $null
            try {
                InModuleScope GitEasy { Test-GERemoteUrlSafe -RemoteUrl ' ' }
            } catch { $thrown = $_ }
            $thrown | Should -Not -BeNullOrEmpty
        }
    }
}