tests/Resolve-ProjectDir.Tests.ps1

# Pester tests for Resolve-ProjectDir in the BykaCCSupervisor module.
#
# Migrated 2026-05-26 (M-13 Phase 3 Wave D) from
# tests/start-channels.Resolve-ProjectDir.Tests.ps1. The original regex-extracted
# from a script; this version dot-sources the Private helper directly because
# Resolve-ProjectDir now lives in its own file.
#
# Run with:
# pwsh -NoProfile -Command "Invoke-Pester -Path packages/byka-cc-supervisor/tests -Output Detailed"
#
# Coverage:
# 1. Missing file -> fallback
# 2. Empty file -> fallback (with warning logged)
# 3. Path traversal attempt (not under BaseDir) -> fallback
# 4. Allowlist regex reject (spaces, semicolons) -> fallback
# 5. Non-existent path -> fallback
# 6. Valid path -> resolved
# 7. Symlink/junction whose target escapes BaseDir -> fallback
# 8. Dedup logging -> <=1 WARN per repeat reason

BeforeAll {
    # Dot-source the Private helper directly (no regex extraction needed since
    # the function now lives in its own file post-extraction).
    $PrivateDir = Join-Path $PSScriptRoot '..' 'Private'
    . (Join-Path $PrivateDir 'Resolve-ProjectDir.ps1')

    # Stub Write-LogLine globally so the function's logging calls don't fail
    # in the test context. In production, the module loader pulls in the real
    # Write-LogLine from Private/Write-LogLine.ps1 BEFORE Resolve-ProjectDir
    # references it, so this stub only matters when tests dot-source just one
    # helper in isolation.
    $script:logCalls = @()
    function global:Write-LogLine {
        param([string]$Message, [string]$Level, [string]$LogFile)
        $script:logCalls += "[$Level] $Message"
    }

    $script:TestAllowlist = '^[A-Za-z0-9_\-\.]{1,64}$'
    $script:TestLogFile   = Join-Path $TestDrive 'test.log'
}

Describe 'Resolve-ProjectDir' {

    BeforeEach {
        $script:logCalls = @()
        # [G4] qa F1: also reset the module-scope dedup state so warns from a
        # prior test context don't suppress the assertion in this one.
        $script:LastResolveWarn = ''
        # Each test uses a fresh temp file.
        $script:tmpFile = Join-Path $TestDrive 'active-project.txt'
    }


    Context 'when active-project.txt is missing' {
        It 'returns the fallback' {
            # Sanity: file does NOT exist
            Test-Path $script:tmpFile | Should -BeFalse

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }
    }

    Context 'when active-project.txt is empty / whitespace' {
        It 'returns the fallback and logs WARN' {
            Set-Content -Path $script:tmpFile -Value '' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
            ($script:logCalls -join "`n") | Should -Match 'WARN.*empty'
        }
    }

    Context 'when active-project.txt contains a path NOT under C:\Dev\' {
        It 'rejects C:\Windows\... → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Windows\System32' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
            ($script:logCalls -join "`n") | Should -Match 'not a valid C:\\Dev'
        }

        It 'rejects path-traversal attempt → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Windows\..\Dev\evil' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'rejects relative paths → fallback' {
            Set-Content -Path $script:tmpFile -Value '..\Dev\byka' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'rejects C:\Dev\..\Windows\System32 (inner-traversal bypass) → fallback' {
            # Reviewer-flagged: prefix regex `^C:\\Dev\\[^\\]+` would otherwise
            # match `C:\Dev\.` then `.\Windows\System32`, leaf becomes System32
            # which passes allowlist, and Test-Path resolves the traversal on
            # disk. Canonicalization via Resolve-Path catches this.
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\..\Windows\System32' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
            ($script:logCalls -join "`n") | Should -Match '(escapes C:\\Dev\\|allowlist|fails allowlist|System32)'
        }

        It 'rejects C:\Dev\byka\..\..\Windows (deeper inner traversal) → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\byka\..\..\Windows' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'rejects a directory symlink whose target escapes BaseDir' {
            # [G4] code-reviewer T1-3 fix: junction lives under $TestDrive instead
            # of polluting C:\Dev\. We point $BaseDir at $TestDrive\trusted-root
            # for this test and create the junction at $TestDrive\trusted-root\<junc>
            # pointing at $TestDrive\evil-target (which is OUTSIDE the trusted root).
            $trustedRoot = Join-Path $TestDrive 'trusted-root'
            $evilTarget  = Join-Path $TestDrive 'evil-target'
            New-Item -ItemType Directory -Path $trustedRoot -Force | Out-Null
            New-Item -ItemType Directory -Path $evilTarget  -Force | Out-Null

            $ts = (Get-Date -Format 'yyyyMMddHHmmssfff')
            $junction = Join-Path $trustedRoot "junc-$ts"
            try {
                New-Item -ItemType Junction -Path $junction -Target $evilTarget -ErrorAction Stop | Out-Null
            } catch {
                Set-ItResult -Skipped -Because "could not create junction: $($_.Exception.Message)"
                return
            }
            try {
                Set-Content -Path $script:tmpFile -Value $junction -NoNewline
                $trustedFallback = Join-Path $trustedRoot 'byka-fake-fallback'
                New-Item -ItemType Directory -Path $trustedFallback -Force | Out-Null

                $result = Resolve-ProjectDir -File $script:tmpFile -Fallback $trustedFallback -BaseDir $trustedRoot -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
                # Canonical target is OUTSIDE trustedRoot -> expect fallback.
                $result | Should -Be $trustedFallback
                ($script:logCalls -join "`n") | Should -Match '(escapes|canonicalizes)'
            } finally {
                try { [System.IO.Directory]::Delete($junction, $true) } catch {}
            }
        }
    }

    Context 'dedup logging' {
        It 'logs the empty-file WARN at most once across repeated calls' {
            Set-Content -Path $script:tmpFile -Value '' -NoNewline

            $script:LastResolveWarn = ''  # reset dedup state
            $r1 = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $r2 = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $r3 = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile

            $r1 | Should -Be 'C:\Dev\byka'
            $r2 | Should -Be 'C:\Dev\byka'
            $r3 | Should -Be 'C:\Dev\byka'

            $emptyWarns = @($script:logCalls | Where-Object { $_ -match 'is empty' })
            $emptyWarns.Count | Should -BeLessOrEqual 1
        }
    }

    Context 'when the leaf directory name fails the allowlist regex' {
        It 'rejects a name with a space → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\my repo' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
            ($script:logCalls -join "`n") | Should -Match 'allowlist regex'
        }

        It 'rejects a name with semicolon → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\foo;bar' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'rejects an empty leaf (trailing backslash) → fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'rejects a name longer than 64 chars → fallback' {
            $longName = 'a' * 65
            Set-Content -Path $script:tmpFile -Value "C:\Dev\$longName" -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }
    }

    Context 'when the resolved path does not exist on disk' {
        It 'returns the fallback' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\does-not-exist-9999' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
            ($script:logCalls -join "`n") | Should -Match 'does not exist'
        }
    }

    Context 'when the file points at a valid C:\Dev\ subdir that exists' {
        It 'returns the resolved path (byka)' {
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\byka' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'returns the resolved path (byka-observer if present)' {
            if (-not (Test-Path 'C:\Dev\byka-observer' -PathType Container)) {
                Set-ItResult -Skipped -Because 'C:\Dev\byka-observer not present on this machine'
                return
            }
            Set-Content -Path $script:tmpFile -Value 'C:\Dev\byka-observer' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka-observer'
        }

        It 'normalizes forward-slash paths to backslash' {
            Set-Content -Path $script:tmpFile -Value 'C:/Dev/byka' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'strips surrounding double quotes' {
            Set-Content -Path $script:tmpFile -Value '"C:\Dev\byka"' -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'strips surrounding single quotes' {
            Set-Content -Path $script:tmpFile -Value "'C:\Dev\byka'" -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }

        It 'strips leading/trailing whitespace' {
            Set-Content -Path $script:tmpFile -Value " C:\Dev\byka " -NoNewline

            $result = Resolve-ProjectDir -File $script:tmpFile -Fallback 'C:\Dev\byka' -BaseDir 'C:\Dev' -Allowlist $script:TestAllowlist -LogFile $script:TestLogFile
            $result | Should -Be 'C:\Dev\byka'
        }
    }
}