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' } } } |