From 419655ca2dfa9bb1948e8749bc5ac5d02d5f2ec1 Mon Sep 17 00:00:00 2001 From: Joey Maffiola <7maffiolajoey@gmail.com> Date: Thu, 21 May 2026 15:43:18 -0500 Subject: [PATCH 1/2] refactor: Move SFA certificate distribution to standalone repository - Remove Scripts/SFA directory (now in separate SFA repo) - Remove Tests/SFA directory (now in separate SFA repo) - Update Output/SFA structure if needed (see migration notes) - All SFA functionality migrated to: https://github.com/username/SFA - Parent repo now focuses on general PowerShell utilities Migration Details: - SFA repo created with 225 tests (100% passing) - Includes GitHub Actions CI/CD workflow - Standalone repository with complete documentation - Scripts: Export-UserCertificates, Publish-SFACertificates, Move-ExpiredUserCertificates --- Scripts/SFA/Export-UserCertificates.ps1 | 511 ---------- Scripts/SFA/Move-ExpiredUserCertificates.ps1 | 93 -- Scripts/SFA/Publish-SFACertificates.ps1 | 672 ------------- Scripts/SFA/README.html | 565 ----------- ...at_Publish-SFACertificates requirements.md | 151 --- Tests/SFA/Export-UserCertificates.Tests.ps1 | 112 --- .../Move-ExpiredUserCertificates.Tests.ps1 | 101 -- ...lish-SFACertificates-Integration.Tests.ps1 | 268 ----- ...icates-Phase2-NetworkIntegration.Tests.ps1 | 488 --------- ...h-SFACertificates-PreflightCheck.Tests.ps1 | 366 ------- Tests/SFA/Publish-SFACertificates.Tests.ps1 | 933 ------------------ Tests/SFA/TEST-IMPLEMENTATION-SUMMARY.md | 214 ---- 12 files changed, 4474 deletions(-) delete mode 100644 Scripts/SFA/Export-UserCertificates.ps1 delete mode 100644 Scripts/SFA/Move-ExpiredUserCertificates.ps1 delete mode 100644 Scripts/SFA/Publish-SFACertificates.ps1 delete mode 100644 Scripts/SFA/README.html delete mode 100644 Scripts/SFA/feat_Publish-SFACertificates requirements.md delete mode 100644 Tests/SFA/Export-UserCertificates.Tests.ps1 delete mode 100644 Tests/SFA/Move-ExpiredUserCertificates.Tests.ps1 delete mode 100644 Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1 delete mode 100644 Tests/SFA/Publish-SFACertificates-Phase2-NetworkIntegration.Tests.ps1 delete mode 100644 Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1 delete mode 100644 Tests/SFA/Publish-SFACertificates.Tests.ps1 delete mode 100644 Tests/SFA/TEST-IMPLEMENTATION-SUMMARY.md diff --git a/Scripts/SFA/Export-UserCertificates.ps1 b/Scripts/SFA/Export-UserCertificates.ps1 deleted file mode 100644 index 910170d..0000000 --- a/Scripts/SFA/Export-UserCertificates.ps1 +++ /dev/null @@ -1,511 +0,0 @@ -<# -.SYNOPSIS -Exports user certificates from the Windows Certificate Store to PFX files. - -.DESCRIPTION -Exports all certificates for specified users from the Local Machine or Current User -certificate stores. Certificates are exported as PFX files to a timestamped output directory. - -.PARAMETER UserListFile -Path to a text file containing user names (one per line). Defaults to userList.txt in the same directory as the script - -.PARAMETER UserNames -Array of specific user names to export certificates for. If provided, UserListFile is ignored. - -.PARAMETER ComputerName -Remote computer to connect to for certificate export. If not specified, uses local machine. - -.PARAMETER Credential -Credential object for remote connection. Only used when ComputerName is specified. - -.PARAMETER CertificateStore -Certificate store to search: 'LocalMachine', 'CurrentUser', or 'Both'. Defaults to 'CurrentUser'. - -.PARAMETER OutputDirectory -Directory to save exported PFX files. Defaults to Scripts/Output/SFA/ - -.PARAMETER Password -SecureString password to protect exported PFX files. If not provided, prompts user. - -.PARAMETER UsernameListFile -Path to a CSV file mapping full names to domain usernames. Format: FullName,Username -If provided, each certificate is protected with the user's domain username as password. - -.PARAMETER UseUsernameAsPassword -Switch to use domain username as password. Enabled by default. Requires either UsernameListFile or -QueryActiveDirectory. - -.PARAMETER QueryActiveDirectory -Switch to query Active Directory for usernames. Enabled by default. Searches by Display Name to find sAMAccountName (username). -Requires ActiveDirectory module and domain connectivity. - -.PARAMETER ADServer -Active Directory server to query (e.g., 'DC01.example.com'). If not specified, uses default DC. - -.EXAMPLE -.\Export-UserCertificates.ps1 - -.EXAMPLE -.\Export-UserCertificates.ps1 -UsernameListFile 'usernames.csv' - -.EXAMPLE -.\Export-UserCertificates.ps1 -ComputerName 'RemotePC' -Credential (Get-Credential) -#> - -[CmdletBinding()] -param( - [Parameter(ValueFromPipeline = $false)] - [string]$UserListFile = (Join-Path $PSScriptRoot 'userList.txt'), - - [Parameter(ValueFromPipeline = $false)] - [string[]]$UserNames, - - [Parameter(ValueFromPipeline = $false)] - [string]$ComputerName, - - [Parameter(ValueFromPipeline = $false)] - [PSCredential]$Credential, - - [Parameter(ValueFromPipeline = $false)] - [ValidateSet('LocalMachine', 'CurrentUser', 'Both')] - [string]$CertificateStore = 'CurrentUser', - - [Parameter(ValueFromPipeline = $false)] - [string]$OutputDirectory = (Join-Path (Split-Path $PSScriptRoot -Parent | Split-Path -Parent) 'Output' 'SFA'), - - [Parameter(ValueFromPipeline = $false)] - [SecureString]$Password, - - [Parameter(ValueFromPipeline = $false)] - [string]$UsernameListFile, - - [Parameter(ValueFromPipeline = $false)] - # PSScriptAnalyzer false positive: switch parameters don't support default values in declaration - [switch]$UseUsernameAsPassword, - - [Parameter(ValueFromPipeline = $false)] - # PSScriptAnalyzer false positive: switch parameters don't support default values in declaration - [switch]$QueryActiveDirectory, - - [Parameter(ValueFromPipeline = $false)] - [string]$ADServer -) - -$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' - -# Set switch defaults (cannot be set in parameter declaration) -$QueryActiveDirectory = $QueryActiveDirectory -or (-not $PSBoundParameters.ContainsKey('QueryActiveDirectory')) -$UseUsernameAsPassword = $UseUsernameAsPassword -or (-not $PSBoundParameters.ContainsKey('UseUsernameAsPassword')) - -# Load users to process -if (-not (Test-Path $UserListFile)) { - Write-Host "❌ User list file not found: $UserListFile" -ForegroundColor Red - exit 1 -} -$usersToProcess = Get-Content $UserListFile | Where-Object { $_.Trim() -ne '' } -Write-Host "πŸ“‹ Loaded $($usersToProcess.Count) users from $UserListFile" -ForegroundColor Cyan - -# Query Active Directory for username mappings if requested -if ($QueryActiveDirectory) { - Write-Host 'πŸ” Querying Active Directory for usernames...' -ForegroundColor Cyan - - # Try to load AD module - if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) { - Write-Host '⚠️ ActiveDirectory module not found. Falling back to manual password entry.' -ForegroundColor Yellow - $QueryActiveDirectory = $false - } - else { - Import-Module ActiveDirectory -ErrorAction Stop - - $usernameMap = @{} - $adParams = @{} - if ($ADServer) { - $adParams['Server'] = $ADServer - } - - # Query AD for each user - foreach ($fullName in $usersToProcess) { - try { - $adUser = Get-ADUser -Filter "DisplayName -eq '$fullName'" @adParams -ErrorAction SilentlyContinue - if ($adUser) { - $usernameMap[$fullName] = $adUser.SamAccountName - Write-Host " βœ… Found $fullName => $($adUser.SamAccountName)" -ForegroundColor Green - } - else { - Write-Host " ⚠️ Not found: $fullName" -ForegroundColor Yellow - } - } - catch { - Write-Host " ❌ Error querying $fullName : $_" -ForegroundColor Red - } - } - - Write-Host "πŸ“‹ Loaded $($usernameMap.Count) usernames from Active Directory" -ForegroundColor Cyan - - # If no usernames were found, disable UseUsernameAsPassword - if ($usernameMap.Count -eq 0) { - Write-Host '⚠️ No usernames found in Active Directory. Will use manual password instead.' -ForegroundColor Yellow - $UseUsernameAsPassword = $false - } - } -} - -if (-not $QueryActiveDirectory -and $UsernameListFile) { - # Load username mapping from CSV file - if (-not (Test-Path $UsernameListFile)) { - Write-Host "❌ Username list file not found: $UsernameListFile" -ForegroundColor Red - exit 1 - } - $usernameMap = @{} - Get-Content $UsernameListFile | Where-Object { $_.Trim() -ne '' } | ForEach-Object { - $parts = $_ -split ',' - if ($parts.Count -eq 2) { - $usernameMap[$parts[0].Trim()] = $parts[1].Trim() - } - } - Write-Host "πŸ“‹ Loaded $($usernameMap.Count) username mappings from $UsernameListFile" -ForegroundColor Cyan -} -elseif (-not $QueryActiveDirectory -and -not $UsernameListFile) { - # No username mapping source - $usernameMap = @{} -} - -# Prompt for missing usernames if UseUsernameAsPassword is enabled -if ($UseUsernameAsPassword -and $usernameMap.Count -gt 0) { - Write-Host "`nπŸ” Checking for users without username mappings..." -ForegroundColor Cyan - - if (-not (Test-Path $UserListFile)) { - Write-Host "❌ User list file not found: $UserListFile" -ForegroundColor Red - exit 1 - } - $usersToProcess = Get-Content $UserListFile | Where-Object { $_.Trim() -ne '' } - - $missingUsers = @() - foreach ($user in $usersToProcess) { - if (-not $usernameMap.ContainsKey($user)) { - $missingUsers += $user - } - } - - # Prompt for each missing user - if ($missingUsers.Count -gt 0) { - Write-Host "⚠️ Found $($missingUsers.Count) user(s) without username mapping:" -ForegroundColor Yellow - foreach ($user in $missingUsers) { - Write-Host " β€’ $user" -ForegroundColor Yellow - } - Write-Host "`nπŸ“ Please provide the domain username (sAMAccountName) for each user:" -ForegroundColor Cyan - - foreach ($user in $missingUsers) { - $username = Read-Host " Username for '$user' (or press Enter to skip)" - if ($username.Trim() -ne '') { - $usernameMap[$user] = $username.Trim() - Write-Host " βœ… Added: $user => $($usernameMap[$user])" -ForegroundColor Green - } - else { - Write-Host " ⏭️ Skipped: $user (will use fallback password)" -ForegroundColor Yellow - } - } - Write-Host '' - } -} - -if ($UserNames.Count -gt 0) { - $usersToProcess = $UserNames - Write-Host "πŸ“‹ Processing $($usersToProcess.Count) specified users..." -ForegroundColor Cyan -} -else { - if (-not (Test-Path $UserListFile)) { - Write-Host "❌ User list file not found: $UserListFile" -ForegroundColor Red - exit 1 - } - $usersToProcess = Get-Content $UserListFile | Where-Object { $_.Trim() -ne '' } - Write-Host "πŸ“‹ Loaded $($usersToProcess.Count) users from $UserListFile" -ForegroundColor Cyan -} - -# Create output directory with timestamped subdirectory -$exportDir = Join-Path $OutputDirectory "exports_$timestamp" -New-Item -ItemType Directory -Path $exportDir -Force | Out-Null -Write-Host "πŸ“ Export directory: $exportDir" -ForegroundColor Cyan - -# Get password for PFX protection -# If using username-based passwords, still prompt for a fallback password for users not in the map -if (-not $Password) { - if ($UseUsernameAsPassword) { - Write-Host 'πŸ” Enter fallback password for users without username mapping' -ForegroundColor Yellow - $Password = Read-Host 'Fallback password (leave empty for default)' -AsSecureString - if ($Password.Length -eq 0) { - $Password = ConvertTo-SecureString 'FallbackCertificatePassword' -AsPlainText -Force - } - } - else { - $Password = Read-Host 'πŸ” Enter password to protect PFX files' -AsSecureString - if ($Password.Length -eq 0) { - Write-Host '⚠️ No password provided; PFX files will be protected with a default password' -ForegroundColor Yellow - $Password = ConvertTo-SecureString 'DefaultCertificatePassword' -AsPlainText -Force - } - } -} - -# Script block for certificate export (runs locally or remotely) -$exportScriptBlock = { - param($Users, $Store, $ExportPath, [SecureString]$PfxPassword, $UsernameMappings, [bool]$UseUsernamePassword) - - $results = @() - $storeLocation = @() - - if ($Store -eq 'Both') { - $storeLocation = @('CurrentUser', 'LocalMachine') - } - else { - $storeLocation = @($Store) - } - - foreach ($storeType in $storeLocation) { - $storePath = "Cert:\$storeType\My" - - # Get all certificates - $allCerts = @() - try { - $allCerts = Get-ChildItem -Path $storePath -ErrorAction Stop - } - catch { - Write-Host "⚠️ Could not access $storePath : $_" -ForegroundColor Yellow - continue - } - - foreach ($userName in $Users) { - # Search for certificates by full name in the Subject (Issued To field) - # Subject format typically includes CN (Common Name) or just the full name - $matchingCerts = $allCerts | Where-Object { - $cert = $_ - # Check Subject (Issued To) for full name match - $cert.Subject -like "*$userName*" -or - # Also check by extracting CN value if it exists - ($cert.Subject -match 'CN=([^,]*)' -and $matches[1] -eq $userName) - } - - if ($matchingCerts.Count -eq 0) { - $results += @{ - User = $userName - Username = '' - Store = $storeType - Status = '⚠️ No certificates found' - Count = 0 - } - continue - } - - # Filter for the newest certificate (expires furthest in the future) - $newestCert = $matchingCerts | Sort-Object -Property NotAfter -Descending | Select-Object -First 1 - - # Export the newest certificate only - foreach ($cert in @($newestCert)) { - # Determine password and username for this certificate - $domainUsername = '' - $usedFallback = $false - $certPassword = $PfxPassword # Start with the main/fallback password - - # Always try to get the username from mapping for the filename - if ($UsernameMappings -and $UsernameMappings.Count -gt 0 -and $UsernameMappings.ContainsKey($userName)) { - $domainUsername = $UsernameMappings[$userName] - } - - # Use username as password if enabled - if ($UseUsernamePassword) { - if ($domainUsername) { - $certPassword = ConvertTo-SecureString $domainUsername -AsPlainText -Force - Write-Host " πŸ”‘ Using username '$domainUsername' as password for $userName" -ForegroundColor Cyan - } - else { - $domainUsername = 'Fallback' - $usedFallback = $true - Write-Host " ⚠️ Using fallback password for $userName (not found in mapping)" -ForegroundColor Yellow - # certPassword already set to $PfxPassword (the fallback) - } - } - - # Format expiration date as YYYYMMDD - $expirationDate = $cert.NotAfter.ToString('yyyyMMdd') - - # New naming convention: Full Name - AD Username/Password - ExpirationDate.pfx - $fileName = "$($userName -replace '[<>:"/\\|?*]', '_') - $($domainUsername -replace '[<>:"/\\|?*]', '_') - $expirationDate.pfx" - $filePath = Join-Path $ExportPath $fileName - - try { - # Use certutil as primary method - it's more reliable and what Windows Certificate Manager uses - # Convert SecureString password to plain text for certutil - $plaintextPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($certPassword)) - - # Export using certutil with password protection - & certutil.exe -p $plaintextPassword -exportPFX -user My $cert.Thumbprint $filePath 2>&1 | Out-Null - - if (Test-Path $filePath -ErrorAction SilentlyContinue) { - $results += @{ - User = $userName - Username = $domainUsername - Store = $storeType - Status = 'βœ… Exported' - FileName = $fileName - Expiration = $expirationDate - UsedFallback = $usedFallback - Count = 1 - } - } - else { - throw "Certificate file was not created" - } - } - catch { - # Fallback to PowerShell Export-PfxCertificate if certutil fails - Write-Host " ⚠️ certutil export failed, trying PowerShell..." -ForegroundColor Yellow - - try { - if ($certPassword) { - Export-PfxCertificate -Cert $cert -FilePath $filePath -Password $certPassword -Force -ErrorAction Stop | Out-Null - } - else { - Export-PfxCertificate -Cert $cert -FilePath $filePath -Force -ErrorAction Stop | Out-Null - } - - $results += @{ - User = $userName - Username = $domainUsername - Store = $storeType - Status = 'βœ… Exported (via PowerShell)' - FileName = $fileName - Expiration = $expirationDate - UsedFallback = $usedFallback - Count = 1 - } - } - catch { - $results += @{ - User = $userName - Username = $domainUsername - Store = $storeType - Status = "❌ Error (both methods failed): $_" - UsedFallback = $usedFallback - Count = 0 - } - } - } - } - } - } - - return $results -} - -# Execute export locally or remotely -Write-Host "`nπŸ”„ Exporting certificates..." -ForegroundColor Cyan - -if ($ComputerName) { - # Use Invoke-Command for remote execution - $splatParams = @{ - ScriptBlock = $exportScriptBlock - ArgumentList = @($usersToProcess, $CertificateStore, $exportDir, $Password, $usernameMap, $UseUsernameAsPassword) - ComputerName = $ComputerName - } - - if ($Credential) { - $splatParams['Credential'] = $Credential - } - - Write-Host "🌐 Connecting to remote computer: $ComputerName" -ForegroundColor Cyan - - try { - $exportResults = Invoke-Command @splatParams - } - catch { - Write-Host "❌ Export failed: $_" -ForegroundColor Red - exit 1 - } -} -else { - # Run locally - call the script block directly - try { - $exportResults = & $exportScriptBlock $usersToProcess $CertificateStore $exportDir $Password $usernameMap $UseUsernameAsPassword - } - catch { - Write-Host "❌ Export failed: $_" -ForegroundColor Red - exit 1 - } -} - -# Display results summary -Write-Host "`nπŸ“Š Export Summary:" -ForegroundColor Green -$exportResults | ForEach-Object { - if ($_.Count -gt 0) { - if ($_.UsedFallback) { - Write-Host " ⚠️ $($_.User) - Exported (FALLBACK PASSWORD - NOT FOUND IN AD) from $($_.Store)" -ForegroundColor Yellow - } - else { - Write-Host " βœ… $($_.User) - Exported from $($_.Store)" -ForegroundColor Green - } - } - else { - Write-Host " $($_.Status) - $($_.User) ($($_.Store))" -ForegroundColor Yellow - } -} - -$totalExported = ($exportResults | Measure-Object -Property Count -Sum).Sum -Write-Host "`nπŸ“¦ Total certificates exported: $totalExported" -ForegroundColor Green -Write-Host "πŸ“‚ Exports saved to: $exportDir" -ForegroundColor Cyan - -# Create summary file with failed exports at beginning and end -$summaryFile = Join-Path $exportDir 'export_summary.txt' -$successfulExports = $exportResults | Where-Object { $_.Count -gt 0 } -$failedExports = $exportResults | Where-Object { $_.Count -eq 0 } -$fallbackUsers = $successfulExports | Where-Object { $_.UsedFallback } -$properUsers = $successfulExports | Where-Object { -not $_.UsedFallback } - -$summaryContent = @() -$summaryContent += '=' * 80 -$summaryContent += 'CERTIFICATE EXPORT SUMMARY' -$summaryContent += '=' * 80 -$summaryContent += '' - -# Add failed exports at the beginning -if ($failedExports.Count -gt 0) { - $summaryContent += "❌ FAILED EXPORTS ($($failedExports.Count) users)" - $summaryContent += '-' * 80 - foreach ($failed in $failedExports) { - $summaryContent += "$($failed.User) - $($failed.Status)" - } - $summaryContent += '' -} - -# Add users with fallback passwords (requires manual password update) -if ($fallbackUsers.Count -gt 0) { - $summaryContent += "⚠️ EXPORTED WITH FALLBACK PASSWORD - REQUIRES MANUAL UPDATE ($($fallbackUsers.Count) users)" - $summaryContent += '-' * 80 - $summaryContent += 'The following users were NOT FOUND in Active Directory.' - $summaryContent += 'Their certificates were exported with the default fallback password.' - $summaryContent += 'You MUST manually re-export these certificates or update the password!' - $summaryContent += '' - foreach ($user in $fallbackUsers) { - $summaryContent += " β€’ $($user.User) - File: $($user.FileName) - Expires: $($user.Expiration)" - $summaryContent += ' Password: FallbackCertificatePassword (CHANGE THIS!)' - } - $summaryContent += '' -} - -# Add successful exports with per-user passwords -$summaryContent += "βœ… EXPORTED WITH PER-USER PASSWORDS ($($properUsers.Count) users)" -$summaryContent += '-' * 80 -$properUsers | ForEach-Object { - $summaryContent += " β€’ $($_.User) - Username: $($_.Username) - Expires: $($_.Expiration)" -} - -# Add failed exports again at the end for quick reference - -if ($failedExports.Count -gt 0) { - $summaryContent += '' - $summaryContent += '❌ FAILED EXPORTS (Summary)' - $summaryContent += '-' * 80 - foreach ($failed in $failedExports) { - $summaryContent += "$($failed.User) - $($failed.Status)" - } -} - -$summaryContent | Out-File $summaryFile -Encoding UTF8 -Write-Host "πŸ“ Summary saved to: $summaryFile" -ForegroundColor Cyan diff --git a/Scripts/SFA/Move-ExpiredUserCertificates.ps1 b/Scripts/SFA/Move-ExpiredUserCertificates.ps1 deleted file mode 100644 index dac6160..0000000 --- a/Scripts/SFA/Move-ExpiredUserCertificates.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -#Requires -Version 5.1 - -<# -.SYNOPSIS - Moves expired user certificates to an archive folder. - -.DESCRIPTION - Scans a target directory recursively for PFX certificate files with embedded expiration dates - in the filename (YYYYMMDD format). Certificates with expiration dates in the past are moved - to an 'Old' subfolder within the target directory. - -.PARAMETER TargetDirectory - The path to the directory containing certificates to scan. Can be a local or UNC path. - If not provided, the script will prompt the user interactively. - -.EXAMPLE - .\Move-ExpiredUserCertificates.ps1 - Prompts user for directory path interactively. - -.EXAMPLE - .\Move-ExpiredUserCertificates.ps1 -TargetDirectory '\\10.30.1.1\Groups\LosAngeles\SFA\certificates' - Moves expired certificates from the specified directory to an archive folder. - -.NOTES - Author: PowerShell Script Generator - Created: November 24, 2025 - - Certificate Naming Convention: - - Files must be named with expiration date at end: filename_YYYYMMDD.pfx - - Example: user_cert_20251231.pfx (expires December 31, 2025) - - Archive Location: - - Expired certificates are moved to: \Old\ -#> - -param( - [Parameter(Mandatory = $false)] - [string]$TargetDirectory -) - -# If TargetDirectory not provided, prompt user interactively -if (-not $TargetDirectory) { - $TargetDirectory = Read-Host "Enter the target directory path (e.g., '\\10.30.1.1\Groups\LosAngeles\SFA\certificates')" -} - -# Validate the directory exists -if (-not (Test-Path -Path $TargetDirectory)) { - Write-Host "Error: Directory not found at '$TargetDirectory'" -ForegroundColor Red - exit 1 -} - -# 2. Set the archive folder location -$archiveFolder = Join-Path -Path $TargetDirectory -ChildPath 'Old' - -# 3. Create archive folder if it doesn't exist -if (-not (Test-Path -Path $archiveFolder)) { - Write-Host "Creating archive folder at: $archiveFolder" -ForegroundColor Cyan - New-Item -Path $archiveFolder -ItemType Directory | Out-Null -} - -# 4. Get today's date -$today = (Get-Date).Date - -# 5. Loop through files RECURSIVELY (-Recurse) -Get-ChildItem -Path $TargetDirectory -Filter '*.pfx' -File -Recurse | ForEach-Object { - - # SAFETY CHECK: Skip this file if it is already inside the Archive folder - if ($_.DirectoryName -like "$archiveFolder*") { - return # Skip to next file - } - - # Regex check for date at end of filename - if ($_.Name -match '(\d{8})\.pfx$') { - $dateString = $Matches[1] - - try { - $certDate = [DateTime]::ParseExact($dateString, 'yyyyMMdd', $null) - - if ($certDate -lt $today) { - Write-Host "Moving expired certificate: $($_.FullName)" -ForegroundColor Yellow - - # Move to the central archive folder - # Note: If files in different folders have the exact same name, this might error. - Move-Item -Path $_.FullName -Destination $archiveFolder - } - } - catch { - Write-Warning "Skipping file '$($_.Name)': Unable to parse date from filename. Expected format: *_yyyyMMdd.pfx (e.g., User_20231215.pfx). Error: $($_.Exception.Message)" - } - } -} - -Write-Host 'Recursive cleanup complete.' -ForegroundColor Green \ No newline at end of file diff --git a/Scripts/SFA/Publish-SFACertificates.ps1 b/Scripts/SFA/Publish-SFACertificates.ps1 deleted file mode 100644 index 47a2019..0000000 --- a/Scripts/SFA/Publish-SFACertificates.ps1 +++ /dev/null @@ -1,672 +0,0 @@ -#Requires -Version 5.1 -#Requires -PSEdition Core - -<# -.SYNOPSIS - Publishes SFA certificates from local branch folders to remote branch servers. - -.DESCRIPTION - Copies all PFX certificates from local SFA Certificates branch folders to corresponding - remote branch servers. After successful transfer, invokes Move-ExpiredUserCertificates - locally with the remote UNC path to clean up expired certificates. - - Includes an automated pre-flight connectivity check that validates all branch paths are - accessible before publishing. If any branches are unreachable, prompts the user to confirm - whether to continue with only accessible branches. - - Uses a branch-to-remote-path mapping hashtable to determine destination paths. Each branch - can have its own custom remote path, allowing for flexibility in server and storage layouts. - -.PARAMETER LocalSourceDirectory - The root directory containing SFA certificate branch folders. Each subdirectory - represents a branch location (e.g., JLV, JPO, JBA, etc.). - Example: 'C:\SFA Certificates' - -.PARAMETER BranchMappings - A hashtable mapping branch codes to their remote certificate paths. If not provided, - uses the default hardcoded mappings. - Format: @{ - 'BranchCode' = @{ Path = '\\SERVER\path\to\certificates' } - } - -.EXAMPLE - .\Publish-SFACertificates.ps1 -LocalSourceDirectory 'C:\SFA Certificates' - Publishes all certificates from local branches to their remote servers. - Includes pre-flight connectivity check with user confirmation if any branches are unreachable. - -.EXAMPLE - $mappings = @{ - 'JLV' = @{ Path = '\\BRANCH-JLV-01\Groups\LosVegas\SFA\certificates\Active' } - 'JPO' = @{ Path = '\\BRANCH-JPO-01\Groups\Portland\SFA\certificates\Active' } - } - .\Publish-SFACertificates.ps1 -LocalSourceDirectory 'C:\SFA Certificates' -BranchMappings $mappings - Publishes certificates using custom branch-to-path mappings. - -.NOTES - Author: PowerShell Script Generator - Created: November 24, 2025 - Updated: December 30, 2025 (integrated pre-flight connectivity check) - - Pre-flight Connectivity Check: - - Runs immediately after parameter validation - - Tests all branch paths defined in BranchMappings - - Displays accessible and inaccessible branches to console - - If all branches accessible: automatically continues - - If any branches inaccessible: prompts user to confirm continuation - - Prevents wasted effort on partial publication failures - - Branch Structure: - - Single branches: folders like JLV, JPO, JBA - - Regional branches: folders with dashes like JHT-JDL-JBR containing subfolders (JDL, JBR) - - Archive (Local): Single 'Archive' folder at root of LocalSourceDirectory - contains expired certs from all branches - - Old (Remote): 'Old' folder created on each remote branch - contains expired certs for that branch - - Local Source Cleanup: - - Before publishing, Move-ExpiredUserCertificates runs on LocalSourceDirectory - - Expired certificates from all branches are moved to the single Archive folder - - Only active certificates are subsequently copied to remote branches - - Remote Cleanup: - - After copying certificates, Move-ExpiredUserCertificates runs on each remote branch - - Expired certificates in each branch are moved to that branch's Old folder -#> - -param( - [Parameter(Mandatory = $false, HelpMessage = 'Path to local SFA Certificates directory')] - [string]$LocalSourceDirectory = 'C:\Users\admin-sfa\Desktop\SFA Certificates', - - [Parameter(Mandatory = $false)] - [hashtable]$BranchMappings -) - -# ============================================================================ -# Default Branch Mappings -# ============================================================================ -# If no mappings provided, use these defaults. Each branch maps to its remote path. -# Path should be the full UNC path to the certificate directory on the remote server. - -if (-not $BranchMappings) { - $BranchMappings = @{ - # Single branch locations - 'JBA' = @{ Path = '\\10.85.1.1\Groups\Baltimore\SFA\certificates' } # Baltimore Branch - 'JBO' = @{ Path = '\\10.82.1.1\Groups\Boston\SFA\certificates' } # Boston Branch - 'JCH' = @{ Path = '\\10.70.1.1\Groups\Chicago\SFA\certificates' } # Chicago Branch - 'JDE' = @{ Path = '\\10.45.1.1\Groups\Denver\SFA\certificates' } # Denver Branch - 'JFH' = @{ Path = '\\10.98.1.1\Groups\Hawaii\SFA\certificates' } # Hawaii Branch - 'JHO' = @{ Path = '\\10.0.1.1\Groups\SFA\certificates' } # Headquarters Branch - 'JLA' = @{ Path = '\\10.30.1.1\Groups\LosAngeles\SFA\certificates' } # Los Angeles Branch - 'JLV' = @{ Path = '\\10.30.1.1\Groups\LasVegas\SFA\certificates' } # Las Vegas Branch (on JLA server) - 'JMI' = @{ Path = '\\10.96.1.1\Groups\Miami\SFA\certificates' } # Miami Branch - 'JMX' = @{ Path = '\\10.39.1.1\Groups\Mexico\SFA\certificates' } # Mexico Branch - 'JNY' = @{ Path = '\\10.80.1.1\Groups\NewYork\SFA\certificates' } # New York Branch - 'JPH' = @{ Path = '\\10.30.1.1\Groups_JPH\Phoenix\SFA\certificates' } # Phoenix Branch (on JLA server) - 'JPO' = @{ Path = '\\10.14.1.1\Groups\Portland\SFA\certificates' } # Portland Branch - DISABLED: Awaiting path from Jared - 'JSD' = @{ Path = '\\10.35.1.1\Groups\SanDiego\SFA\certificates' } # San Diego Branch - 'JSF' = @{ Path = '\\10.14.1.1\Groups\Seattle\SFA\certificates' } # Portland Branch (on Seattle server) - # 'KMS' = @{ Path = '\\10.230.103.111\Tools\SFA\certificates' } # KMS Server Location - DISABLED: Filenames have bad date format - - # Regional branch locations with sub-branches - 'JAT' = @{ Path = '\\10.95.1.1\Groups\Atlanta\SFA\certificates' } # Atlanta - 'JOR' = @{ Path = '\\10.95.1.1\Groups\Orlando\SFA\certificates' } # Orlando - 'JHT' = @{ Path = '\\10.50.1.1\Groups\Houston\SFA\certificates' } # Houston (parent folder JHT-JDL-JBR) - 'JDL' = @{ Path = '\\10.50.1.1\Groups\Dallas\SFA\certificates' } # Dallas (sub-branch of JHT-JDL-JBR) - 'JBR' = @{ Path = '\\10.50.1.1\Groups\Baton Rouge\SFA\certificates' } # Baton Rouge (sub-branch of JHT-JDL-JBR) - 'JPM' = @{ Path = '\\10.160.1.1\Groups\PMAI\SFA\certificates\JPM (LA)' } # PMAI - Los Angeles - 'JPN' = @{ Path = '\\10.160.1.1\Groups\PMAI\SFA\certificates\JPN (NY)' } # PMAI - New York - 'JPS' = @{ Path = '\\10.160.1.1\Groups\PMAI\SFA\certificates\JPS (SF)' } # PMAI - San Francisco - 'JPV' = @{ Path = '\\10.160.1.1\Groups\PMAI\SFA\certificates\JPV (Canada)' } # PMAI - Vancouver - } -} - -# ============================================================================ -# Validation and Setup -# ============================================================================ - -Write-Host "πŸ” Validating local source directory: $LocalSourceDirectory" -ForegroundColor Cyan - -if (-not (Test-Path -Path $LocalSourceDirectory -PathType Container)) { - Write-Host "❌ Error: Local source directory not found: $LocalSourceDirectory" -ForegroundColor Red - exit 1 -} - -# Track failures for reporting -$failureList = @() -$successList = @() -$cleanupWarnings = @() - -# ============================================================================ -# Helper Functions -# ============================================================================ - -function Invoke-LocalSourceCleanup { - <# - .SYNOPSIS - Runs cleanup script on local source directory to remove expired certificates before publishing - .DESCRIPTION - Invokes Move-ExpiredUserCertificates on the local source directory to archive - expired certificates before any publishing occurs. This prevents expired certs - from being distributed to remote branches. - #> - param( - [string]$LocalSourceDirectory, - [string]$CleanupScriptPath - ) - - if (-not (Test-Path $CleanupScriptPath)) { - Write-Host "⚠️ Cleanup script not found: $CleanupScriptPath" -ForegroundColor Yellow - return @{ Success = $false; Message = 'Cleanup script not found'; Error = $null } - } - - try { - Write-Host "🧹 Running local source cleanup..." -ForegroundColor Cyan - - # Invoke cleanup script on local source directory - # Capture warnings to stream 3 - & $CleanupScriptPath -TargetDirectory $LocalSourceDirectory -ErrorAction Stop 3>&1 | Out-Null - - Write-Host "βœ… Local source cleanup completed" -ForegroundColor Green - return @{ Success = $true; Message = 'Local cleanup completed successfully'; Error = $null } - } - catch { - Write-Host "⚠️ Local cleanup encountered warning (non-fatal): $($_.Exception.Message)" -ForegroundColor Yellow - return @{ Success = $true; Message = 'Cleanup completed with warnings'; Error = $_.Exception.Message } - } -} - -function Test-RemoteConnectivity { - <# - .SYNOPSIS - Tests if a remote path is accessible. - #> - param( - [string]$RemotePath - ) - - try { - $null = Test-Path -Path $RemotePath -PathType Container -ErrorAction Stop - return $true - } - catch { - return $false - } -} - -function Test-RemoteCertificateExists { - <# - .SYNOPSIS - Tests if a certificate file already exists on remote path - .DESCRIPTION - Compares local certificate filename against remote directory contents. - Uses filename-only comparison (no hash/content validation). - .PARAMETER RemotePath - The remote directory path to check - .PARAMETER CertificateFilename - The certificate filename to search for - .OUTPUTS - Boolean: $true if file exists on remote, $false otherwise - #> - param( - [string]$RemotePath, - [string]$CertificateFilename - ) - - if (-not (Test-Path -Path $RemotePath -PathType Container)) { - # Remote path doesn't exist yet, certificate can't be present - return $false - } - - try { - # Check if file exists in remote directory - $remoteFile = Get-ChildItem -Path $RemotePath -Filter $CertificateFilename -ErrorAction Stop -Recurse:$false - return $null -ne $remoteFile - } - catch { - # If we can't check, assume it doesn't exist (allow copy attempt) - return $false - } -} - -# ============================================================================ -# Main Processing -# ============================================================================ - -Write-Host "`nπŸ“‹ Starting certificate publication process..." -ForegroundColor Green -Write-Host "Branches to process: $($BranchMappings.Keys.Count)" -ForegroundColor Gray - -# ============================================================================ -# Pre-flight: Connectivity Check -# ============================================================================ - -Write-Host "`nπŸ” Running pre-flight connectivity check..." -ForegroundColor Cyan - -$accessibleBranches = @() -$inaccessibleBranches = @() - -foreach ($branchCode in $BranchMappings.Keys | Sort-Object) { - $remotePath = $BranchMappings[$branchCode].Path - $canAccess = Test-Path -Path $remotePath -PathType Container 2>$null - - if ($canAccess) { - $accessibleBranches += $branchCode - Write-Host " βœ… $branchCode" -ForegroundColor Green - } - else { - $inaccessibleBranches += $branchCode - Write-Host " ❌ $branchCode" -ForegroundColor Red - } -} - -Write-Host "`nπŸ“Š Pre-flight Check Results:" -ForegroundColor Cyan -Write-Host " Accessible: $($accessibleBranches.Count) / $($BranchMappings.Keys.Count)" -ForegroundColor Green -Write-Host " Inaccessible: $($inaccessibleBranches.Count) / $($BranchMappings.Keys.Count)" -ForegroundColor Red - -if ($inaccessibleBranches.Count -gt 0) { - Write-Host "`n⚠️ Note: The following branches are currently unreachable:" -ForegroundColor Yellow - $inaccessibleBranches | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } - Write-Host "`n These branches will be skipped during publication." -ForegroundColor Yellow - - # Prompt user to continue or abort - Write-Host "`n❓ Do you want to continue with the remaining $($accessibleBranches.Count) accessible branch(es)?" -ForegroundColor Cyan - $userChoice = Read-Host " Enter 'yes' to continue or 'no' to abort" - - if ($userChoice -ne 'yes' -and $userChoice -ne 'y') { - Write-Host "`n❌ Publication aborted by user." -ForegroundColor Red - exit 0 - } - - Write-Host "`nβœ… Continuing with accessible branches..." -ForegroundColor Green -} -else { - Write-Host "`nβœ… All branches are accessible! Proceeding with publication..." -ForegroundColor Green -} - - -# ============================================================================ -# Preprocessing: Local Source Cleanup -# ============================================================================ - -Write-Host "`nπŸ” Preprocessing local certificate source..." -ForegroundColor Cyan - -$cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'Move-ExpiredUserCertificates.ps1' -$localCleanupResult = Invoke-LocalSourceCleanup -LocalSourceDirectory $LocalSourceDirectory -CleanupScriptPath $cleanupScriptPath - -if ($localCleanupResult.Success) { - Write-Host "πŸ“Š Local source is now clean - only active certificates will be published" -ForegroundColor Green -} -else { - Write-Host "⚠️ Proceeding without local cleanup" -ForegroundColor Yellow -} - -if ($localCleanupResult.Error) { - $cleanupWarnings += @{ - Stage = 'LocalCleanup' - Message = $localCleanupResult.Error - Details = 'Local source cleanup' - } -} - -Write-Host "`nπŸ“‹ Starting certificate publication process..." -ForegroundColor Green - -foreach ($branchCode in $BranchMappings.Keys | Sort-Object) { - $branchInfo = $BranchMappings[$branchCode] - - # Build local branch path - handle various folder structures - $localBranchPath = $null - $localBranchExists = $false - - # Strategy 1: Check for direct folder match (single branches like JBA, JBO, etc.) - $directPath = Join-Path -Path $LocalSourceDirectory -ChildPath $branchCode - if (Test-Path -Path $directPath -PathType Container) { - $localBranchPath = $directPath - $localBranchExists = $true - } - else { - # Strategy 2: Check for regional parent folders (JHT-JDL-JBR, JMI-JOR, PMAI) - $branchFolders = Get-ChildItem -Path $LocalSourceDirectory -Directory -ErrorAction SilentlyContinue - - foreach ($folder in $branchFolders) { - # Strategy 2a: Check if branch is a subfolder of regional parent (e.g., JDL in JHT-JDL-JBR) - $subPath = Join-Path -Path $folder.FullName -ChildPath $branchCode - if (Test-Path -Path $subPath -PathType Container) { - $localBranchPath = $subPath - $localBranchExists = $true - break - } - - # Strategy 2b: Check if regional parent folder itself is for this branch - # (e.g., JHT-JDL-JBR folder for JHT, or JMI-JOR folder for JMI) - if ($folder.Name -match "^$branchCode-|^.*-$branchCode-|^.*-$branchCode`$") { - # This folder represents the regional group containing this branch - $localBranchPath = $folder.FullName - $localBranchExists = $true - break - } - - # Strategy 2c: Check if branch is inside PMAI folder (JPM, JPN, JPS, JPV) - # PMAI folders have names like "JPM (LA)", "JPN (NY)", etc., so match by prefix - if ($folder.Name -eq 'PMAI') { - $pmaiSubFolders = Get-ChildItem -Path $folder.FullName -Directory -ErrorAction SilentlyContinue - foreach ($pmaiSub in $pmaiSubFolders) { - # Match if subfolder name starts with the branch code (e.g., "JPM (LA)" matches "JPM") - if ($pmaiSub.Name -match "^$branchCode\s*\(.*\)|^$branchCode`$") { - $localBranchPath = $pmaiSub.FullName - $localBranchExists = $true - break - } - } - if ($localBranchExists) { break } - } - } - } - - # If local branch doesn't exist, record failure and continue - if (-not $localBranchExists) { - # Check if folder actually exists (for better error messaging) - $directPathExists = Test-Path -Path (Join-Path -Path $LocalSourceDirectory -ChildPath $branchCode) -PathType Container - $regionalhPathExists = @(Get-ChildItem -Path $LocalSourceDirectory -Directory -ErrorAction SilentlyContinue | Where-Object { - $_.Name -match "^$branchCode-|^.*-$branchCode-|^.*-$branchCode`$" -or $_.Name -eq 'PMAI' - }).Count -gt 0 - - $reason = if ($directPathExists -or $regionalhPathExists) { - 'No active certificates found' - } - else { - 'Local folder not found' - } - - $failureList += @{ - Branch = $branchCode - Reason = $reason - Details = if ($reason -eq 'Local folder not found') { - "Neither direct path nor subfolder found in $LocalSourceDirectory" - } - else { - "Folder exists but contains no PFX certificates to publish" - } - } - - $displayReason = if ($reason -eq 'Local folder not found') { - "local folder not found" - } - else { - "no certificates to publish" - } - Write-Host "⚠️ Skipping $branchCode - $displayReason" -ForegroundColor Yellow - continue - } - - # Resolve remote path - $remotePath = $branchInfo.Path - - # Test remote connectivity - if (-not (Test-RemoteConnectivity -RemotePath $remotePath)) { - $failureList += @{ - Branch = $branchCode - Reason = 'Remote unreachable' - Details = "Cannot access $remotePath" - } - Write-Host "⚠️ Skipping $branchCode - remote server unreachable ($remotePath)" -ForegroundColor Yellow - continue - } - - # Get all PFX files from local branch (excluding Archive and Old folders) - # Note: Archive folder is at the root of LocalSourceDirectory (for all branches) - # Old folders are at the branch level (on remote servers after cleanup) - $certificateFiles = Get-ChildItem -Path $localBranchPath -Filter '*.pfx' -File -Recurse -ErrorAction SilentlyContinue | - Where-Object { - # Filter out both Archive (on local source) and Old (on remote after cleanup) - # Check that path doesn't contain \Archive\ or \Old\ as folder separators - $_.FullName -notlike '*\Archive\*' -and - $_.FullName -notlike '*\Old\*' - } - - if ($certificateFiles.Count -eq 0) { - Write-Host "ℹ️ No certificates found in $branchCode - folder exists but is empty or contains no active certificates" -ForegroundColor Gray - continue - } - - # Copy certificates to remote - Write-Host "πŸ“¦ Publishing $($certificateFiles.Count) certificate(s) for $branchCode..." -ForegroundColor Cyan - $copyErrors = @() - $skippedCertificates = 0 - $copiedCertificates = 0 - - foreach ($cert in $certificateFiles) { - try { - # Preserve directory structure on remote using safe path computation - # Use GetRelativePath if available (PS 6.0+), otherwise use validated substring method - if ($PSVersionTable.PSVersion.Major -ge 6) { - $relativePath = [System.IO.Path]::GetRelativePath($localBranchPath, $cert.FullName) - } - else { - # Fallback for PowerShell 5.1: validate path before substring operation - if (-not $cert.FullName.StartsWith($localBranchPath, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Certificate file path does not start with expected base path' - } - $relativePath = $cert.FullName.Substring($localBranchPath.Length + 1) - } - - # Validate relative path to prevent directory traversal attacks - # Match '..' only as complete path components, not within filenames - $directoryTraversalPattern = '(^|[/\\])\.\.([/\\]|$)' - if ($relativePath -match $directoryTraversalPattern -or [System.IO.Path]::IsPathRooted($relativePath)) { - throw 'Invalid relative path detected: potential directory traversal attempt' - } - - $remoteFilePath = Join-Path -Path $remotePath -ChildPath $relativePath - $remoteFileDir = Split-Path -Path $remoteFilePath -Parent - - # Check if certificate already exists on remote (skip if present) - $remoteFileName = Split-Path -Leaf $remoteFilePath - if (Test-RemoteCertificateExists -RemotePath $remoteFileDir -CertificateFilename $remoteFileName) { - Write-Host " ⏭️ Skipping: $($cert.Name) (already present on remote)" -ForegroundColor Gray - $skippedCertificates++ - continue - } - - # Create remote directory if needed - if (-not (Test-Path -Path $remoteFileDir)) { - $null = New-Item -Path $remoteFileDir -ItemType Directory -Force -ErrorAction SilentlyContinue - } - - Copy-Item -Path $cert.FullName -Destination $remoteFilePath -Force -ErrorAction Stop - Write-Host " βœ“ Copied: $($cert.Name)" -ForegroundColor Green - $copiedCertificates++ - } - catch { - $copyErrors += @{ - File = $cert.Name - Error = $_.Exception.Message - } - Write-Host " βœ— Failed to copy $($cert.Name): $($_.Exception.Message)" -ForegroundColor Red - } - } - - # Determine success/failure and always run cleanup - $failureCount = $copyErrors.Count - - if ($failureCount -gt 0) { - $failureList += @{ - Branch = $branchCode - Reason = 'Copy errors' - Details = "Failed to copy $failureCount file(s): " + ($copyErrors | ForEach-Object { $_.File }) -join ', ' - } - } - - # Invoke Move-ExpiredUserCertificates locally with remote UNC path - Write-Host "🧹 Running cleanup for $branchCode..." -ForegroundColor Cyan - - try { - # Locate cleanup script in cloned repo (same directory structure as this script) - $cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'Move-ExpiredUserCertificates.ps1' - - if (-not (Test-Path -Path $cleanupScriptPath)) { - Write-Host " ⚠️ Cleanup script not found at $cleanupScriptPath" -ForegroundColor Yellow - } - else { - # Execute cleanup script for the remote directory and capture warnings - $cleanupWarningsOutput = & $cleanupScriptPath -TargetDirectory $remotePath 3>&1 1>$null 2>&1 - - if ($cleanupWarningsOutput) { - foreach ($warning in $cleanupWarningsOutput) { - $cleanupWarnings += @{ - Branch = $branchCode - Warning = $warning.Message - } - Write-Host " ⚠️ Warning: $($warning.Message)" -ForegroundColor Yellow - } - } - else { - Write-Host " βœ“ Cleanup completed for $branchCode" -ForegroundColor Green - } - } - } - catch { - Write-Host " ⚠️ Cleanup failed for $branchCode : $($_.Exception.Message)" -ForegroundColor Yellow - } - - # Record success - $successList += @{ - Branch = $branchCode - CertificatesCopied = $copiedCertificates - CertificatesSkipped = $skippedCertificates - TotalProcessed = $copiedCertificates + $skippedCertificates - RemotePath = $remotePath - } - - Write-Host "βœ… Completed $branchCode`n" -ForegroundColor Green -} - -# ============================================================================ -# Summary Report -# ============================================================================ - -# Create output directory if needed -$outputDir = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Output\SFA' -if (-not (Test-Path -Path $outputDir)) { - $null = New-Item -Path $outputDir -ItemType Directory -Force -ErrorAction SilentlyContinue -} - -# Export cleanup warnings to CSV if any -if ($cleanupWarnings.Count -gt 0 -and $cleanupWarnings[0].Stage -eq 'LocalCleanup') { - $localCleanupWarnings = $cleanupWarnings | Where-Object { $_.Stage -eq 'LocalCleanup' } - if ($localCleanupWarnings) { - $cleanupWarningsFile = Join-Path -Path $outputDir -ChildPath "LocalCleanup-Warnings_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" - $localCleanupWarnings | Export-Csv -Path $cleanupWarningsFile -NoTypeInformation - Write-Host "πŸ“„ Local cleanup warnings exported: $cleanupWarningsFile" -ForegroundColor Gray - } -} - -Write-Host "`n" + ('=' * 80) -ForegroundColor Magenta -Write-Host 'πŸ“Š PUBLICATION SUMMARY' -ForegroundColor Magenta -Write-Host ('=' * 80) -ForegroundColor Magenta - -if ($failureList.Count -gt 0) { - Write-Host "`n⚠️ FAILURES ENCOUNTERED ($($failureList.Count)):" -ForegroundColor Red - foreach ($failure in $failureList) { - Write-Host " ❌ $($failure.Branch)" -ForegroundColor Red - Write-Host " Reason: $($failure.Reason)" -ForegroundColor Gray - Write-Host " Details: $($failure.Details)" -ForegroundColor Gray - } -} - -if ($cleanupWarnings.Count -gt 0) { - Write-Host "`n⚠️ CLEANUP WARNINGS ($($cleanupWarnings.Count)):" -ForegroundColor Yellow - foreach ($warning in $cleanupWarnings) { - Write-Host " ⚠️ $($warning.Stage): $($warning.Message)" -ForegroundColor Yellow - } -} - -Write-Host "`nβœ… SUCCESSFULLY PROCESSED ($($successList.Count)):" -ForegroundColor Green -if ($successList.Count -eq 0) { - Write-Host ' (No branches published)' -ForegroundColor Gray -} -else { - foreach ($success in $successList) { - $summaryLine = " βœ“ $($success.Branch)" - if ($success.CertificatesSkipped -gt 0) { - $summaryLine += " - Copied: $($success.CertificatesCopied), Skipped: $($success.CertificatesSkipped)" - } - else { - $summaryLine += " - Copied: $($success.CertificatesCopied)" - } - Write-Host $summaryLine -ForegroundColor Green - } -} - -# Calculate aggregate statistics -$totalCopied = ($successList | Measure-Object -Property CertificatesCopied -Sum).Sum -$totalSkipped = ($successList | Measure-Object -Property CertificatesSkipped -Sum).Sum -$totalProcessed = $totalCopied + $totalSkipped - -Write-Host "`nπŸ“Š AGGREGATE STATISTICS:" -ForegroundColor Cyan -Write-Host " Total Certificates Copied: $totalCopied" -ForegroundColor Cyan -Write-Host " Total Certificates Skipped (already present): $totalSkipped" -ForegroundColor Cyan -Write-Host " Total Certificates Processed: $totalProcessed" -ForegroundColor Cyan -if ($totalSkipped -gt 0 -and $totalProcessed -gt 0) { - $skipPercentage = [math]::Round(($totalSkipped / $totalProcessed) * 100, 1) - Write-Host " Duplicate Prevention: $skipPercentage% reduction in network traffic" -ForegroundColor Green -} - -Write-Host "`n" + ('=' * 80) -ForegroundColor Magenta -Write-Host 'Process completed. Review failures above if any.' -ForegroundColor Gray -Write-Host ('=' * 80) -ForegroundColor Magenta - -# ============================================================================ -# Export Report to Output Folder -# ============================================================================ - -$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' -$outputDir = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Output\SFA' -$outputFile = Join-Path -Path $outputDir -ChildPath "Publish-SFACertificates_Failures_$timestamp.csv" - -# Create output directory if it doesn't exist -if (-not (Test-Path -Path $outputDir)) { - $null = New-Item -Path $outputDir -ItemType Directory -Force -ErrorAction SilentlyContinue -} - -# Export failures -if ($failureList.Count -gt 0) { - $failureList | Export-Csv -Path $outputFile -NoTypeInformation -Force - Write-Host "`nπŸ“„ Failure report exported to: $outputFile" -ForegroundColor Cyan -} - -# Export successes -$successReportFile = Join-Path -Path $outputDir -ChildPath "Publish-SFACertificates_Success_$timestamp.csv" -if ($successList.Count -gt 0) { - $successList | Export-Csv -Path $successReportFile -NoTypeInformation -Force - Write-Host "πŸ“„ Success report exported to: $successReportFile" -ForegroundColor Green -} - -# Export cleanup warnings -$cleanupWarningsFile = Join-Path -Path $outputDir -ChildPath "Publish-SFACertificates_CleanupWarnings_$timestamp.csv" -if ($cleanupWarnings.Count -gt 0) { - $cleanupWarnings | Export-Csv -Path $cleanupWarningsFile -NoTypeInformation -Force - Write-Host "πŸ“„ Cleanup warnings report exported to: $cleanupWarningsFile" -ForegroundColor Yellow -} - -# Export combined summary -$summaryFile = Join-Path -Path $outputDir -ChildPath "Publish-SFACertificates_Summary_$timestamp.txt" -$summary = @" -PUBLICATION SUMMARY REPORT -Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -=============================== - -EXECUTION STATISTICS -- Total Branches Processed: $(($successList.Count) + ($failureList.Count)) -- Successful: $($successList.Count) -- Failed: $($failureList.Count) -- Cleanup Warnings: $($cleanupWarnings.Count) - -FAILURES ($($failureList.Count)) -$($failureList | ForEach-Object { "- $($_.Branch): $($_.Reason) - $($_.Details)" } | Out-String) - -CLEANUP WARNINGS ($($cleanupWarnings.Count)) -$($cleanupWarnings | ForEach-Object { "- $($_.Branch): $($_.Warning)" } | Out-String) - -SUCCESSES ($($successList.Count)) -$($successList | ForEach-Object { "- $($_.Branch): $($_.CertificateCount) certificate(s) β†’ $($_.RemotePath)" } | Out-String) -"@ - -$summary | Out-File -FilePath $summaryFile -Force -Write-Host "πŸ“„ Summary report exported to: $summaryFile" -ForegroundColor Green diff --git a/Scripts/SFA/README.html b/Scripts/SFA/README.html deleted file mode 100644 index 7c78b12..0000000 --- a/Scripts/SFA/README.html +++ /dev/null @@ -1,565 +0,0 @@ - - - - - - - Certificate Export Script - README - - - - -
-
-

πŸ” Certificate Export Script

-

Bulk export user certificates from Windows Certificate Store to PFX files

-
- -
-

Complete SFA Certificate Workflow

-

- This script is the first step in a three-stage SFA certificate distribution system: -

- -
-

πŸ“‹ Stage 1: Export Certificates

-

Script: Export-UserCertificates.ps1

-

Purpose: Extract user certificates from Windows Certificate Store

-
    -
  • Reads user names from userList.txt
  • -
  • Queries Active Directory for domain usernames
  • -
  • Exports newest certificate per user as PFX files
  • -
  • Protects with per-user passwords (username = password)
  • -
  • Organizes in timestamped output directory
  • -
  • Generates summary report of successes/failures
  • -
-

Format for userList.txt:

-
John Doe
-Jane Smith
-Alice Johnson
-Hyun Young
-

Each name on a separate line, matching exactly as it appears in Windows Certificate Store (CN field)

-

Output: PFX files in exports_TIMESTAMP/ directory

-
- -
-

πŸ“¦ Stage 2: Publish to Branches

-

Script: Publish-SFACertificates.ps1

-

Purpose: Distribute exported certificates to 24 branch servers

-
    -
  • Pre-flight Connectivity Check: Automatically validates all branch paths are accessible before publishing
  • -
  • If any branches are unreachable, displays them to the user and prompts whether to continue with only accessible branches
  • -
  • Reads PFX files from exports_TIMESTAMP/ directory
  • -
  • Maps each certificate to its target branch server via UNC paths
  • -
  • Copies only active (non-expired) certificates
  • -
  • Skips duplicates already present on remotes
  • -
  • Generates detailed success/failure reports
  • -
-

Output: Certificates distributed to all 24 SFA branches worldwide

-
- -
-

🧹 Stage 3: Archive Expired Certificates

-

Script: Move-ExpiredUserCertificates.ps1

-

Purpose: Automatically clean up expired certificates

-
    -
  • Runs on local source before publishing (removes old exports)
  • -
  • Runs on each remote branch after certificates copied (removes old branch certs)
  • -
  • Scans filenames for YYYYMMDD date format
  • -
  • Moves expired certs to Old/ subfolder for archival
  • -
  • Ensures only current certificates remain in production
  • -
-

Output: Archive folders with expired certificates on all systems

-
- -

Complete Workflow Example

-
# Step 1: Export certificates from local Windows Certificate Store -.\Export-UserCertificates.ps1 -# Creates: exports_2025-12-29_143022/ with PFX files - -# Step 2: Publish to all 24 branch servers -.\Publish-SFACertificates.ps1 -LocalSourceDirectory 'C:\Users\admin-sfa\Desktop\SFA Certificates' -# Distributes PFX files to all branches and cleans up expired ones - -# The workflow is now complete - all branches have current certificates!
- -

Key Features

-
-
-

πŸ—‚οΈ Batch Processing

-

Export multiple user certificates in a single run from a user list file

-
-
-

πŸ” AD Integration

-

Automatically query Active Directory for user sAMAccountName (username)

-
-
-

πŸ”‘ Smart Passwords

-

Use per-user usernames as passwords or fallback to a global password

-
-
-

πŸ’» Remote Execution

-

Export certificates from remote computers via PowerShell remoting

-
-
-

⏱️ Timestamped Exports

-

All exports organized in timestamped directories for easy tracking

-
-
-

πŸ“Š Detailed Reporting

-

Comprehensive summary file tracking all successful and failed exports

-
-
- -

Pre-flight Connectivity Check

-

- Publish-SFACertificates.ps1 includes an automated pre-flight connectivity check that runs immediately - before publishing certificates: -

-
    -
  • Tests connectivity to all 24+ branch server paths
  • -
  • Displays accessible and inaccessible branches with emoji indicators (βœ…/❌)
  • -
  • If all branches are accessible: Automatically continues without prompting
  • -
  • If any branches are inaccessible: -
      -
    • Shows a list of unreachable branches
    • -
    • Prompts: "Do you want to continue with the remaining X accessible branch(es)?"
    • -
    • User must explicitly confirm by entering 'yes' or 'y'
    • -
    • Any other response aborts cleanly without making changes
    • -
    -
  • -
-

- This ensures administrators are always aware of connectivity issues before the publication process begins, - preventing partial failures and wasted effort. -

- -

Usage

- -

Basic Usage

-

Run with default settings (reads from userList.txt in the script directory):

-
.\Export-UserCertificates.ps1
- -

With Username Mapping File

-

Provide a CSV file mapping full names to domain usernames:

-
.\Export-UserCertificates.ps1 -UsernameListFile 'usernames.csv'
- -

Remote Computer Export

-

Export certificates from a remote computer:

-
- .\Export-UserCertificates.ps1 -ComputerName 'RemotePC' -Credential (Get-Credential) -
- -

Custom Output Directory

-

Specify where to save the exported certificates:

-
.\Export-UserCertificates.ps1 -OutputDirectory 'C:\Certificates'
- -

Parameters

- -
- -UserListFile
- Path to a text file containing user full names (one per line). Defaults to userList.txt in - the script directory. -
- -
- -UserNames
- Array of specific user full names to process. If provided, UserListFile is ignored.
- Example: -UserNames 'John Doe', 'Jane Smith' -
- -
- -ComputerName
- Remote computer to connect to. If not specified, exports from the local machine. -
- -
- -Credential
- PSCredential object for remote connection. Only used with ComputerName. -
- -
- -CertificateStore
- Certificate store to search: 'LocalMachine', 'CurrentUser', or - 'Both'. Defaults to 'CurrentUser'. -
- -
- -OutputDirectory
- Directory to save exported PFX files. Defaults to the script's current directory. -
- -
- -Password
- SecureString password to protect exported PFX files. If not provided, prompts user at runtime. -
- -
- -UsernameListFile
- Path to CSV file mapping full names to domain usernames. Format: FullName,Username
- Example: John Doe,jdoe -
- -
- -UseUsernameAsPassword
- Use domain username as password for each certificate. Enabled by default. Requires - either UsernameListFile or QueryActiveDirectory. -
- -
- -QueryActiveDirectory
- Query Active Directory for usernames by Display Name. Enabled by default. Requires - ActiveDirectory module and domain connectivity. -
- -
- -ADServer
- Specific Active Directory server to query. Optional; uses default DC if not specified. -
- -

Certificate Filename Convention

-

Exported certificates follow this naming pattern:

-
FullName - Username - YYYYMMDD.pfx
-

Example: John Doe - jdoe - 20271201.pfx

- -

Password Management

- -

Per-User Passwords

-

When -UseUsernameAsPassword is enabled (default):

-
    -
  • Each user's certificate is protected with their AD username as the password
  • -
  • Filename shows the username: John Doe - jdoe - 20271201.pfx
  • -
  • Certificate opens with password: jdoe
  • -
- -

Fallback Password

-

For users not found in AD or without username mapping:

-
    -
  • Certificate protected with fallback password (prompted at runtime)
  • -
  • Filename shows "Fallback": Hyun Young - Fallback - 20271201.pfx
  • -
  • Summary file clearly marks these for manual follow-up
  • -
- -

Interactive Username Prompts

-

If AD query finds some usernames but not all, the script prompts for missing ones:

-
⚠️ Found 1 user(s) without username mapping: - β€’ Hyun Young - -πŸ“ Please provide the domain username (sAMAccountName) for each user: - Username for 'Hyun Young' (or press Enter to skip): hchung - βœ… Added: Hyun Young => hchung
- -

You can provide usernames or press Enter to skip (uses fallback password instead).

- -

Export Summary

-

The script generates a detailed export_summary.txt file in the export directory:

-
    -
  • Failed Exports - Listed at top and bottom for easy reference
  • -
  • Fallback Password Users - Clearly marked as requiring manual attention
  • -
  • Per-User Password Users - Listed with their usernames and expiration dates
  • -
- -

Console Output

-

The script provides real-time feedback with emoji-enhanced messaging:

-
    -
  • βœ… Green: Successfully exported with per-user password
  • -
  • ⚠️ Yellow: Exported with fallback password (needs manual action)
  • -
  • ❌ Red: Failed to export
  • -
- -

Examples

- -

Example 1: Basic Batch Export

-

Create userList.txt with:

-
John Doe -Jane Smith -Alice Johnson
-

Run:

-
.\Export-UserCertificates.ps1
- -

Example 2: CSV Username Mapping

-

Create usernames.csv with:

-
John Doe,jdoe -Jane Smith,jsmith -Alice Johnson,ajohnson
-

Run:

-
- .\Export-UserCertificates.ps1 -UsernameListFile 'usernames.csv' -QueryActiveDirectory:$false -
- -

Example 3: Remote Export with Custom Password

-
- $cred = Get-Credential -$password = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force -.\Export-UserCertificates.ps1 -ComputerName 'RemotePC' -Credential $cred -Password $password -UseUsernameAsPassword:$false -
- -

Prerequisites

-
    -
  • PowerShell 5.1 or higher (Windows PowerShell or PowerShell 7+)
  • -
  • For AD queries: ActiveDirectory module (RSAT-AD-PowerShell on Windows)
  • -
  • Domain connectivity (for AD username lookup)
  • -
  • User certificates must be present in the specified certificate store
  • -
  • For remote execution: PowerShell remoting enabled on target computer
  • -
- -

Troubleshooting

- -

Active Directory Module Not Found

-
- If the ActiveDirectory module is not available, the script will fall back to manual password entry and - disable username-based password protection. -
- -

Certificates Not Found

-

- The script searches for certificates by matching the user's full name in the certificate's Subject (CN - field). - Ensure the certificate's "Issued To" field matches exactly with the name in your user list. -

- -

Remote Export Fails

-

- Verify that PowerShell remoting is enabled on the target computer and that you have appropriate - credentials and permissions. -

- -

Password Not Working

-

- Check the export summary file to confirm which password type was used (per-user username vs fallback). - For users with fallback passwords, use the fallback password provided during the export run. -

- -

Testing Passwords

-

- Use the companion script Test-CertificatePasswords.ps1 to verify that exported certificates - can be opened with their expected passwords: -

-
.\Test-CertificatePasswords.ps1 -ExportDirectory 'path/to/exports' -
- -

Best Practices

-
    -
  • Always verify the export summary file after running the export
  • -
  • Keep a record of the fallback password used for each export batch
  • -
  • Test exported certificates with Test-CertificatePasswords.ps1 before distributing
  • -
  • Follow up manually with users whose certificates used fallback passwords
  • -
  • Store exports in a secure location with appropriate access controls
  • -
  • Use unique fallback passwords for different export batches
  • -
- -

Support & Issues

-

- For issues or questions, check the export summary file first as it contains detailed information about - which certificates exported successfully and which ones need attention. The summary clearly marks users - whose certificates are using fallback passwords and require manual password configuration. -

-
- - -
- - - \ No newline at end of file diff --git a/Scripts/SFA/feat_Publish-SFACertificates requirements.md b/Scripts/SFA/feat_Publish-SFACertificates requirements.md deleted file mode 100644 index c99c697..0000000 --- a/Scripts/SFA/feat_Publish-SFACertificates requirements.md +++ /dev/null @@ -1,151 +0,0 @@ -# **Product Requirements Document (PRD)** - -Project Name: Automated SFA Certificate Distribution System -Module: Publish-SFACertificates -Version: 1.0 -Status: Draft -Date: 2025-12-10 - -## **1\. Introduction** - -### **1.1 Purpose** - -The purpose of the Publish-SFACertificates module is to automate the deployment of user authentication certificates (PFX files) from a central "SFA Server" to distributed file servers located at various branch offices. This ensures that users at remote locations have access to the latest valid certificates required for authentication. - -### **1.2 Scope** - -* **In Scope:** - * Validating local source and remote destination paths. - * Mapping Branch Codes (e.g., JLV, JNY) to remote UNC paths. - * Recursive copying of .pfx files from source to destination. - * Handling "Regional" folder structures (e.g., parent folders containing multiple branches). - * Executing post-copy cleanup routines on remote targets. - * Logging successes and failures to CSV and TXT reports. -* **Out of Scope:** - * Generation of the certificates (Assumed to be handled by upstream process Export-UserCertificates). - * Creation of user accounts or Active Directory management. - -### **1.3 Definitions & Acronyms** - -* **SFA:** Sales Force Automation (Target application/system). -* **Branch Code:** A 3-letter identifier for a physical location (e.g., JNY for New York, JLA for Los Angeles). -* **UNC Path:** Universal Naming Convention (e.g., \\\\Server\\Share\\Path). -* **Regional Branch:** A folder structure where multiple branches share a parent directory (e.g., JHT-JDL-JBR). - -## **2\. User Personas** - -* **Persona A (System Administrator):** Responsible for ensuring certificates are generated and available. Needs detailed logs to troubleshoot network connectivity or permission errors. -* **Persona B (SFA User):** A non-technical user at a branch office. Needs their certificate to be available in the shared folder immediately after issuance. - -## **3\. Functional Requirements** - -### Format: "The system shall..." - -### **3.1 Path Mapping & Discovery** - -* **FR-01:** The system shall accept a LocalSourceDirectory parameter indicating where generated certificates are staged. -* **FR-02:** The system shall maintain a mapping of **Branch Codes** to **Remote UNC Paths** (e.g., JNY \-\> \\\\10.80.1.1\\Groups\\NewYork\\SFA\\certificates). -* **FR-03:** The system shall support custom path mappings via a BranchMappings hashtable parameter to override defaults. -* **FR-04:** The system shall intelligently locate local source folders, supporting both: - * Direct matches (e.g., \\Source\\JNY\\) - * Nested regional matches (e.g., \\Source\\JHT-JDL-JBR\\JDL\\) - -### **3.2 Connectivity & Validation** - -* **FR-05:** The system shall validate the existence of the LocalSourceDirectory before processing. -* **FR-06:** The system shall perform a **pre-flight connectivity check** on all remote branch paths before attempting any file transfers. -* **FR-06a:** If any branches are unreachable, the system shall display them to the user and prompt whether to continue with only accessible branches. -* **FR-06b:** If the user chooses not to continue (enters anything other than 'yes'/'y'), the script shall exit cleanly without making any changes. -* **FR-07:** If a remote path is unreachable during the main publication process, the system shall log a failure and proceed to the next branch (non-blocking failure). - -### **3.3 File Distribution (Publishing)** - -* **FR-08:** The system shall identify all .pfx files within the valid local branch folder. -* **FR-09:** The system must **exclude** any files located in a local Archive subfolder to prevent republishing old data. -* **FR-10:** The system shall copy files to the remote destination using Force (overwrite) mode to ensure the latest version is present. -* **FR-11:** The system shall create the remote destination directory if it does not exist. -* **FR-12:** The system shall validate relative paths to prevent directory traversal vulnerabilities. - -### **3.4 Remote Cleanup** - -* **FR-13:** After a successful copy operation for a branch, the system shall invoke the helper script Move-ExpiredUserCertificates.ps1. -* **FR-14:** The cleanup script shall be executed locally but target the **Remote UNC Path** to organize expired certificates into an Old folder on the branch server. - -### **3.5 Reporting** - -* **FR-15:** The system shall generate a timestamped **Failure Report (CSV)** containing Branch, Reason, and Details for any failed operations. -* **FR-16:** The system shall generate a timestamped **Success Report (CSV)** containing Branch, File Count, and Remote Path. -* **FR-17:** The system shall generate a human-readable **Summary Text File** aggregating statistics. -* **FR-18:** Reports shall be saved to ..\\..\\Output\\SFA. - -## **4\. Non-Functional Requirements** - -### **4.1 Reliability** - -* **NFR-01:** The script must handle partial network outages (e.g., if 3 out of 20 branches are offline, the other 17 must still be updated). - -### **4.2 Security** - -* **NFR-02:** The script must run under a Service Account with **Read** access to the SFA Server and **Write/Modify** access to all Branch file shares. -* **NFR-03:** Credentials for remote connections should be handled via the running user context (Integrated Windows Authentication). - -### **4.3 Environment** - -* **NFR-04:** The solution must be compatible with **PowerShell 5.1** and higher. -* **NFR-05:** The solution must not require the installation of modules on remote branch servers. - -## **5\. User Stories** - -1. **Story 1: Automated Distribution** - * *As a* SysAdmin, - * *I want* to run a single scheduled task, - * *So that* all 20+ branch servers receive the latest user certificates overnight without manual copying. -2. **Story 2: Network Resilience** - * *As a* SysAdmin, - * *I want* the script to skip offline locations and log them, - * *So that* a single power outage in one branch doesn't stop updates for the rest of the company. -3. **Story 3: Hygiene & Cleanup** - * *As a* Security Auditor, - * *I want* expired certificates moved to an "Old" folder automatically, - * *So that* users do not accidentally try to install or use invalid credentials. - -## **6\. Technical Constraints & Assumptions** - -### **6.1 Tech Stack** - -* **Language:** PowerShell 5.1 / 7+ -* **Source:** Central Windows Server (SFA Server) -* **Destinations:** Multiple Windows Server File Shares (10.x.x.x IPs) - -### **6.2 Directory Structure Assumptions** - -* **Local Source:** C:\\SFA Certificates (or similar). -* **Branch Folders:** Named by Branch Code (e.g., JNY). -* **Regional Folders:** Named by combined codes (e.g., JHT-JDL-JBR) containing subfolders. -* **File Naming:** Certificate files end in .pfx. - -## **7\. Implementation Details** - -### **7.1 Pre-flight Connectivity Check** - -The script now includes a mandatory pre-flight connectivity check that runs immediately after parameter validation: - -* Tests all branch paths defined in BranchMappings -* Displays accessible and inaccessible branches to the console -* If any branches are unreachable: - * Shows a list of inaccessible branches with warning emoji - * Prompts user: "Do you want to continue with the remaining X accessible branch(es)?" - * User must explicitly confirm by entering 'yes' or 'y' - - Any other response aborts the script cleanly (exit code 0) -* If all branches are accessible: - * Automatically continues to publication without prompting - -### **7.2 Integrated Test-BranchMappings Functionality** - -The standalone `Test-BranchMappings.ps1` script has been removed and its functionality integrated into `Publish-SFACertificates.ps1`. Users no longer need to run a separate diagnostic toolβ€”connectivity validation happens automatically. - -## **8\. Open Questions / Action Items** - -1. **Portland Branch (JPO):** The script indicates JPO path is currently DISABLED: Awaiting path from Jared. *Action: Obtain path and uncomment.* -2. **Service Account:** Does the account running this scheduled task have Write permissions to the hidden administrative shares or specific shares defined in the mapping? (Current mapping uses specific shares like \\Groups\\, not C$, which is good practice). -3. **Conflict Handling:** Currently, Force overwrite is used. Is versioning required if a cert is regenerated on the same day? (Currently assumed No). diff --git a/Tests/SFA/Export-UserCertificates.Tests.ps1 b/Tests/SFA/Export-UserCertificates.Tests.ps1 deleted file mode 100644 index 1bc3b09..0000000 --- a/Tests/SFA/Export-UserCertificates.Tests.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -<# -.SYNOPSIS - Pester tests for Export-UserCertificates.ps1 - -.DESCRIPTION - Tests for user certificate export functionality including parameter validation, - file handling, and basic operational checks. -#> - -BeforeAll { - # Get the script path - $scriptRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $scriptPath = Join-Path $scriptRoot 'Scripts\SFA\Export-UserCertificates.ps1' - - # Verify the script exists - if (-not (Test-Path $scriptPath)) { - throw "Script not found: $scriptPath" - } -} - -Describe 'Export-UserCertificates' { - Context 'Script exists and is valid' { - It 'script file exists' { - Test-Path $scriptPath | Should -Be $true - } - - It 'script is readable PowerShell' { - { Get-Content $scriptPath -ErrorAction Stop } | Should -Not -Throw - } - - It 'has help documentation' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - } - } - - Context 'Parameter validation' { - It 'has UserListFile parameter defined' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\[Parameter.*\]\s+.*\$UserListFile' - } - - It 'has ComputerName parameter defined' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\$ComputerName' - } - - It 'has OutputDirectory parameter defined' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\$OutputDirectory' - } - - It 'has Credential parameter defined' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\[PSCredential\]' - } - } - - Context 'File and directory operations' { - It 'creates output directory structure' { - $testDir = Join-Path $env:TEMP "test_export_$(Get-Random)" - New-Item -Path $testDir -ItemType Directory | Out-Null - - Test-Path $testDir | Should -Be $true - - Remove-Item $testDir -Force -ErrorAction SilentlyContinue - } - - It 'generates proper timestamp format' { - $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' - $timestamp | Should -Match '^\d{8}_\d{6}$' - } - } - - Context 'Certificate operations' { - It 'accesses local certificate stores' { - $certPath = 'Cert:\CurrentUser\My' - Test-Path $certPath | Should -Be $true - } - - It 'handles PFX file extension' { - '.pfx' | Should -Match '\.pfx$' - } - } - - Context 'Error handling' { - It 'script includes try-catch blocks' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'try\s*\{' - $content | Should -Match 'catch\s*\{' - } - - It 'provides error feedback' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host.*(?:error|Error|warning|Warning)' - } - } - - Context 'User feedback' { - It 'includes status messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host' - } - - It 'uses emoji in messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '[πŸ”πŸ“¦βœ…βŒβš οΈπŸ—‚οΈ]' - } - } -} - diff --git a/Tests/SFA/Move-ExpiredUserCertificates.Tests.ps1 b/Tests/SFA/Move-ExpiredUserCertificates.Tests.ps1 deleted file mode 100644 index a624050..0000000 --- a/Tests/SFA/Move-ExpiredUserCertificates.Tests.ps1 +++ /dev/null @@ -1,101 +0,0 @@ -<# -.SYNOPSIS - Pester tests for Move-ExpiredUserCertificates.ps1 - -.DESCRIPTION - Functional tests for certificate expiration date parsing and archive folder management. -#> - -BeforeAll { - # Get the script path - $scriptRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $scriptPath = Join-Path $scriptRoot 'Scripts\SFA\Move-ExpiredUserCertificates.ps1' - - # Verify the script exists - if (-not (Test-Path $scriptPath)) { - throw "Script not found: $scriptPath" - } -} - -Describe 'Move-ExpiredUserCertificates' { - Context 'Script exists and is valid' { - It 'script file exists' { - Test-Path $scriptPath | Should -Be $true - } - - It 'script is readable PowerShell' { - { Get-Content $scriptPath -ErrorAction Stop } | Should -Not -Throw - } - - It 'has help documentation' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - } - } - - Context 'Date parsing logic' { - It 'parses YYYYMMDD date format correctly' { - $dateString = '20251225' - $certDate = [DateTime]::ParseExact($dateString, 'yyyyMMdd', $null) - - $certDate.Year | Should -Be 2025 - $certDate.Month | Should -Be 12 - $certDate.Day | Should -Be 25 - } - - It 'identifies expired certificates (past date)' { - $today = (Get-Date).Date - $expiredDate = $today.AddDays(-1) - - ($expiredDate -lt $today) | Should -Be $true - } - - It 'identifies future-dated certificates' { - $today = (Get-Date).Date - $futureDate = $today.AddDays(1) - - ($futureDate -lt $today) | Should -Be $false - } - } - - Context 'Filename pattern matching' { - It 'extracts date from filename with YYYYMMDD format' { - $fileName = 'certificate_20251225.pfx' - - if ($fileName -match '(\d{8})\.pfx$') { - $matches[1] | Should -Be '20251225' - } - } - - It 'does not match filenames without date' { - $fileName = 'certificate.pfx' - - ($fileName -match '(\d{8})\.pfx$') | Should -Be $false - } - - It 'extracts date from complex filename' { - $fileName = 'Full Name - Username - 20241215.pfx' - - if ($fileName -match '(\d{8})\.pfx$') { - $matches[1] | Should -Be '20241215' - } - } - } - - Context 'Archive folder path construction' { - It 'constructs archive folder path correctly' { - $targetDir = 'C:\certificates' - $archiveFolder = Join-Path -Path $targetDir -ChildPath 'Old' - - $archiveFolder | Should -Match 'Old$' - } - - It 'prevents moving files already in archive' { - $archiveFolder = 'C:\certificates\Old' - $filePath = 'C:\certificates\Old\cert.pfx' - - (Split-Path $filePath) -like "*$archiveFolder*" | Should -Be $true - } - } -} diff --git a/Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1 b/Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1 deleted file mode 100644 index 7f3817d..0000000 --- a/Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1 +++ /dev/null @@ -1,268 +0,0 @@ -#Requires -Version 5.1 -#Requires -PSEdition Core - -<# -.SYNOPSIS - Integration tests for Publish-SFACertificates.ps1 certificate operations. -.DESCRIPTION - Tests certificate copy operations, branch mapping logic, file handling, and edge cases. -.NOTES - Tests use temporary directories to simulate real operations without network access. -#> - -Describe 'Publish-SFACertificates Certificate Copy Operations' { - BeforeEach { - # Create temporary test directories - $tempRoot = Join-Path $env:TEMP "SFA-Tests-$(Get-Random)" - $testSourceDir = Join-Path $tempRoot 'source' - $testRemoteDir = Join-Path $tempRoot 'remote' - New-Item -Path $testSourceDir -ItemType Directory -Force | Out-Null - New-Item -Path $testRemoteDir -ItemType Directory -Force | Out-Null - } - - AfterEach { - # Cleanup temp directories - if (Test-Path $tempRoot) { - Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue - } - } - - Context 'Simulated Certificate Copy Operations' { - It 'copies single PFX certificate file' { - # Arrange - $branchSourceDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchSourceDir -ItemType Directory -Force | Out-Null - $certFile = Join-Path $branchSourceDir 'test-cert.pfx' - 'dummy content' | Out-File -FilePath $certFile - - # Act - Copy-Item -Path $certFile -Destination (Join-Path $testRemoteDir 'test-cert.pfx') - - # Assert - $remoteCertPath = Join-Path $testRemoteDir 'test-cert.pfx' - Test-Path -Path $remoteCertPath | Should -Be $true - Get-Content $remoteCertPath | Should -Match 'dummy content' - } - - It 'copies multiple PFX files maintaining names' { - # Arrange - $branchSourceDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchSourceDir -ItemType Directory -Force | Out-Null - @('cert1.pfx', 'cert2.pfx', 'cert3.pfx') | ForEach-Object { - 'dummy' | Out-File -Path (Join-Path $branchSourceDir $_) - } - - # Act - Get-ChildItem -Path $branchSourceDir -Filter '*.pfx' | ForEach-Object { - Copy-Item -Path $_.FullName -Destination (Join-Path $testRemoteDir $_.Name) - } - - # Assert - $copiedFiles = @(Get-ChildItem -Path $testRemoteDir -Filter '*.pfx') - $copiedFiles.Count | Should -Be 3 - $copiedFiles.Name | Should -Contain 'cert1.pfx' - } - - It 'preserves directory structure when copying nested files' { - # Arrange - $branchSourceDir = Join-Path $testSourceDir 'JBA' - $nestedDir = Join-Path $branchSourceDir 'subfolder\deeper' - New-Item -Path $nestedDir -ItemType Directory -Force | Out-Null - $certPath = Join-Path $nestedDir 'nested-cert.pfx' - 'nested content' | Out-File -Path $certPath - - # Act - $remotePath = Join-Path $testRemoteDir 'subfolder\deeper' - New-Item -Path $remotePath -ItemType Directory -Force | Out-Null - Copy-Item -Path $certPath -Destination (Join-Path $remotePath 'nested-cert.pfx') - - # Assert - Test-Path -Path (Join-Path $testRemoteDir 'subfolder\deeper\nested-cert.pfx') | Should -Be $true - } - - It 'skips file if already present (deduplication)' { - # Arrange - $existingFile = Join-Path $testRemoteDir 'duplicate.pfx' - 'already present' | Out-File -Path $existingFile - $sourceFile = Join-Path $testSourceDir 'duplicate.pfx' - 'new content' | Out-File -Path $sourceFile - - # Act - if (-not (Test-Path $existingFile)) { - Copy-Item -Path $sourceFile -Destination $existingFile - } - - # Assert - Get-Content $existingFile | Should -Match 'already present' - } - - It 'creates remote directory if it does not exist' { - # Arrange - $sourceDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $sourceDir -ItemType Directory -Force | Out-Null - 'cert' | Out-File -Path (Join-Path $sourceDir 'file.pfx') - $remoteNestedDir = Join-Path $testRemoteDir 'new-nested-dir' - - # Act - New-Item -Path $remoteNestedDir -ItemType Directory -Force | Out-Null - Copy-Item -Path (Join-Path $sourceDir 'file.pfx') -Destination (Join-Path $remoteNestedDir 'file.pfx') - - # Assert - Test-Path -Path (Join-Path $remoteNestedDir 'file.pfx') | Should -Be $true - } - } - - Context 'Branch Path Validation' { - It 'detects accessible local branch folder' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - Test-Path -Path $branchDir -PathType Container | Should -Be $true - } - - It 'detects missing local branch folder' { - $missingDir = Join-Path $testSourceDir 'NOTEXISTS' - Test-Path -Path $missingDir -PathType Container | Should -Be $false - } - - It 'filters PFX files from directory' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - 'cert' | Out-File -Path (Join-Path $branchDir 'cert1.pfx') - 'cert' | Out-File -Path (Join-Path $branchDir 'cert2.pfx') - 'data' | Out-File -Path (Join-Path $branchDir 'data.txt') - - $pfxFiles = @(Get-ChildItem -Path $branchDir -Filter '*.pfx' -File) - $pfxFiles.Count | Should -Be 2 - $pfxFiles.Name | Should -Contain 'cert1.pfx' - } - - It 'excludes Archive and Old folders from file search' { - $branchDir = Join-Path $testSourceDir 'JBA' - $archiveDir = Join-Path $branchDir 'Archive' - $oldDir = Join-Path $branchDir 'Old' - New-Item -Path $branchDir, $archiveDir, $oldDir -ItemType Directory -Force | Out-Null - - 'cert' | Out-File -Path (Join-Path $branchDir 'active.pfx') - 'cert' | Out-File -Path (Join-Path $archiveDir 'archived.pfx') - 'cert' | Out-File -Path (Join-Path $oldDir 'old.pfx') - - $allFiles = @(Get-ChildItem -Path $branchDir -Filter '*.pfx' -Recurse) - $activeFiles = $allFiles | Where-Object { - $_.FullName -notlike '*\Archive\*' -and - $_.FullName -notlike '*\Old\*' - } - - $allFiles.Count | Should -Be 3 - $activeFiles.Count | Should -Be 1 - $activeFiles.Name | Should -Contain 'active.pfx' - } - } - - Context 'Special Character Handling' { - It 'handles certificate names with spaces' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - $certPath = Join-Path $branchDir 'my cert file.pfx' - 'cert data' | Out-File -Path $certPath - - Copy-Item -Path $certPath -Destination (Join-Path $testRemoteDir 'my cert file.pfx') - Test-Path -Path (Join-Path $testRemoteDir 'my cert file.pfx') | Should -Be $true - } - - It 'handles certificate names with dashes and underscores' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - $certPath = Join-Path $branchDir 'cert-file_v2.pfx' - 'cert data' | Out-File -Path $certPath - - Copy-Item -Path $certPath -Destination (Join-Path $testRemoteDir 'cert-file_v2.pfx') - Test-Path -Path (Join-Path $testRemoteDir 'cert-file_v2.pfx') | Should -Be $true - } - - It 'handles mixed case certificate names' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - $certPath = Join-Path $branchDir 'CertFile-V2.PFX' - 'cert data' | Out-File -Path $certPath - - Copy-Item -Path $certPath -Destination (Join-Path $testRemoteDir 'CertFile-V2.PFX') - Test-Path -Path (Join-Path $testRemoteDir 'CertFile-V2.PFX') | Should -Be $true - } - } - - Context 'Report Generation Simulation' { - It 'generates success record with required fields' { - $successRecord = @{ - Branch = 'JBA' - CertificatesCopied = 3 - CertificatesSkipped = 1 - TotalProcessed = 4 - RemotePath = $testRemoteDir - } - - $recordObject = [PSCustomObject]$successRecord - $recordObject.Branch | Should -Be 'JBA' - $recordObject.CertificatesCopied | Should -Be 3 - } - - It 'generates failure record with error details' { - $failureRecord = @{ - Branch = 'JBA' - Reason = 'Remote unreachable' - Details = "Cannot access \\nonexistent\path" - } - - $recordObject = [PSCustomObject]$failureRecord - $recordObject.Branch | Should -Be 'JBA' - $recordObject.Reason | Should -Match 'unreachable' - } - - It 'exports records to CSV format' { - $outputDir = Join-Path $tempRoot 'output' - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null - - $records = @( - [PSCustomObject]@{ Branch = 'JBA'; Status = 'Success'; CertificatesCopied = 5 }, - [PSCustomObject]@{ Branch = 'JBO'; Status = 'Failed'; Reason = 'Unreachable' } - ) - - $csvPath = Join-Path $outputDir 'report.csv' - $records | Export-Csv -Path $csvPath -NoTypeInformation - - Test-Path -Path $csvPath | Should -Be $true - $csvContent = Get-Content $csvPath -Raw - $csvContent | Should -Match 'JBA' - $csvContent | Should -Match 'JBO' - } - } - - Context 'Empty and Missing Data Handling' { - It 'handles branch with no PFX files' { - $branchDir = Join-Path $testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - - $files = @(Get-ChildItem -Path $branchDir -Filter '*.pfx') - $files.Count | Should -Be 0 - } - - It 'handles multiple branches with mixed results' { - $jbaDir = Join-Path $testSourceDir 'JBA' - $jboDir = Join-Path $testSourceDir 'JBO' - $jchDir = Join-Path $testSourceDir 'JCH' - - New-Item -Path $jbaDir, $jboDir, $jchDir -ItemType Directory -Force | Out-Null - - 'cert' | Out-File -Path (Join-Path $jbaDir 'cert.pfx') - 'cert' | Out-File -Path (Join-Path $jboDir 'cert1.pfx') - 'cert' | Out-File -Path (Join-Path $jboDir 'cert2.pfx') - - $jbaCount = @(Get-ChildItem -Path $jbaDir -Filter '*.pfx').Count - $jboCount = @(Get-ChildItem -Path $jboDir -Filter '*.pfx').Count - $jchCount = @(Get-ChildItem -Path $jchDir -Filter '*.pfx').Count - - $jbaCount | Should -Be 1 - $jboCount | Should -Be 2 - $jchCount | Should -Be 0 - } - } -} diff --git a/Tests/SFA/Publish-SFACertificates-Phase2-NetworkIntegration.Tests.ps1 b/Tests/SFA/Publish-SFACertificates-Phase2-NetworkIntegration.Tests.ps1 deleted file mode 100644 index 8a1a442..0000000 --- a/Tests/SFA/Publish-SFACertificates-Phase2-NetworkIntegration.Tests.ps1 +++ /dev/null @@ -1,488 +0,0 @@ -#Requires -Version 5.1 -#Requires -PSEdition Core - -<# -.SYNOPSIS - Phase 2 Integration tests for Publish-SFACertificates.ps1 with real network paths. -.DESCRIPTION - Tests actual file operations on real/simulated network shares, end-to-end workflow, - cleanup integration, and real branch mapping scenarios. -.NOTES - These tests are designed to work with actual UNC paths when network shares are available. - Can also use local paths that simulate network behavior for testing without actual shares. -#> - -param( - # Set to $true to use actual network shares, $false to use local temp directories - [bool]$UseNetworkShares = $false, - - # UNC path prefix for test shares (if UseNetworkShares is $true) - [string]$TestSharePrefix = '\\test-server\shares' -) - -$scriptPath = Join-Path $PSScriptRoot '..\..\Scripts\SFA\Publish-SFACertificates.ps1' -$moveExpiredScriptPath = Join-Path $PSScriptRoot '..\..\Scripts\SFA\Move-ExpiredUserCertificates.ps1' - -Describe 'Publish-SFACertificates Phase 2: Real Network Integration' { - BeforeAll { - # Determine if we can use actual network shares - $script:UseNetwork = $false - if ($UseNetworkShares) { - $script:TestPath = $TestSharePrefix - if (Test-Path -Path "$TestSharePrefix\test-connectivity" -ErrorAction SilentlyContinue) { - $script:UseNetwork = $true - Write-Host "Using actual network shares at: $TestSharePrefix" - } - else { - Write-Host "Network shares not available, using local simulation" - $script:TestPath = Join-Path $env:TEMP "SFA-Network-Sim" - New-Item -Path $script:TestPath -ItemType Directory -Force | Out-Null - } - } - else { - $script:TestPath = Join-Path $env:TEMP "SFA-Network-Sim-$(Get-Random)" - New-Item -Path $script:TestPath -ItemType Directory -Force | Out-Null - } - } - - BeforeEach { - $script:testSourceDir = Join-Path $script:TestPath 'source' - $script:testRemoteDir = Join-Path $script:TestPath 'remote' - - if (Test-Path $script:testSourceDir) { - Remove-Item -Path $script:testSourceDir -Recurse -Force -ErrorAction SilentlyContinue - } - if (Test-Path $script:testRemoteDir) { - Remove-Item -Path $script:testRemoteDir -Recurse -Force -ErrorAction SilentlyContinue - } - - New-Item -Path $script:testSourceDir -ItemType Directory -Force | Out-Null - New-Item -Path $script:testRemoteDir -ItemType Directory -Force | Out-Null - } - - AfterAll { - # Cleanup - if ($script:TestPath -and -not $UseNetworkShares -and (Test-Path $script:TestPath)) { - Remove-Item -Path $script:TestPath -Recurse -Force -ErrorAction SilentlyContinue - } - } - - Context 'Real File Operations with Network Paths' { - It 'copies actual PFX files across multiple branches' { - # Arrange - $branches = @('JBA', 'JBO', 'JCH') - foreach ($branch in $branches) { - $branchDir = Join-Path $script:testSourceDir $branch - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - - # Create multiple certificates per branch - @('cert1.pfx', 'cert2.pfx') | ForEach-Object { - "PFX content for $_" | Out-File -Path (Join-Path $branchDir $_) - } - } - - $branchMappings = @{ - 'JBA' = @{ Path = Join-Path $script:testRemoteDir 'jba' } - 'JBO' = @{ Path = Join-Path $script:testRemoteDir 'jbo' } - 'JCH' = @{ Path = Join-Path $script:testRemoteDir 'jch' } - } - - # Act - foreach ($branch in $branches) { - $sourcePath = Join-Path $script:testSourceDir $branch - $destPath = $branchMappings[$branch].Path - New-Item -Path $destPath -ItemType Directory -Force | Out-Null - - Get-ChildItem -Path $sourcePath -Filter '*.pfx' | ForEach-Object { - Copy-Item -Path $_.FullName -Destination (Join-Path $destPath $_.Name) - } - } - - # Assert - Test-Path -Path (Join-Path $script:testRemoteDir 'jba\cert1.pfx') | Should -Be $true - Test-Path -Path (Join-Path $script:testRemoteDir 'jbo\cert2.pfx') | Should -Be $true - Test-Path -Path (Join-Path $script:testRemoteDir 'jch\cert1.pfx') | Should -Be $true - } - - It 'validates deduplication with actual network file operations' { - # Arrange - $branchDir = Join-Path $script:testSourceDir 'JBA' - $remoteDir = Join-Path $script:testRemoteDir 'jba' - New-Item -Path $branchDir, $remoteDir -ItemType Directory -Force | Out-Null - - # Create source file - 'source content' | Out-File -Path (Join-Path $branchDir 'cert.pfx') - - # Pre-populate remote with different content - 'existing remote content' | Out-File -Path (Join-Path $remoteDir 'cert.pfx') - $originalContent = Get-Content (Join-Path $remoteDir 'cert.pfx') - - # Act - attempt to copy (should skip) - if (Test-Path (Join-Path $remoteDir 'cert.pfx')) { - # Skip - file already exists - } - else { - Copy-Item -Path (Join-Path $branchDir 'cert.pfx') -Destination (Join-Path $remoteDir 'cert.pfx') - } - - # Assert - original content should remain - $finalContent = Get-Content (Join-Path $remoteDir 'cert.pfx') - $finalContent | Should -Be $originalContent - $finalContent | Should -Match 'existing remote content' - } - - It 'handles actual directory structure preservation' { - # Arrange - $branchDir = Join-Path $script:testSourceDir 'JBA' - $nestedSource = Join-Path $branchDir 'region\subregion' - New-Item -Path $nestedSource -ItemType Directory -Force | Out-Null - - 'nested cert' | Out-File -Path (Join-Path $nestedSource 'regional-cert.pfx') - - # Act - $remoteDir = Join-Path $script:testRemoteDir 'jba' - $nestedRemote = Join-Path $remoteDir 'region\subregion' - New-Item -Path $nestedRemote -ItemType Directory -Force | Out-Null - Copy-Item -Path (Join-Path $nestedSource 'regional-cert.pfx') -Destination (Join-Path $nestedRemote 'regional-cert.pfx') - - # Assert - Test-Path -Path (Join-Path $remoteDir 'region\subregion\regional-cert.pfx') | Should -Be $true - Get-Content (Join-Path $remoteDir 'region\subregion\regional-cert.pfx') | Should -Match 'nested cert' - } - - It 'validates report generation with actual file operations' { - # Arrange - $outputDir = Join-Path $script:testRemoteDir 'output' - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null - - $successRecords = @( - [PSCustomObject]@{ - Branch = 'JBA' - CertificatesCopied = 5 - CertificatesSkipped = 2 - TotalProcessed = 7 - RemotePath = Join-Path $script:testRemoteDir 'jba' - }, - [PSCustomObject]@{ - Branch = 'JBO' - CertificatesCopied = 3 - CertificatesSkipped = 0 - TotalProcessed = 3 - RemotePath = Join-Path $script:testRemoteDir 'jbo' - } - ) - - # Act - $reportPath = Join-Path $outputDir 'success-report.csv' - $successRecords | Export-Csv -Path $reportPath -NoTypeInformation - - # Assert - Test-Path -Path $reportPath | Should -Be $true - $content = Get-Content $reportPath -Raw - $content | Should -Match 'JBA' - $content | Should -Match 'JBO' - $content | Should -Match '5' # JBA copies - $content | Should -Match '2' # JBA skipped - } - } - - Context 'Pre-flight Check with Real Network Paths' { - It 'validates all branch paths exist before copying' { - # Arrange - $accessibleBranches = @() - $inaccessibleBranches = @() - - $branchMappings = @{ - 'JBA' = @{ Path = Join-Path $script:testRemoteDir 'jba' } - 'JBO' = @{ Path = Join-Path $script:testRemoteDir 'jbo' } - 'JCH' = @{ Path = '\\nonexistent-server\share' } # Unreachable - } - - # Act - simulate pre-flight check - foreach ($branch in $branchMappings.Keys) { - $path = $branchMappings[$branch].Path - if (Test-Path -Path $path -PathType Container 2>$null) { - $accessibleBranches += $branch - } - else { - $inaccessibleBranches += $branch - } - } - - # Assert - $accessibleBranches.Count | Should -Be 0 # None created yet - $inaccessibleBranches.Count | Should -Be 3 # All unreachable - } - - It 'allows partial publication when some branches unreachable' { - # Arrange - $branchDir = Join-Path $script:testSourceDir 'JBA' - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - 'cert' | Out-File -Path (Join-Path $branchDir 'cert.pfx') - - $jbaRemote = Join-Path $script:testRemoteDir 'jba' - New-Item -Path $jbaRemote -ItemType Directory -Force | Out-Null - - # Act - copy only accessible branch - $branchMappings = @{ - 'JBA' = @{ Path = $jbaRemote } - 'UNREACHABLE' = @{ Path = '\\nonexistent\share' } - } - - foreach ($branch in $branchMappings.Keys) { - $remotePath = $branchMappings[$branch].Path - if (Test-Path -Path $remotePath -PathType Container 2>$null) { - $sourcePath = Join-Path $script:testSourceDir $branch - if (Test-Path -Path $sourcePath) { - Get-ChildItem -Path $sourcePath -Filter '*.pfx' | ForEach-Object { - Copy-Item -Path $_.FullName -Destination (Join-Path $remotePath $_.Name) - } - } - } - } - - # Assert - JBA copied successfully despite UNREACHABLE being unreachable - Test-Path -Path (Join-Path $jbaRemote 'cert.pfx') | Should -Be $true - } - } - - Context 'Cleanup Integration with Real Network Paths' { - It 'verifies cleanup script can be invoked on remote paths' { - # Arrange - $remoteDir = Join-Path $script:testRemoteDir 'jba' - New-Item -Path $remoteDir -ItemType Directory -Force | Out-Null - - # Create mock certificates with dates - 'cert1' | Out-File -Path (Join-Path $remoteDir 'cert_20231201.pfx') - 'cert2' | Out-File -Path (Join-Path $remoteDir 'cert_20250630.pfx') - 'cert3' | Out-File -Path (Join-Path $remoteDir 'cert_20251231.pfx') - - # Act - verify cleanup script can access the directory - $canAccess = Test-Path -Path $remoteDir -PathType Container - - # Assert - $canAccess | Should -Be $true - @(Get-ChildItem -Path $remoteDir -Filter '*.pfx').Count | Should -Be 3 - } - - It 'creates Old folder in remote directory for expired certificates' { - # Arrange - $remoteDir = Join-Path $script:testRemoteDir 'jba' - New-Item -Path $remoteDir -ItemType Directory -Force | Out-Null - - # Act - create Old folder structure - $oldFolder = Join-Path $remoteDir 'Old' - New-Item -Path $oldFolder -ItemType Directory -Force | Out-Null - 'archived cert' | Out-File -Path (Join-Path $oldFolder 'expired.pfx') - - # Assert - Test-Path -Path $oldFolder | Should -Be $true - Test-Path -Path (Join-Path $oldFolder 'expired.pfx') | Should -Be $true - } - } - - Context 'Multiple Branch Scenario with Mixed Results' { - It 'handles publication to 5+ branches with different scenarios' { - # Arrange - $branches = @{ - 'JBA' = @{ Accessible = $true; CertCount = 3 } - 'JBO' = @{ Accessible = $true; CertCount = 2 } - 'JCH' = @{ Accessible = $true; CertCount = 1 } - 'JDE' = @{ Accessible = $true; CertCount = 0 } # No certs - 'JFH' = @{ Accessible = $false; CertCount = 0 } # Unreachable - } - - # Setup source directories - foreach ($branch in $branches.Keys | Where-Object { $branches[$_].CertCount -gt 0 }) { - $branchDir = Join-Path $script:testSourceDir $branch - New-Item -Path $branchDir -ItemType Directory -Force | Out-Null - - for ($i = 1; $i -le $branches[$branch].CertCount; $i++) { - "cert$i" | Out-File -Path (Join-Path $branchDir "cert$i.pfx") - } - } - - # Setup remote directories - foreach ($branch in $branches.Keys | Where-Object { $branches[$_].Accessible }) { - $remoteDir = Join-Path $script:testRemoteDir $branch - New-Item -Path $remoteDir -ItemType Directory -Force | Out-Null - } - - # Act - simulate publication - $successCount = 0 - $failureCount = 0 - $totalCertsCopied = 0 - - foreach ($branch in $branches.Keys) { - $remote = $branches[$branch] - $sourcePath = Join-Path $script:testSourceDir $branch - $remotePath = Join-Path $script:testRemoteDir $branch - - if (-not $remote.Accessible) { - $failureCount++ - continue - } - - if ($remote.CertCount -eq 0) { - continue - } - - if (Test-Path $sourcePath) { - $certs = @(Get-ChildItem -Path $sourcePath -Filter '*.pfx') - foreach ($cert in $certs) { - Copy-Item -Path $cert.FullName -Destination (Join-Path $remotePath $cert.Name) - $totalCertsCopied++ - } - $successCount++ - } - } - - # Assert - $successCount | Should -Be 3 # JBA, JBO, JCH (branches with certs) - $failureCount | Should -Be 1 # JFH unreachable - $totalCertsCopied | Should -Be 6 # 3 + 2 + 1 - } - - It 'generates separate success and failure reports' { - # Arrange - $outputDir = Join-Path $script:testRemoteDir 'reports' - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null - - $successRecords = @( - [PSCustomObject]@{ Branch = 'JBA'; Status = 'Success'; Certs = 5 }, - [PSCustomObject]@{ Branch = 'JBO'; Status = 'Success'; Certs = 3 } - ) - - $failureRecords = @( - [PSCustomObject]@{ Branch = 'JFH'; Status = 'Failed'; Reason = 'Unreachable' }, - [PSCustomObject]@{ Branch = 'JDE'; Status = 'Failed'; Reason = 'No certs' } - ) - - # Act - $successRecords | Export-Csv -Path (Join-Path $outputDir 'success.csv') -NoTypeInformation - $failureRecords | Export-Csv -Path (Join-Path $outputDir 'failures.csv') -NoTypeInformation - - # Assert - Test-Path -Path (Join-Path $outputDir 'success.csv') | Should -Be $true - Test-Path -Path (Join-Path $outputDir 'failures.csv') | Should -Be $true - - $successContent = Get-Content (Join-Path $outputDir 'success.csv') -Raw - $failureContent = Get-Content (Join-Path $outputDir 'failures.csv') -Raw - - $successContent | Should -Match 'JBA' - $failureContent | Should -Match 'JFH' - } - } - - Context 'Error Scenarios with Real Paths' { - It 'handles permission denied errors gracefully' { - # Arrange - $remoteDir = Join-Path $script:testRemoteDir 'restricted' - New-Item -Path $remoteDir -ItemType Directory -Force | Out-Null - - 'cert' | Out-File -Path (Join-Path $remoteDir 'cert.pfx') - - # Act - $errorOccurred = $false - try { - # Simulate permission check - if (Test-Path -Path $remoteDir) { - Get-ChildItem -Path $remoteDir -ErrorAction Stop | Out-Null - } - } - catch { - $errorOccurred = $true - } - - # Assert - operation should complete even if permissions vary - $errorOccurred | Should -Be $false - } - - It 'continues copying when one branch copy fails' { - # Arrange - $branch1Dir = Join-Path $script:testSourceDir 'JBA' - $branch2Dir = Join-Path $script:testSourceDir 'JBO' - New-Item -Path $branch1Dir, $branch2Dir -ItemType Directory -Force | Out-Null - - 'cert1' | Out-File -Path (Join-Path $branch1Dir 'cert.pfx') - 'cert2' | Out-File -Path (Join-Path $branch2Dir 'cert.pfx') - - $remote1 = Join-Path $script:testRemoteDir 'jba' - $remote2 = Join-Path $script:testRemoteDir 'jbo' - New-Item -Path $remote1, $remote2 -ItemType Directory -Force | Out-Null - - # Act - copy with potential failure handling - $successCount = 0 - - try { - Copy-Item -Path (Join-Path $branch1Dir 'cert.pfx') -Destination (Join-Path $remote1 'cert.pfx') -ErrorAction Stop - $successCount++ - } - catch { - # Continue to next branch - } - - try { - Copy-Item -Path (Join-Path $branch2Dir 'cert.pfx') -Destination (Join-Path $remote2 'cert.pfx') -ErrorAction Stop - $successCount++ - } - catch { - # Continue - } - - # Assert - both should succeed - $successCount | Should -Be 2 - Test-Path -Path (Join-Path $remote1 'cert.pfx') | Should -Be $true - Test-Path -Path (Join-Path $remote2 'cert.pfx') | Should -Be $true - } - } - - Context 'Timestamp and Report Validation' { - It 'generates timestamped report filenames with correct format' { - # Arrange - $outputDir = Join-Path $script:testRemoteDir 'reports' - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null - - $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' - $reportName = "Publish-SFACertificates_Success_$timestamp.csv" - - # Act - $records = @([PSCustomObject]@{ Branch = 'JBA'; Status = 'Success' }) - $records | Export-Csv -Path (Join-Path $outputDir $reportName) -NoTypeInformation - - # Assert - Test-Path -Path (Join-Path $outputDir $reportName) | Should -Be $true - $reportName | Should -Match '^[\w-]+_\d{8}_\d{6}\.csv$' - } - - It 'validates CSV report contains all required columns' { - # Arrange - $outputDir = Join-Path $script:testRemoteDir 'reports' - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null - - $records = @( - [PSCustomObject]@{ - Branch = 'JBA' - CertificatesCopied = 5 - CertificatesSkipped = 1 - TotalProcessed = 6 - RemotePath = '\\server\share\jba' - } - ) - - # Act - $reportPath = Join-Path $outputDir 'report.csv' - $records | Export-Csv -Path $reportPath -NoTypeInformation - - # Assert - $content = Get-Content $reportPath - $header = $content[0] - - $header | Should -Match 'Branch' - $header | Should -Match 'CertificatesCopied' - $header | Should -Match 'CertificatesSkipped' - $header | Should -Match 'TotalProcessed' - $header | Should -Match 'RemotePath' - } - } -} diff --git a/Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1 b/Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1 deleted file mode 100644 index 50d5fa7..0000000 --- a/Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1 +++ /dev/null @@ -1,366 +0,0 @@ -<# -.SYNOPSIS - Pester tests for Publish-SFACertificates.ps1 pre-flight connectivity check - -.DESCRIPTION - Tests for the pre-flight connectivity check functionality including: - - Branch accessibility validation - - User prompt behavior (all accessible, partial accessibility, abort scenarios) - - Display formatting and emoji indicators - - Pre-flight check integration with main publication flow -#> - -BeforeAll { - # Get the script path - $scriptRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $scriptPath = Join-Path $scriptRoot 'Scripts\SFA\Publish-SFACertificates.ps1' - - # Verify the script exists - if (-not (Test-Path $scriptPath)) { - throw "Script not found: $scriptPath" - } - - # Create temporary test directories - $script:testRoot = Join-Path -Path $env:TEMP -ChildPath "SFA_PreflightTests_$(Get-Random)" - $script:localSource = Join-Path -Path $script:testRoot -ChildPath "LocalSource" - $null = New-Item -Path $script:localSource -ItemType Directory -Force -} - -AfterAll { - # Clean up temporary test directory - if ($script:testRoot -and (Test-Path $script:testRoot)) { - Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} - -Describe 'Publish-SFACertificates Pre-flight Connectivity Check' { - Context 'Pre-flight check existence and structure' { - It 'includes pre-flight connectivity check in script' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Pre-flight.*connectivity.*check|connectivity.*check' - } - - It 'tests all branch mappings during pre-flight' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'foreach.*BranchMappings.*Keys|for.*branch.*BranchMappings' - } - - It 'uses Test-Path for connectivity validation' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*remotePath|Test-Path.*path.*PathType.*Container' - } - - It 'tracks accessible and inaccessible branches' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'accessibleBranches|accessible' - $content | Should -Match 'inaccessibleBranches|inaccessible' - } - } - - Context 'Pre-flight display output' { - It 'displays pre-flight check header' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host.*[Pp]re-flight' - } - - It 'shows individual branch status with emoji' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'βœ…|❌' # Emoji indicators for accessible/inaccessible - } - - It 'displays summary statistics' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Accessible.*Inaccessible|Summary' - } - - It 'shows inaccessible branches when any exist' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'if.*inaccessible|if.*count.*gt.*0' - } - } - - Context 'User prompt behavior - all accessible' { - It 'continues without prompting when all branches accessible' { - $content = Get-Content $scriptPath -Raw - # Should have conditional that only prompts if inaccessibleBranches.Count > 0 - $content | Should -Match 'if.*inaccessibleBranches\.Count.*-gt 0' - } - - It 'displays success message when all accessible' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'All branches.*accessible|Proceeding.*accessible' - } - - It 'does not call Read-Host when all accessible' { - $content = Get-Content $scriptPath -Raw - # Read-Host should only be in the inaccessible branch if block - $content | Should -Match 'if.*inaccessibleBranches.*{[\s\S]*Read-Host' - } - } - - Context 'User prompt behavior - partial accessibility' { - It 'prompts user when any branches inaccessible' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Read-Host.*continue|Read-Host.*yes' - } - - It 'asks for explicit yes/y confirmation' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Read-Host.*'yes'|Read-Host.*-eq 'yes'" - } - - It 'accepts yes (case insensitive)' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "'yes'|'y'" - } - - It 'accepts y (case insensitive)' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "'y'" - } - - It 'aborts script when user does not confirm' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "exit 0|return|exit" - } - - It 'exits cleanly without error when user aborts' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'exit 0' - } - - It 'displays confirmation message when user chooses to continue' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Continuing|proceed|Continue' - } - } - - Context 'Pre-flight check timing' { - It 'runs pre-flight check before local source cleanup' { - $content = Get-Content $scriptPath -Raw - $preflightPos = $content.IndexOf('Pre-flight') - $cleanupPos = $content.IndexOf('Local Source Cleanup') - $preflightPos | Should -BeLessThan $cleanupPos - } - - It 'runs pre-flight check before main processing loop' { - $content = Get-Content $scriptPath -Raw - $preflightPos = $content.IndexOf('Pre-flight') - $foreachPos = $content.IndexOf('foreach') - # Pre-flight should come before main foreach loop - $preflightPos | Should -BeLessThan $foreachPos - } - } - - Context 'Pre-flight logic validation' { - It 'includes accessible branch count' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'accessibleBranches\.Count' - } - - It 'includes inaccessible branch count' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'inaccessibleBranches\.Count' - } - - It 'displays total branch count' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'BranchMappings\.Keys\.Count|BranchMappings\.Count' - } - - It 'iterates through all branch mappings' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'foreach.*BranchMappings\.Keys' - } - - It 'tests each remote path for accessibility' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*remotePath.*PathType.*Container|Test-Path.*path.*Container' - } - - It 'suppresses errors when testing paths' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*2>|ErrorAction.*SilentlyContinue' - } - } - - Context 'Pre-flight results do not affect main processing' { - It 'uses local branch path logic in main loop (not cached from pre-flight)' { - $content = Get-Content $scriptPath -Raw - # Main loop should still check for local branches independently - $mainLoop = $content -split 'foreach.*branchCode.*BranchMappings' | Select-Object -Last 1 - $mainLoop | Should -Match 'Test-Path.*localBranchPath|localBranchPath.*=' - } - - It 'skips branches again in main loop if unreachable (not cached)' { - $content = Get-Content $scriptPath -Raw - # Main loop should still have its own connectivity check - $mainLoop = $content -split 'foreach.*branchCode.*BranchMappings' | Select-Object -Last 1 - $mainLoop | Should -Match 'Test-RemoteConnectivity|Test-Path.*remotePath' - } - } - - Context 'Integration with branch mappings' { - It 'uses same branch mappings for pre-flight as main processing' { - $content = Get-Content $scriptPath -Raw - # BranchMappings should be referenced in parameter and in pre-flight/main loops - ($content | Select-String '\$BranchMappings\[').Count | Should -BeGreaterThan 0 - $content | Should -Match 'param.*BranchMappings' - } - - It 'tests all 24+ branches defined in mappings' { - $content = Get-Content $scriptPath -Raw - # Should iterate through all keys - $content | Should -Match 'foreach.*BranchMappings\.Keys' - } - - It 'handles custom branch mappings parameter' { - $content = Get-Content $scriptPath -Raw - # Should accept BranchMappings parameter - $content | Should -Match 'param.*BranchMappings|Parameter.*BranchMappings' - } - } - - Context 'Edge cases' { - It 'handles zero branches mapping' { - # Pre-flight should handle empty or null BranchMappings gracefully - $testMapping = @{} - $testMapping.Keys.Count | Should -Be 0 - } - - It 'handles all branches inaccessible' { - $content = Get-Content $scriptPath -Raw - # Should still display count and allow user to decide - $content | Should -Match 'inaccessibleBranches\.Count' - } - - It 'handles single branch accessible' { - $content = Get-Content $scriptPath -Raw - # Should still show prompt asking about continuing with 1 branch - $content | Should -Match 'remaining.*branch' - } - - It 'shows branch name in accessibility status' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host.*branchCode|Write-Host.*branch' - } - } - - Context 'Error handling and robustness' { - It 'continues even if one branch path test fails' { - $content = Get-Content $scriptPath -Raw - # Should not use -ErrorAction Stop during pre-flight loop - $preflightSection = $content -split 'Pre-flight' | Select-Object -First 2 | Select-Object -Last 1 - $preflightSection | Should -Not -Match 'ErrorAction Stop' - } - - It 'catches exceptions during path testing' { - $content = Get-Content $scriptPath -Raw - # Pre-flight should suppress errors - $content | Should -Match '2>.*null|2>&1|ErrorAction' - } - - It 'displays clear message for inaccessible branches' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '❌|Inaccessible|unreachable' - } - } -} - -Describe 'Publish-SFACertificates Pre-flight Abort Behavior' { - Context 'Script exit behavior on user abort' { - It 'exits with code 0 (success) when user aborts' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'exit 0' - } - - It 'exits before any file operations when aborted' { - $content = Get-Content $scriptPath -Raw - $preflightPos = $content.IndexOf('exit 0') - $copyItemPos = $content.IndexOf('Copy-Item') - $preflightPos | Should -BeLessThan $copyItemPos - } - - It 'does not create output reports when aborted' { - $content = Get-Content $scriptPath -Raw - # exit 0 should come before Export-Csv - $exitPos = $content.IndexOf('exit 0') - $exportPos = $content.IndexOf('Export-Csv') - $exitPos | Should -BeLessThan $exportPos - } - - It 'displays abort confirmation message' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'abort|Abort|Cancel|aborted' - } - } - - Context 'State preservation on abort' { - It 'does not modify local source directory when aborted' { - $content = Get-Content $scriptPath -Raw - # All file operations should be after exit point - $exitPos = $content.IndexOf('exit 0') - $movePos = $content.IndexOf('Move-Item|Remove-Item', $exitPos) - # Should not find file modification operations after exit - $movePos | Should -Be -1 - } - - It 'does not modify remote servers when aborted' { - $content = Get-Content $scriptPath -Raw - # The script should call exit 0 in the abort path - $content | Should -Match 'exit 0' - # Verify that exit happens in a conditional path (not at the end) - $readHostCount = ($content | Select-String 'Read-Host.*continue' | Measure-Object).Count - $readHostCount | Should -BeGreaterThan 0 - } - } -} - -Describe 'Publish-SFACertificates Pre-flight User Experience' { - Context 'Output messaging' { - It 'uses colored output for status messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'ForegroundColor' - } - - It 'uses green color for accessible branches' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Green.*βœ…|βœ….*Green" - } - - It 'uses red color for inaccessible branches' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Red.*❌|❌.*Red" - } - - It 'uses yellow or cyan for prompts' { - $content = Get-Content $scriptPath -Raw - # The script should use Cyan or Yellow for colored output - $hasCyan = $content | Select-String 'Cyan' | Measure-Object | ForEach-Object { $_.Count -gt 0 } - $hasYellow = $content | Select-String 'Yellow' | Measure-Object | ForEach-Object { $_.Count -gt 0 } - ($hasCyan -or $hasYellow) | Should -Be $true - } - } - - Context 'Prompt clarity' { - It 'clearly states the question being asked' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "continue|proceed|abort|stop" - } - - It 'explains the consequence of saying no' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "abort|Cancel|stop|No changes" - } - - It 'is case-tolerant for user input' { - # yes, YES, y, Y should all work - $testInput1 = 'yes' - $testInput2 = 'YES' - $testInput3 = 'y' - $testInput4 = 'Y' - # All should be treated the same - 'yes', 'y' | Should -Contain 'yes' - } - } -} diff --git a/Tests/SFA/Publish-SFACertificates.Tests.ps1 b/Tests/SFA/Publish-SFACertificates.Tests.ps1 deleted file mode 100644 index e5d87bf..0000000 --- a/Tests/SFA/Publish-SFACertificates.Tests.ps1 +++ /dev/null @@ -1,933 +0,0 @@ -<# -.SYNOPSIS - Pester tests for Publish-SFACertificates.ps1 - -.DESCRIPTION - Tests for certificate publication functionality, branch mapping validation, - and certificate copy operations. -#> - -BeforeAll { - # Get the script path - $scriptRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $scriptPath = Join-Path $scriptRoot 'Scripts\SFA\Publish-SFACertificates.ps1' - - # Verify the script exists - if (-not (Test-Path $scriptPath)) { - throw "Script not found: $scriptPath" - } -} - -Describe 'Publish-SFACertificates' { - Context 'Script exists and is valid' { - It 'script file exists' { - Test-Path $scriptPath | Should -Be $true - } - - It 'script is readable PowerShell' { - { Get-Content $scriptPath -ErrorAction Stop } | Should -Not -Throw - } - - It 'has help documentation' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - } - } - - Context 'Parameters' { - It 'has mandatory LocalSourceDirectory parameter' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\$LocalSourceDirectory' - } - - It 'has optional BranchMappings parameter' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\$BranchMappings' - } - - It 'validates LocalSourceDirectory exists' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*LocalSourceDirectory' - } - } - - Context 'Branch mapping functionality' { - It 'defines default branch mappings' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '@\{|hashtable' - } - - It 'uses UNC paths for remote locations' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '\\\\' # UNC paths start with \\ - } - - It 'maps branches to remote servers' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Server|SERVER' - } - } - - Context 'Connectivity and operations' { - It 'performs remote connectivity checks' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path|Invoke-Command' - } - - It 'copies certificates to target path' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Copy-Item' - } - - It 'supports recursive copy operations' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Recurse' - } - } - - Context 'Cleanup integration' { - It 'calls Move-ExpiredUserCertificates after copy' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Move-ExpiredUserCertificates' - } - - It 'passes target directory to cleanup script' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'TargetDirectory' - } - } - - Context 'Error handling and reporting' { - It 'includes try-catch blocks' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'try\s*\{' - $content | Should -Match 'catch\s*\{' - } - - It 'tracks failures and successes' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'List|failures|successes' - } - - It 'provides diagnostic messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host' - } - } - - Context 'User feedback' { - It 'uses Write-Host for status messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host' - } - - It 'includes emoji in messages' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '[πŸ”πŸ“¦βœ…βŒβš οΈ]' - } - - It 'uses color formatting' { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'ForegroundColor' - } - } -} - -Describe 'Publish-SFACertificates branch mapping' { - Context 'Path resolution' { - It 'constructs branch paths correctly' { - $base = '/tmp/certificates' - $branch = 'JLV' - $path = Join-Path -Path $base -ChildPath $branch - - $path | Should -Match 'JLV$' - } - - It 'validates UNC path format' { - $unc = '\\server\share\certificates' - Test-Path $unc -IsValid | Should -Be $true - } - - It 'handles path construction with Join-Path' { - $result = Join-Path -Path '/tmp' -ChildPath 'branch' - $result | Should -Match 'branch' - } - } -} - -Describe 'Publish-SFACertificates unit tests' { - Context 'Hashtable operations' { - It 'creates and uses branch mappings' { - $mappings = @{ - 'JLV' = @{ Server = 'SERVER1'; Path = '\\server1\share' } - 'JPO' = @{ Server = 'SERVER2'; Path = '\\server2\share' } - } - - $mappings.Keys.Count | Should -Be 2 - $mappings['JLV'].Server | Should -Be 'SERVER1' - } - - It 'iterates through branch mappings' { - $mappings = @{ - 'main' = 'path1' - 'dev' = 'path2' - } - - $count = 0 - foreach ($key in $mappings.Keys) { - $count++ - } - - $count | Should -Be 2 - } - } - - Context 'Path handling' { - It 'validates path accessibility' { - $testPath = '/tmp' - Test-Path $testPath | Should -Be $true - } - - It 'returns false for inaccessible paths' { - $invalidPath = '\\nonexistent\impossible\path' - Test-Path $invalidPath | Should -Be $false - } - - It 'joins paths correctly' { - $result = Join-Path -Path '/tmp' -ChildPath 'active' - $result | Should -Match 'active' - } - } - - Context 'Copy operation validation' { - It 'accepts recursive copy parameter' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match '-Recurse' - } - - It 'accepts force parameter for overwrites' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match '-Force' - } - } -} - -<# -.DESCRIPTION - Phase 1: Setup & Prerequisites Tests - Validates that the script properly initializes and checks prerequisites -#> -Describe 'Phase 1: Setup & Prerequisites' { - Context 'Script initialization' { - It 'defines LocalSourceDirectory parameter' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'LocalSourceDirectory' - } - - It 'has parameter validation' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Parameter|param' - } - } - - Context 'Prerequisite validation' { - It 'checks for local directory existence' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*LocalSourceDirectory' - } - - It 'validates source directory exists before processing' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*Container' - } - - It 'defines default source directory' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - # Should have a default LocalSourceDirectory value - $content | Should -Match 'admin-sfa.*Desktop.*SFA' - } - } -} - -<# -.DESCRIPTION - Phase 3: Common Failure Scenarios Tests - Validates that the script handles common error conditions gracefully -#> -Describe 'Phase 3: Common Failure Scenarios' { - Context 'Error handling patterns' { - It 'includes error handling' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'catch' - } - - It 'captures failure information' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'failureList|Failure|failure' - } - - It 'provides detailed failure information' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Details|Reason' - } - } - - Context 'Path and filename handling' { - It 'handles paths with special characters' { - $testPath = Join-Path $env:TEMP 'test-special_chars (1)' - # Test that PowerShell path cmdlets handle this properly - { Join-Path $testPath 'file.txt' } | Should -Not -Throw - } - - It 'validates certificate file extensions' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'pfx' - } - } -} - -<# -.DESCRIPTION - Phase 5: Integration & Validation Tests - Validates end-to-end functionality and report generation -#> -Describe 'Phase 5: Integration & Validation' { - Context 'Report generation' { - It 'creates output directory for reports' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Output|Report|Export-Csv' - } - - It 'exports success report' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Success.*csv|Publish-SFACertificates.*Success' - } - - It 'exports failure report' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Fail|Error.*csv|Publish-SFACertificates.*Fail' - } - - It 'creates summary report' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Summary|Summary.*Report' - } - } - - Context 'Cleanup integration' { - It 'invokes cleanup script after certificate copy' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Move-ExpiredUserCertificates|Cleanup.*Script' - } - - It 'passes correct directory path to cleanup' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'TargetDirectory|remotePath' - } - - It 'captures cleanup warnings' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Warning|warnings|stderr|3>&1' - } - } - - Context 'End-to-end validation' { - It 'processes multiple branches without stopping on first failure' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'foreach.*Keys|for.*branchCode' - } - - It 'maintains statistics throughout execution' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'processed|total|count' - } - - It 'displays final status message' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host.*Complete|Write-Host.*Done|Write-Host.*Finished' - } - } -} - -<# -.DESCRIPTION - Phase 4: Edge Case Testing - Validates that Publish-SFACertificates handles unusual scenarios gracefully -#> -Describe 'Publish-SFACertificates edge case tests' { - <# - .SYNOPSIS - Edge case testing for Phase 4 of issue #50 - - .DESCRIPTION - These tests validate that Publish-SFACertificates handles unusual scenarios gracefully: - - Expired certificates (dates in the past) - - Special characters in certificate names (spaces, dashes, parentheses, etc.) - - Archive folder filtering (ensuring 'Old' folders are excluded) - - Very long filenames (approaching Windows 260-character path limits) - - Mixed case branch codes (JBa vs JBA) - - Missing or malformed branch mapping entries - - Duplicate certificate names across branches - #> - - BeforeAll { - # Create a temporary test directory structure for edge case testing - $script:testRoot = Join-Path -Path '/tmp' -ChildPath "SFA_EdgeCaseTests_$(Get-Random)" - $null = New-Item -Path $script:testRoot -ItemType Directory -Force - - # Read script content for validation tests - $script:scriptRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $script:scriptPath = Join-Path $script:scriptRoot 'Scripts/SFA/Publish-SFACertificates.ps1' - $script:scriptContent = Get-Content $script:scriptPath -Raw - } - - AfterAll { - # Clean up temporary test directory - if ($script:testRoot -and (Test-Path $script:testRoot)) { - Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue - } - } - - Context 'Expired certificate handling' { - <# - Edge Case: Expired Certificates - - Certificates with expiration dates in the past should still be copied to remote locations - - The cleanup script (Move-ExpiredUserCertificates) is responsible for archiving expired certs - - This test validates that the script doesn't prematurely filter expired certificates - #> - - It 'does not filter certificates based on expiration date during copy' { - # Validate that the script doesn't check certificate expiration dates before copying - # Only the cleanup script (Move-ExpiredUserCertificates) should handle expiration logic - $scriptContent | Should -Not -Match 'ParseExact.*yyyyMMdd' - } - - It 'parses expired date format correctly for cleanup integration' { - # Verify that date format used in filenames (YYYYMMDD) can be parsed - $expiredDate = '20200101' # January 1, 2020 (expired) - $certDate = [DateTime]::ParseExact($expiredDate, 'yyyyMMdd', $null) - - $certDate.Year | Should -Be 2020 - $certDate.Month | Should -Be 1 - $certDate.Day | Should -Be 1 - } - - It 'validates expired certificates are still valid PFX files for copying' { - # Create a test certificate filename with an expired date - $expiredCertName = "John Doe - jdoe - 20200101.pfx" - - # Verify filename matches expected pattern - $expiredCertName | Should -Match '\.pfx$' - $expiredCertName | Should -Match '\d{8}\.pfx$' - } - - It 'confirms cleanup script is invoked to handle expired certificates' { - # Verify that the script calls Move-ExpiredUserCertificates after copying - $scriptContent | Should -Match 'Move-ExpiredUserCertificates' - } - } - - Context 'Special characters in certificate names' { - <# - Edge Case: Special Characters in Certificate Names - - Certificates may contain spaces, dashes, parentheses, and other special characters - - Common pattern: "Full Name - Username - YYYYMMDD.pfx" - - Must handle Unicode characters, apostrophes, and accented characters - #> - - It 'handles certificate names with spaces' { - $certName = "John Doe - jdoe - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - - It 'handles certificate names with multiple dashes' { - $certName = "Mary-Jane Smith-Jones - mjsmith - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - - It 'handles certificate names with parentheses' { - $certName = "Department (Finance) - User (Admin) - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - - It 'handles certificate names with apostrophes' { - $certName = "O'Brien - obrien - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - - It 'handles certificate names with accented characters' { - $certName = "JosΓ© GarcΓ­a - jgarcia - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - - It 'validates script uses Copy-Item which handles special characters' { - # Verify that the script uses Copy-Item for file operations - $scriptContent | Should -Match 'Copy-Item' - } - - It 'handles certificate names with numbers and underscores' { - $certName = "User_123 - Test_User_01 - 20251231.pfx" - $certName | Should -Match '\.pfx$' - } - } - - Context 'Archive folder filtering' { - <# - Edge Case: Archive Folder Filtering - - The script must exclude certificates in 'Old' archive folders from being copied - - This prevents republishing previously archived/expired certificates - - Pattern matching should be case-insensitive and work with various folder structures - #> - - It 'verifies script excludes Old archive folders from copy operations' { - # Validate that the script filters out 'Old' folders - $scriptContent | Should -Match "Old" - } - - It 'validates case-insensitive Old folder matching' { - # Test path matching with different cases - $testPaths = @( - 'C:\Certificates\Old\cert.pfx' - 'C:\Certificates\old\cert.pfx' - 'C:\Certificates\OLD\cert.pfx' - ) - - foreach ($path in $testPaths) { - $path | Should -Match '\\Old\\|\\old\\|\\OLD\\' - } - } - - It 'validates nested Old folder structures are excluded' { - $nestedOldPath = 'C:\Certificates\Branch\Old\Subfolder\cert.pfx' - $nestedOldPath | Should -Match '\\Old\\' - } - - It 'validates Old folder pattern matches correctly' { - # These paths SHOULD match the Old folder pattern - # The script uses: -inotmatch '\\\\Old(\\\\|$)' to exclude these - $oldPaths = @( - 'C:\Certificates\Old\cert.pfx' - 'C:\Certificates\Branch\Old\cert.pfx' - 'C:\Certificates\Old' # Ends with 'Old' - ) - - foreach ($path in $oldPaths) { - # These SHOULD match the pattern '\\Old(\\|$)' - $path | Should -Match '\\Old(\\|$)' - } - } - - It 'ensures non-Old folders are not incorrectly filtered' { - # These paths should NOT match the Old folder pattern - # The script uses the pattern: -inotmatch '\\\\Old(\\\\|$)' - # Which matches: \Old\ or \Old at the end of path - $validPaths = @( - 'C:\Certificates\Older\cert.pfx' # 'Older' is not 'Old' - 'C:\Certificates\Hold\cert.pfx' # 'Hold' contains 'Old' but isn't 'Old' - 'C:\Certificates\Bold\cert.pfx' # 'Bold' contains 'Old' but isn't 'Old' - ) - - foreach ($path in $validPaths) { - # These should NOT match the pattern '\\Old(\\|$)' which requires backslash or end after Old - $path | Should -Not -Match '\\Old(\\|$)' - } - } - - It 'validates script uses case-insensitive matching for Old folder' { - # Verify that the script uses -notlike (case-insensitive) or similar operators - $scriptContent | Should -Match '-notlike.*Old' - } - } - - Context 'Long filename handling' { - <# - Edge Case: Very Long Filenames - - Windows has a 260-character path limit (MAX_PATH) - - Certificate names with very long full names can approach this limit - - Script must handle these gracefully without crashing - #> - - It 'validates Windows path length limits' { - # Windows MAX_PATH is 260 characters - $maxPathLength = 260 - $maxPathLength | Should -Be 260 - } - - It 'creates a very long certificate filename within limits' { - # Construct a long but valid filename - $longName = "VeryLongUserFullName" * 5 # 100 chars - $userName = "vlufn" - $date = "20251231" - $fullCertName = "$longName - $userName - $date.pfx" - - # Verify it's still a valid PFX filename - $fullCertName | Should -Match '\.pfx$' - $fullCertName.Length | Should -BeLessThan 200 - } - - It 'validates script uses Join-Path for safe path construction' { - # Join-Path handles path length issues better than string concatenation - $scriptContent | Should -Match 'Join-Path' - } - - It 'tests path construction with long branch and certificate names' { - # Simulate long path construction - $basePath = Join-Path -Path $script:testRoot -ChildPath 'VeryLongBranchNameForTesting' - $longCertName = "A" * 100 + " - user - 20251231.pfx" - $fullPath = Join-Path -Path $basePath -ChildPath $longCertName - - # Verify path is constructable - $fullPath | Should -Not -BeNullOrEmpty - } - - It 'validates error handling for path operations' { - # Verify that the script has try-catch blocks for file operations - $scriptContent | Should -Match 'try\s*\{[\s\S]*Copy-Item[\s\S]*\}\s*catch' - } - } - - Context 'Mixed case branch code handling' { - <# - Edge Case: Mixed Case Branch Codes - - Branch codes in folder names might have inconsistent casing (JBa vs JBA) - - Script should handle case-insensitive matching for branch codes - - Hashtable keys in PowerShell are case-insensitive by default - #> - - It 'validates PowerShell hashtables are case-insensitive by default' { - $testMapping = @{ - 'JBA' = 'Baltimore' - 'JLV' = 'LasVegas' - } - - # Access with different casing should work - $testMapping['jba'] | Should -Be 'Baltimore' - $testMapping['JBA'] | Should -Be 'Baltimore' - $testMapping['Jba'] | Should -Be 'Baltimore' - } - - It 'validates branch code lookup is case-insensitive' { - # Test case variations of branch codes - $branchCodes = @('JBA', 'jba', 'Jba', 'jBA') - - foreach ($code in $branchCodes) { - $code.ToUpper() | Should -Be 'JBA' - } - } - - It 'validates folder name matching uses case-insensitive comparison' { - # Verify that the script uses case-insensitive string operations - $scriptContent | Should -Match 'StringComparison\.OrdinalIgnoreCase|StartsWith.*StringComparison' - } - - It 'handles mixed case in regional branch folder patterns' { - # Regional folders like JHT-JDL-JBR might have different casing - $testFolderPattern = 'jht-jdl-jbr' - $testFolderPattern.ToUpper() | Should -Be 'JHT-JDL-JBR' - } - - It 'validates branch code matching in regex patterns' { - # Test case-insensitive regex pattern for branch code matching - $branchCode = 'JBA' - $testFolderNames = @('JBA', 'jba', 'Jba') - - foreach ($folderName in $testFolderNames) { - # Use -imatch for case-insensitive matching - $matchResult = $folderName -imatch "^$branchCode`$" - $matchResult | Should -Be $true - } - } - } - - Context 'Missing or malformed branch mapping entries' { - <# - Edge Case: Missing or Malformed Branch Mappings - - Branch mapping hashtable might be missing expected keys - - Path values might be null, empty, or malformed - - Script should handle these gracefully and log failures appropriately - #> - - It 'handles branch mappings with missing Path property' { - $malformedMapping = @{ - 'JBA' = @{ Server = 'Baltimore' } # Missing Path - } - - # Verify that attempting to access missing Path returns null - $malformedMapping['JBA'].Path | Should -BeNullOrEmpty - } - - It 'handles branch mappings with empty Path values' { - $emptyPathMapping = @{ - 'JBA' = @{ Path = '' } - } - - $emptyPathMapping['JBA'].Path | Should -BeNullOrEmpty - } - - It 'handles branch mappings with null Path values' { - $nullPathMapping = @{ - 'JBA' = @{ Path = $null } - } - - $nullPathMapping['JBA'].Path | Should -BeNullOrEmpty - } - - It 'validates script has connectivity checks for remote paths' { - # Verify that the script checks remote path accessibility - $scriptContent | Should -Match 'Test-Path.*RemotePath|Test-RemoteConnectivity' - } - - It 'validates script tracks and reports failures' { - # Verify that the script has failure tracking - $scriptContent | Should -Match 'failureList|Reason|Details' - } - - It 'validates script continues processing after failures' { - # Verify that the script uses 'continue' to skip failed branches - $scriptContent | Should -Match 'continue' - } - - It 'handles branch code not found in local directory' { - # Script should skip branches that don't have local folders - $scriptContent | Should -Match 'not found|Skipping' - } - } - - Context 'Duplicate certificate names across branches' { - <# - Edge Case: Duplicate Certificate Names - - Same user might exist in multiple branches - - Certificates with identical names but in different branch folders - - Script should copy to appropriate branch-specific remote paths - #> - - It 'validates certificates are organized by branch directories' { - # Verify that script preserves directory structure - $scriptContent | Should -Match 'Join-Path' - } - - It 'handles same certificate name in different branches' { - # Create duplicate certificate names in different branch contexts - $cert1 = Join-Path -Path $script:testRoot -ChildPath 'JBA\John Doe - jdoe - 20251231.pfx' - $cert2 = Join-Path -Path $script:testRoot -ChildPath 'JLV\John Doe - jdoe - 20251231.pfx' - - # Verify they resolve to different paths - $cert1 | Should -Not -Be $cert2 - $cert1 | Should -Match 'JBA' - $cert2 | Should -Match 'JLV' - } - - It 'validates relative path preservation for certificates' { - # Verify that the script uses relative paths to preserve structure - $scriptContent | Should -Match 'GetRelativePath|Substring' - } - - It 'validates directory structure is created on remote' { - # Verify that script creates remote directories as needed - $scriptContent | Should -Match 'New-Item.*Directory' - } - - It 'validates Force parameter ensures overwrites for duplicate names' { - # Verify that Copy-Item uses -Force to overwrite existing files - $scriptContent | Should -Match 'Copy-Item.*-Force' - } - - It 'handles subfolder structures within branches' { - # Test certificates in branch subfolders - $subfolderCert = Join-Path -Path $script:testRoot -ChildPath 'JBA\Subfolder\cert.pfx' - $subfolderCert | Should -Match 'JBA.*Subfolder' - } - - It 'validates branch-specific remote paths in mappings' { - # Verify that branches have their own remote path mappings in hashtable format - $scriptContent | Should -Match "'JBA'\s*=\s*@\{.*Path\s*=" - $scriptContent | Should -Match "'JLV'\s*=\s*@\{.*Path\s*=" - } - } - - Context 'Report generation with edge cases' { - <# - Edge Case: Report Generation - - Reports should capture all edge case scenarios - - Failure reports should include detailed error information - - Success reports should track all processed branches - #> - - It 'validates success list captures certificate counts' { - $scriptContent | Should -Match 'successList.*CertificateCount|Count' - } - - It 'validates failure list captures error details' { - $scriptContent | Should -Match 'failureList.*Reason.*Details' - } - - It 'validates reports are exported to Output folder' { - $scriptContent | Should -Match 'Output\\SFA' - } - - It 'validates timestamped report filenames' { - $scriptContent | Should -Match 'Get-Date.*Format.*yyyyMMdd' - } - - It 'validates CSV export for structured data' { - $scriptContent | Should -Match 'Export-Csv' - } - - It 'validates summary report in text format' { - $scriptContent | Should -Match 'Out-File' - } - } - - Context 'Skip Present Certificates (Deduplication)' { - <# - Phase 6: Skip Present Certificates - - Test existence check function implementation - - Test skip logic integration into copy loop - - Test tracking of skipped certificate counts - - Test reporting of skip metrics - #> - - It 'has Test-RemoteCertificateExists function' { - $scriptContent | Should -Match 'function Test-RemoteCertificateExists' - } - - It 'checks if certificate file exists on remote path' { - $scriptContent | Should -Match 'Get-ChildItem.*RemotePath.*Filter.*CertificateFilename' - } - - It 'returns false when remote path does not exist' { - $scriptContent | Should -Match 'Test-Path.*PathType Container' - $scriptContent | Should -Match 'return \$false' - } - - It 'handles errors gracefully when checking remote existence' { - $scriptContent | Should -Match 'catch \{' - $scriptContent | Should -Match 'ErrorAction Stop' - } - - It 'skips copying certificate if already present on remote' { - $scriptContent | Should -Match 'Test-RemoteCertificateExists' - $scriptContent | Should -Match 'continue' - $scriptContent | Should -Match 'already present on remote' - } - - It 'tracks count of skipped certificates' { - $scriptContent | Should -Match '\$skippedCertificates' - $scriptContent | Should -Match '\$skippedCertificates\+\+' - } - - It 'tracks count of copied certificates' { - $scriptContent | Should -Match '\$copiedCertificates' - $scriptContent | Should -Match '\$copiedCertificates\+\+' - } - - It 'includes skip metrics in success list' { - $scriptContent | Should -Match 'CertificatesSkipped' - $scriptContent | Should -Match 'CertificatesCopied' - $scriptContent | Should -Match 'TotalProcessed' - } - - It 'exports skip statistics to CSV report' { - $scriptContent | Should -Match 'Export-Csv.*Success' - $scriptContent | Should -Match 'CertificatesSkipped|CertificatesCopied' - } - - It 'displays aggregate skip statistics in summary' { - $scriptContent | Should -Match 'totalSkipped.*Measure-Object' - $scriptContent | Should -Match 'AGGREGATE STATISTICS' - $scriptContent | Should -Match 'reduction in network traffic' - } - - It 'calculates traffic reduction percentage' { - $scriptContent | Should -Match 'skipPercentage' - $scriptContent | Should -Match '\[math\]::Round' - } - } -} - -<# -.DESCRIPTION - Phase 6: Local Cleanup Preprocessing Tests - Validates that local source cleanup occurs before publishing certificates -#> -Describe 'Phase 6: Local Cleanup Preprocessing' { - Context 'Local cleanup function' { - It 'defines Invoke-LocalSourceCleanup function' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'function Invoke-LocalSourceCleanup' - } - - It 'local cleanup runs before branch processing' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Preprocessing.*Local.*Cleanup' - } - - It 'constructs cleanup script path from PSScriptRoot' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Join-Path.*PSScriptRoot.*Move-ExpiredUserCertificates" - } - - It 'handles missing cleanup script gracefully' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Test-Path.*CleanupScriptPath' - } - - It 'continues publishing if local cleanup fails' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'catch\s*\{[\s\S]*Success\s*=\s*\$true' - } - - It 'tracks and reports cleanup warnings' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'cleanupWarnings|LocalCleanup' - } - - It 'exports cleanup warnings to CSV when present' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'LocalCleanup.*Export-Csv|cleanupWarningsFile.*Export-Csv' - } - - It 'displays local cleanup status in console output' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'Write-Host.*cleanup' - } - - It 'creates output directory for cleanup reports' { - $scriptPath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + '\Scripts\SFA\Publish-SFACertificates.ps1' - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'New-Item.*outputDir' - } - } -} - - diff --git a/Tests/SFA/TEST-IMPLEMENTATION-SUMMARY.md b/Tests/SFA/TEST-IMPLEMENTATION-SUMMARY.md deleted file mode 100644 index c2c86bc..0000000 --- a/Tests/SFA/TEST-IMPLEMENTATION-SUMMARY.md +++ /dev/null @@ -1,214 +0,0 @@ -# SFA Certificate Management Test Implementation Summary - -**Date**: December 30, 2025 -**Branch**: test/sfa-integration-tests -**Total Tests**: 211 | **Passing**: 211 | **Coverage**: 100% βœ… - -## Overview - -This document summarizes the implementation of comprehensive test suites for the SFA certificate management system. Two new test files were created with 68 functional tests, complementing the existing 143 structural tests for a total of 211 tests. - -## Test Files Created/Modified - -### 1. Publish-SFACertificates-PreflightCheck.Tests.ps1 (51 tests) - -**Purpose**: Validate pre-flight connectivity check functionality integrated into the main publish script. - -**Test Categories**: -- **Pre-flight check existence & structure** (4 tests) - Verify the check exists and validates all branches -- **Display output** (4 tests) - Verify proper formatting with emoji and color-coding -- **User prompt behavior - all accessible** (3 tests) - Auto-continue scenario -- **User prompt behavior - partial accessibility** (7 tests) - User confirmation scenarios -- **Pre-flight timing** (2 tests) - Verify execution order -- **Pre-flight logic** (6 tests) - Verify counting, iteration, and error suppression -- **Integration with branch mappings** (3 tests) - Verify 24+ branches supported -- **Edge cases** (4 tests) - Zero branches, all inaccessible, single branch -- **Error handling** (3 tests) - Exception handling, clear messaging -- **Script exit behavior on abort** (4 tests) - Exit codes, state preservation -- **Output messaging** (4 tests) - Color usage (green, red, cyan, yellow) -- **Prompt clarity** (3 tests) - Clear questions, consequences, case-tolerance - -**Key Findings**: -- All pre-flight check features working correctly -- User prompts functioning as designed -- Clean abort behavior without file modifications -- Emoji and color-coding properly implemented - -### 2. Publish-SFACertificates-Integration.Tests.ps1 (17 tests) - -**Purpose**: Validate certificate copy operations, file handling, branch mapping, and reporting. - -**Test Categories**: -- **Simulated certificate copy operations** (5 tests) - - Single and multiple file copying - - Directory structure preservation - - Deduplication (skip if already present) - - Remote directory creation - -- **Branch path validation** (4 tests) - - Local branch folder detection - - Missing folder handling - - PFX file filtering - - Archive/Old folder exclusion - -- **Special character handling** (3 tests) - - Names with spaces - - Names with dashes and underscores - - Mixed case names - -- **Report generation** (3 tests) - - Success record creation - - Failure record creation - - CSV export - -- **Empty & missing data handling** (2 tests) - - Branches with no files - - Multiple branches with mixed results - -**Key Findings**: -- File operations working correctly with special characters -- Directory structure properly preserved -- Deduplication logic sound -- CSV export functionality confirmed - -## Test Execution Results - -### Overall Statistics -``` -Total Tests Discovered: 211 -Total Tests Passed: 211 βœ… -Total Tests Failed: 0 -Success Rate: 100% -Execution Time: ~20 seconds -``` - -### Breakdown by Test File -| Test File | Tests | Status | Time | -|-----------|-------|--------|------| -| Publish-SFACertificates.Tests.ps1 | 143 | βœ… All Pass | 15.25s | -| Publish-SFACertificates-PreflightCheck.Tests.ps1 | 51 | βœ… All Pass | 1.03s | -| Publish-SFACertificates-Integration.Tests.ps1 | 17 | βœ… All Pass | 0.96s | -| Export-UserCertificates.Tests.ps1 | 43 | βœ… All Pass | 3.21s | -| Move-ExpiredUserCertificates.Tests.ps1 | 43 | βœ… All Pass | 0.29s | - -## Key Improvements Made - -### Bug Fixes -1. **Fixed duplicate JSF entry bug** - Removed duplicate $successList entry in main script -2. **Fixed test assertion issues** - Updated pre-flight test assertions to properly validate code patterns - -### Feature Implementation -1. **Integrated pre-flight connectivity check** - Moved from standalone Test-BranchMappings.ps1 into main script -2. **Added user confirmation logic** - Prompts user if any branches unreachable, allows safe abort -3. **Enhanced report generation** - Separate success/failure/cleanup warning reports - -### Test Infrastructure -1. **Created comprehensive pre-flight test suite** - 51 tests validating all aspects of connectivity check -2. **Created integration test suite** - 17 tests validating file operations and data handling -3. **Maintained mock/simulation approach** - Tests don't require network access -4. **Fixed test assertions** - Corrected regex patterns and PowerShell syntax issues - -## Test Coverage Areas - -### Phase 1: Complete βœ… -- Pre-flight connectivity check (51 tests) -- Certificate copy operations simulation (17 tests) -- No network access required -- All edge cases covered - -### Phase 2: Future -- Real network integration tests (when network available) -- Actual file operations on test shares -- Real branch mapping validation - -### Phase 3: Future -- Export-UserCertificates functional tests -- Move-ExpiredUserCertificates functional tests -- Credential handling tests - -## Running the Tests - -```powershell -# Run all SFA tests -Invoke-Pester -Path 'Tests/SFA/' -Output Detailed - -# Run specific test suite -Invoke-Pester -Path 'Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1' -Invoke-Pester -Path 'Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1' - -# Generate coverage report (future enhancement) -Invoke-Pester -Path 'Tests/SFA/' -CodeCoverage 'Scripts/SFA/*.ps1' -``` - -## Files Modified - -### Code Changes -- `Scripts/SFA/Publish-SFACertificates.ps1` - Added pre-flight check section (lines ~270-310) - -### Test Files Created -- `Tests/SFA/Publish-SFACertificates-PreflightCheck.Tests.ps1` - NEW (51 tests) -- `Tests/SFA/Publish-SFACertificates-Integration.Tests.ps1` - NEW (17 tests) - -### Documentation Updated -- `feat_Publish-SFACertificates requirements.md` - Added pre-flight check requirements -- `README.html` - Updated workflow diagram with pre-flight check -- `.github/copilot-instructions.md` - Added SFA workflow documentation -- Issue #77 - Updated with test status and completion summary - -## Commits Made - -1. **1d53d2b** - fix: Remove duplicate JSF entry in success list -2. **b766dff** - feat: Integrate pre-flight connectivity check into main script -3. **405ec19** - docs: Update all SFA documentation with pre-flight check details -4. **20b321e** - docs: Add SFA certificate management workflow to copilot instructions -5. **2f01423** - test: Fix pre-flight check test assertions -6. **d522624** - test: Add integration tests for certificate copy operations - -## Validation Checklist - -- [x] All pre-flight check functionality tested (51 tests) -- [x] All certificate copy operations tested (17 tests) -- [x] Edge cases covered (special characters, empty directories, etc.) -- [x] Report generation validated -- [x] No network access required for tests -- [x] All tests passing (211/211) -- [x] Test code is clean and maintainable -- [x] Documentation updated to reflect implementation -- [x] GitHub issue #77 updated with progress - -## Recommendations for Future Work - -1. **Implement Phase 2 Integration Tests** - - Use actual test network shares (if available) - - Test with real branch server paths - - Validate actual file transfers - -2. **Add Export-UserCertificates Functional Tests** - - Test credential handling - - Validate PFX generation - - Test error scenarios - -3. **Add Move-ExpiredUserCertificates Functional Tests** - - Test date parsing with various formats - - Validate file movement operations - - Test archive management - -4. **Implement Code Coverage Analysis** - - Add coverage metrics to test runs - - Target 95%+ line coverage on critical paths - - Generate coverage reports in CI/CD - -5. **Add to CI/CD Pipeline** - - Run tests on every commit - - Block merges if tests fail - - Generate test reports for each build - -## Conclusion - -The SFA certificate management system now has comprehensive test coverage for the pre-flight connectivity check and certificate copy operations. All 211 tests are passing with 100% success rate. The implementation demonstrates the value of integrating diagnostic tools directly into production workflows while maintaining comprehensive test validation. Future phases should continue this momentum by adding functional tests for the remaining scripts and integrating with real network environments. - ---- - -**Prepared by**: GitHub Copilot -**Status**: Ready for review and merge -**Next Steps**: Merge to main, plan Phase 2 integration tests, add to CI/CD pipeline From 56698fc4e9c5ad016552e46a492efdfc722270a4 Mon Sep 17 00:00:00 2001 From: Joey Maffiola <7maffiolajoey@gmail.com> Date: Thu, 21 May 2026 15:46:28 -0500 Subject: [PATCH 2/2] removed sfa output folder --- Output/SFA/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Output/SFA/.gitkeep diff --git a/Output/SFA/.gitkeep b/Output/SFA/.gitkeep deleted file mode 100644 index e69de29..0000000