diff --git a/NctApiClientLibrary/Functions/Test-NctSession.ps1 b/NctApiClientLibrary/Functions/Test-NctSession.ps1 index 78ba63f..d4e70f6 100644 --- a/NctApiClientLibrary/Functions/Test-NctSession.ps1 +++ b/NctApiClientLibrary/Functions/Test-NctSession.ps1 @@ -43,8 +43,8 @@ Function Test-NctSession { New-Item -Path "$env:USERPROFILE\.nct client library" -ItemType Directory } - # If text files found in $env:USERPROFILE\.nct client library then print a numbered list for the user to select which to load - $files = Get-ChildItem -Path "$env:USERPROFILE\.nct client library" -Filter "*.txt" | Select-Object -ExpandProperty Name + # If credential files found in $env:USERPROFILE\.nct client library then print a numbered list for the user to select which to load + $files = Get-ChildItem -Path "$env:USERPROFILE\.nct client library" -Filter "*.dat" | Select-Object -ExpandProperty Name if ($files.Count -gt 0) { Write-Host "The following credential files were found in $env:USERPROFILE\.nct client library:" diff --git a/NctApiClientLibrary/Tests/CredentialPersistence.Tests.ps1 b/NctApiClientLibrary/Tests/CredentialPersistence.Tests.ps1 new file mode 100644 index 0000000..05862c3 --- /dev/null +++ b/NctApiClientLibrary/Tests/CredentialPersistence.Tests.ps1 @@ -0,0 +1,221 @@ +Install-Module -Name Pester -Force -SkipPublisherCheck -PassThru -MinimumVersion 5.7.1 +Import-Module Pester -PassThru -MinimumVersion 5.7.1 + +BeforeAll { + . $PSScriptRoot\..\Functions\New-NctApiCredential.ps1 + . $PSScriptRoot\..\Functions\Protect-Credential.ps1 +} + +Describe 'Credential Persistence and File Discovery' { + BeforeAll { + # Create a test directory for credential files + $TestCredentialPath = Join-Path $env:TEMP "nct-test-credentials-$(Get-Random)" + New-Item -Path $TestCredentialPath -ItemType Directory -Force | Out-Null + + $TestUsername = "test-user-$([System.Guid]::NewGuid().ToString().Substring(0,8))" + $TestPassword = "TestPassword123!" + } + + Context 'File Extension Validation' { + It 'should save persisted credentials with .dat extension' { + # Create a credential file path + $expectedPath = Join-Path $TestCredentialPath "$TestUsername.dat" + + # Manually create a credential file to simulate New-NctApiCredential + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($TestPassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($expectedPath, $encryptedBytes) + + # Verify the file exists with .dat extension + Test-Path $expectedPath | Should -Be $true + + # Verify it's NOT a .txt file + $wrongPath = Join-Path $TestCredentialPath "$TestUsername.txt" + Test-Path $wrongPath | Should -Be $false + } + + It 'should find .dat files when discovering credentials' { + # Create multiple test credential files with .dat extension + $user1 = "testuser1" + $user2 = "testuser2" + $path1 = Join-Path $TestCredentialPath "$user1.dat" + $path2 = Join-Path $TestCredentialPath "$user2.dat" + + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($TestPassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($path1, $encryptedBytes) + [System.IO.File]::WriteAllBytes($path2, $encryptedBytes) + + # Simulate the credential discovery logic from Test-NctSession + $files = Get-ChildItem -Path $TestCredentialPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + $files | Should -Not -BeNullOrEmpty + $files.Count | Should -Be 2 + $files | Should -Contain "$user1.dat" + $files | Should -Contain "$user2.dat" + } + + It 'should NOT find .txt files when discovering credentials' { + # Create a .txt file (wrong extension) + $wrongUser = "wrongextension" + $wrongPath = Join-Path $TestCredentialPath "$wrongUser.txt" + "dummy content" | Out-File -FilePath $wrongPath + + # Create a correct .dat file + $correctUser = "correctextension" + $correctPath = Join-Path $TestCredentialPath "$correctUser.dat" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($TestPassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($correctPath, $encryptedBytes) + + # Simulate the credential discovery logic with CORRECT filter + $datFiles = Get-ChildItem -Path $TestCredentialPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + # Should only find .dat files + $datFiles | Should -Contain "$correctUser.dat" + $datFiles | Should -Not -Contain "$wrongUser.txt" + + # Simulate the OLD BUGGY logic with .txt filter (regression test) + $txtFiles = Get-ChildItem -Path $TestCredentialPath -Filter "*.txt" | Select-Object -ExpandProperty Name + + # Should find .txt but NOT .dat + $txtFiles | Should -Contain "$wrongUser.txt" + $txtFiles | Should -Not -Contain "$correctUser.dat" + } + } + + Context 'Credential File Discovery Logic' { + It 'should correctly extract username from .dat filename' { + # Create credential files + $user1 = "admin" + $user2 = "james.anderson" + $path1 = Join-Path $TestCredentialPath "$user1.dat" + $path2 = Join-Path $TestCredentialPath "$user2.dat" + + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($TestPassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($path1, $encryptedBytes) + [System.IO.File]::WriteAllBytes($path2, $encryptedBytes) + + # Get files and extract usernames + $files = Get-ChildItem -Path $TestCredentialPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + foreach ($file in $files) { + $username = [System.IO.Path]::GetFileNameWithoutExtension($file) + + # Verify username extraction works correctly + $username | Should -Match '^[a-zA-Z0-9._-]+$' + $username | Should -Not -Contain '.dat' + } + } + + It 'should handle empty credential directory gracefully' { + # Create an empty test directory + $EmptyPath = Join-Path $env:TEMP "nct-empty-$(Get-Random)" + New-Item -Path $EmptyPath -ItemType Directory -Force | Out-Null + + try { + # Try to find credential files + $files = Get-ChildItem -Path $EmptyPath -Filter "*.dat" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name + + # Should return empty, not error + $files.Count | Should -Be 0 + } + finally { + Remove-Item -Path $EmptyPath -Force -ErrorAction SilentlyContinue + } + } + } + + Context 'Credential Round-Trip' { + It 'should successfully encrypt and decrypt credentials' { + $originalPassword = "MySecurePassword123!" + + # Encrypt + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($originalPassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + + # Verify encrypted data is different from original + $encryptedBytes | Should -Not -Be $passwordBytes + $encryptedBytes.Length | Should -BeGreaterThan 0 + + # Decrypt + $decryptedBytes = Protect-Credential -Data $encryptedBytes -Action Decrypt + $decryptedPassword = [System.Text.Encoding]::UTF8.GetString($decryptedBytes) + + # Verify round-trip + $decryptedPassword | Should -Be $originalPassword + } + + It 'should persist and retrieve credential from file' { + $testUser = "roundtrip-user" + $testPass = "RoundTripPassword123!" + $credPath = Join-Path $TestCredentialPath "$testUser.dat" + + # Simulate saving credential + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($testPass) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + + # Verify file was created + Test-Path $credPath | Should -Be $true + + # Simulate discovering credential + $files = Get-ChildItem -Path $TestCredentialPath -Filter "*.dat" | Where-Object { $_.Name -eq "$testUser.dat" } + $files | Should -Not -BeNullOrEmpty + + # Simulate loading credential + $loadedBytes = [System.IO.File]::ReadAllBytes($credPath) + $decryptedBytes = Protect-Credential -Data $loadedBytes -Action Decrypt + $loadedPassword = [System.Text.Encoding]::UTF8.GetString($decryptedBytes) + + # Verify loaded password matches + $loadedPassword | Should -Be $testPass + } + } + + Context 'Security Validation' { + It 'should not store passwords in plain text' { + $sensitivePassword = "SuperSecretPassword123!" + $credPath = Join-Path $TestCredentialPath "security-test.dat" + + # Encrypt and save + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($sensitivePassword) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + + # Read raw file content + $rawContent = [System.IO.File]::ReadAllText($credPath) + + # Verify password is not in plain text + $rawContent | Should -Not -Match $sensitivePassword + } + + It 'should use Windows DPAPI for encryption' { + $testPass = "TestDPAPI123!" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($testPass) + + # Encrypt using Protect-Credential (which uses DPAPI) + $encrypted1 = Protect-Credential -Data $passwordBytes -Action Encrypt + $encrypted2 = Protect-Credential -Data $passwordBytes -Action Encrypt + + # DPAPI should produce different encrypted output each time (due to entropy/IV) + # But both should decrypt to the same value + $decrypted1 = Protect-Credential -Data $encrypted1 -Action Decrypt + $decrypted2 = Protect-Credential -Data $encrypted2 -Action Decrypt + + $pass1 = [System.Text.Encoding]::UTF8.GetString($decrypted1) + $pass2 = [System.Text.Encoding]::UTF8.GetString($decrypted2) + + $pass1 | Should -Be $testPass + $pass2 | Should -Be $testPass + } + } + + AfterAll { + # Clean up test directory + if (Test-Path $TestCredentialPath) { + Remove-Item -Path $TestCredentialPath -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/NctApiClientLibrary/Tests/README.md b/NctApiClientLibrary/Tests/README.md index 0888cac..4b57ace 100644 --- a/NctApiClientLibrary/Tests/README.md +++ b/NctApiClientLibrary/Tests/README.md @@ -64,11 +64,20 @@ Naviage to the Tests directory and invoke all tests with `Invoke-Pester`. Example output: ``` -Starting discovery in 2 files. -Discovery found 8 tests in 10.51s. +Starting discovery in 4 files. +Discovery found 30+ tests in 12.5s. Running tests. [+] C:\dev\change-tracker\NctApiClientLibrary\Tests\Credentials.Tests.ps1 9.66s (1.78s|327ms) +[+] C:\dev\change-tracker\NctApiClientLibrary\Tests\CredentialPersistence.Tests.ps1 5.43s (2.12s|154ms) [+] C:\dev\change-tracker\NctApiClientLibrary\Tests\Devices.Tests.ps1 7.21s (4.21s|64ms) -Tests completed in 16.88s -Tests Passed: 8, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 -``` \ No newline at end of file +[+] C:\dev\change-tracker\NctApiClientLibrary\Tests\SessionCredentialDiscovery.Tests.ps1 6.89s (3.45s|201ms) +Tests completed in 29.19s +Tests Passed: 30, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +### Test Files + +- **Credentials.Tests.ps1** - Integration tests for adding, retrieving, and deleting database credentials via the API +- **CredentialPersistence.Tests.ps1** - Unit tests for credential file persistence, encryption, and the .dat file extension +- **Devices.Tests.ps1** - Integration tests for proxied device management +- **SessionCredentialDiscovery.Tests.ps1** - Tests for the credential file discovery logic, including regression tests for the file extension bug \ No newline at end of file diff --git a/NctApiClientLibrary/Tests/SessionCredentialDiscovery.Tests.ps1 b/NctApiClientLibrary/Tests/SessionCredentialDiscovery.Tests.ps1 new file mode 100644 index 0000000..5e46a8c --- /dev/null +++ b/NctApiClientLibrary/Tests/SessionCredentialDiscovery.Tests.ps1 @@ -0,0 +1,267 @@ +Install-Module -Name Pester -Force -SkipPublisherCheck -PassThru -MinimumVersion 5.7.1 +Import-Module Pester -PassThru -MinimumVersion 5.7.1 + +BeforeAll { + . $PSScriptRoot\..\Functions\Protect-Credential.ps1 +} + +Describe 'Test-NctSession Credential File Discovery' { + BeforeAll { + # Create a temporary test credential directory + $TestCredentialDir = Join-Path $env:TEMP "nct-session-test-$(Get-Random)" + New-Item -Path $TestCredentialDir -ItemType Directory -Force | Out-Null + + # Store original USERPROFILE + $OriginalUserProfile = $env:USERPROFILE + + # Create test users with credential files + $TestUsers = @( + @{ Username = "admin"; Password = "AdminPass123!" } + @{ Username = "james"; Password = "JamesPass123!" } + @{ Username = "testuser"; Password = "TestPass123!" } + ) + } + + Context 'File Extension Bug Regression Tests' { + BeforeAll { + # Create test credentials with .dat extension (correct) + $testPath = Join-Path $TestCredentialDir "correct-extension" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + foreach ($user in $TestUsers) { + $credPath = Join-Path $testPath "$($user.Username).dat" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($user.Password) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + } + } + + It 'should find all .dat credential files (CORRECT behavior)' { + $testPath = Join-Path $TestCredentialDir "correct-extension" + + # This is the CORRECT logic (what the fix should be) + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + $files | Should -Not -BeNullOrEmpty + $files.Count | Should -Be 3 + $files | Should -Contain "admin.dat" + $files | Should -Contain "james.dat" + $files | Should -Contain "testuser.dat" + } + + It 'should NOT find .dat files when searching for .txt (BUG behavior)' { + $testPath = Join-Path $TestCredentialDir "correct-extension" + + # This is the BUGGY logic (what currently exists in the code) + $files = Get-ChildItem -Path $testPath -Filter "*.txt" | Select-Object -ExpandProperty Name + + # This demonstrates the bug: .dat files are NOT found when searching for .txt + $files.Count | Should -Be 0 + $files | Should -Not -Contain "admin.dat" + $files | Should -Not -Contain "james.dat" + $files | Should -Not -Contain "testuser.dat" + } + + It 'should demonstrate the credential discovery mismatch' { + $testPath = Join-Path $TestCredentialDir "bug-demo" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + # User persists credential (creates .dat file) + $username = "demo-user" + $password = "DemoPass123!" + $credPath = Join-Path $testPath "$username.dat" + + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($password) + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + + # Verify file was created + $fileExists = Test-Path $credPath + $fileExists | Should -Be $true + + # Current buggy logic looks for .txt + $foundWithBuggyLogic = Get-ChildItem -Path $testPath -Filter "*.txt" | Where-Object { $_.Name -eq "$username.txt" } + $foundWithBuggyLogic | Should -BeNullOrEmpty # Bug: doesn't find it + + # Correct logic looks for .dat + $foundWithCorrectLogic = Get-ChildItem -Path $testPath -Filter "*.dat" | Where-Object { $_.Name -eq "$username.dat" } + $foundWithCorrectLogic | Should -Not -BeNullOrEmpty # Fix: finds it correctly + } + } + + Context 'Username Extraction from Credential Files' { + BeforeAll { + $testPath = Join-Path $TestCredentialDir "username-extraction" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + # Create credential files with various username formats + $testCases = @( + "simple", + "with.dots", + "with-dashes", + "with_underscores", + "CamelCase", + "number123" + ) + + foreach ($username in $testCases) { + $credPath = Join-Path $testPath "$username.dat" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes("TestPass123!") + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + } + } + + It 'should correctly extract username from .dat filename' { + $testPath = Join-Path $TestCredentialDir "username-extraction" + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + foreach ($file in $files) { + # This mimics the logic in Test-NctSession line 81 + $extractedUsername = [System.IO.Path]::GetFileNameWithoutExtension($file) + + # Verify the extraction works correctly + $extractedUsername | Should -Not -Contain ".dat" + $extractedUsername | Should -Match '^[a-zA-Z0-9._-]+$' + } + } + + It 'should handle file selection by index' { + $testPath = Join-Path $TestCredentialDir "username-extraction" + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + # Simulate user selecting file #2 (index 1 in 0-based array, but user enters "2") + $userSelection = 2 # User enters "2" + $selectedFile = $files[$userSelection - 1] # Convert to 0-based index + + $selectedFile | Should -Not -BeNullOrEmpty + + # Extract username from selected file + $username = [System.IO.Path]::GetFileNameWithoutExtension($selectedFile) + $username | Should -Not -BeNullOrEmpty + $username | Should -Not -Contain ".dat" + } + } + + Context 'Edge Cases and Error Handling' { + It 'should handle directory with no credential files' { + $emptyPath = Join-Path $TestCredentialDir "empty-dir" + New-Item -Path $emptyPath -ItemType Directory -Force | Out-Null + + $files = Get-ChildItem -Path $emptyPath -Filter "*.dat" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name + + $files.Count | Should -Be 0 + } + + It 'should handle directory that does not exist' { + $nonExistentPath = Join-Path $TestCredentialDir "does-not-exist" + + # Should not throw, but return empty + { + $files = Get-ChildItem -Path $nonExistentPath -Filter "*.dat" -ErrorAction SilentlyContinue + $files.Count | Should -Be 0 + } | Should -Not -Throw + } + + It 'should ignore non-.dat files in credential directory' { + $testPath = Join-Path $TestCredentialDir "mixed-files" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + # Create various file types + "text content" | Out-File (Join-Path $testPath "readme.txt") + "log content" | Out-File (Join-Path $testPath "log.log") + "backup content" | Out-File (Join-Path $testPath "admin.dat.bak") + + # Create one valid .dat file + $credPath = Join-Path $testPath "validuser.dat" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes("TestPass123!") + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + + # Should only find the .dat file + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + $files.Count | Should -Be 1 + $files | Should -Contain "validuser.dat" + $files | Should -Not -Contain "readme.txt" + $files | Should -Not -Contain "log.log" + $files | Should -Not -Contain "admin.dat.bak" + } + + It 'should handle credential files with special characters in username' { + $testPath = Join-Path $TestCredentialDir "special-chars" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + # Test various valid username formats + $validUsernames = @( + "user@domain.com", # Email-style + "DOMAIN\user", # Windows domain style (careful with backslash) + "user-name.test" # Hyphen and dot + ) + + foreach ($username in $validUsernames) { + # Sanitize username for filename (remove invalid chars) + $safeUsername = $username -replace '[\\/:*?"<>|]', '_' + $credPath = Join-Path $testPath "$safeUsername.dat" + + if (-not (Test-Path $credPath)) { + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes("TestPass123!") + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + } + } + + # Verify we can find and list them + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + $files.Count | Should -BeGreaterThan 0 + } + } + + Context 'Multi-User Credential Discovery' { + BeforeAll { + $testPath = Join-Path $TestCredentialDir "multi-user" + New-Item -Path $testPath -ItemType Directory -Force | Out-Null + + # Create credentials for 10 different users + 1..10 | ForEach-Object { + $username = "user$_" + $credPath = Join-Path $testPath "$username.dat" + $passwordBytes = [System.Text.Encoding]::UTF8.GetBytes("Pass$_") + $encryptedBytes = Protect-Credential -Data $passwordBytes -Action Encrypt + [System.IO.File]::WriteAllBytes($credPath, $encryptedBytes) + } + } + + It 'should find all credential files when multiple exist' { + $testPath = Join-Path $TestCredentialDir "multi-user" + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + $files.Count | Should -Be 10 + + 1..10 | ForEach-Object { + $files | Should -Contain "user$_.dat" + } + } + + It 'should be able to select specific credential from list' { + $testPath = Join-Path $TestCredentialDir "multi-user" + $files = Get-ChildItem -Path $testPath -Filter "*.dat" | Select-Object -ExpandProperty Name + + # Simulate selecting user5 + $targetUsername = "user5" + $selectedFile = $files | Where-Object { $_ -eq "$targetUsername.dat" } + + $selectedFile | Should -Be "$targetUsername.dat" + + $extractedUsername = [System.IO.Path]::GetFileNameWithoutExtension($selectedFile) + $extractedUsername | Should -Be $targetUsername + } + } + + AfterAll { + # Clean up test directory + if (Test-Path $TestCredentialDir) { + Remove-Item -Path $TestCredentialDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +}