diff --git a/ModuleChangelog.md b/ModuleChangelog.md index dc5b9a86..27318ed3 100644 --- a/ModuleChangelog.md +++ b/ModuleChangelog.md @@ -1,3 +1,21 @@ +## 2.12.0 + +Release Date: January 15, 2026 + +#### RELEASE NOTES + +``` +This release includes the option to update ADMU status on the JumpCloud system description field when migrating remotely. This allows administrators to track migration progress remotely. +``` + +#### FEATURES: + +Additional method for remotely migrating system by reading the device description field. + +#### IMPROVEMENTS: + +Several new tests added to validate new features and existing functionality. + ## 2.11.0 Release Date: January 2, 2026 @@ -56,7 +74,6 @@ Patch release to address several issues. - This release introduces the ability to set a device's primary user using the PrimaryUser parameter. This can only be done when using the AutoBindJCUser feature as well as using SystemContext. - Profile image permission backup functionality added - ## 2.9.4 Release Date: November 06, 2025 diff --git a/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/3_ADMU_Invoke.ps1 b/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/3_ADMU_Invoke.ps1 index 110d20b8..e6f80cb1 100644 --- a/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/3_ADMU_Invoke.ps1 +++ b/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/3_ADMU_Invoke.ps1 @@ -1,724 +1,587 @@ -# This script is designed to be run from the JumpCloud Console as a command. It -# will be invoked by the JumpCloud Agent on the target system. -# The script will run the ADMU command to migrate a user to JumpCloud -################################################################################ -# Update Variables Below -################################################################################ -#region variables -# CSV or Github input -$dataSource = 'csv' -# CSV variables only required if the dataSource is set to 'csv' this is the name of the CSV uploaded to the JumpCloud command -$csvName = 'jcdiscovery.csv' - -# ADMU variables -$TempPassword = 'Temp123!Temp123!' -$LeaveDomain = $true -$ForceReboot = $true -$UpdateHomePath = $false -$AutoBindJCUser = $true -$PrimaryUser = $false -$BindAsAdmin = $false # Bind user as admin (default False) -$JumpCloudAPIKey = 'YOURAPIKEY' # This field is required if the device is not eligible to use the systemContext API/ the systemContextBinding variable is set to false -$JumpCloudOrgID = 'YOURORGID' # This field is required if you use a MTP API Key -$SetDefaultWindowsUser = $true # Set the default last logged on windows user to the JumpCloud user (default True) -$ReportStatus = $false # Report status back to JumpCloud Description (default False) - -# Option to shutdown or restart -# Restarting the system is the default behavior -# If you want to shutdown the system, set the postMigrationBehavior to Shutdown -# The 'shutdown' behavior performs a shutdown of the system in a much faster manner than 'restart' which can take 5 mins form the time the command is issued -$postMigrationBehavior = 'Restart' # Restart or Shutdown - -# Option to remove the existing MDM -$removeMDM = $false # Remove the existing MDM (default false) - -# option to bind using the systemContext API -$systemContextBinding = $false # Bind using the systemContext API (default False) -# If you want to bind using the systemContext API, set the systemContextBinding to true -# The systemContextBinding option is only available for devices that have enrolled a device using a JumpCloud Administrators Connect Key -# for more information, see the JumpCloud documentation: https://docs.jumpcloud.com/api/2.0/index.html#section/System-Context -#endregion variables -################################################################################ -# Do not edit below -################################################################################ -#region functionDefinitions -function Confirm-MigrationParameter { - [CmdletBinding()] - param( - [ValidateSet('csv', 'github')] - [string]$dataSource = 'csv', - - [string]$csvName = 'jcdiscovery.csv', - [string]$GitHubUsername = '', - [string]$GitHubToken = '', - [string]$GitHubRepoName = 'Jumpcloud-ADMU-Discovery', - - [string]$TempPassword = 'Temp123!Temp123!', - [bool]$LeaveDomain = $true, - [bool]$ForceReboot = $true, - [bool]$UpdateHomePath = $false, - [bool]$AutoBindJCUser = $true, - [bool]$PrimaryUser = $false, - [bool]$BindAsAdmin = $false, - [bool]$SetDefaultWindowsUser = $true, - [bool]$removeMDM = $true, - - [bool]$systemContextBinding = $false, - [string]$JumpCloudAPIKey = 'YOURAPIKEY', - [string]$JumpCloudOrgID = 'YOURORGID', - [bool]$ReportStatus = $false, - - [ValidateSet('Restart', 'Shutdown')] - [string]$postMigrationBehavior = 'Restart' - ) - if ($dataSource -eq 'csv') { - if ([string]::IsNullOrWhiteSpace($csvName)) { - throw "Parameter Validation Failed: When dataSource is 'csv', the 'csvName' parameter cannot be empty." - } - } elseif ($dataSource -eq 'github') { - if ([string]::IsNullOrWhiteSpace($GitHubUsername)) { - throw "Parameter Validation Failed: When dataSource is 'github', the 'GitHubUsername' parameter cannot be empty." - } - if ([string]::IsNullOrWhiteSpace($GitHubToken)) { - throw "Parameter Validation Failed: When dataSource is 'github', the 'GitHubToken' parameter cannot be empty." - } - if ([string]::IsNullOrWhiteSpace($GitHubRepoName)) { - throw "Parameter Validation Failed: When dataSource is 'github', the 'GitHubRepoName' parameter cannot be empty." - } - } - if ([string]::IsNullOrEmpty($TempPassword)) { - throw "Parameter Validation Failed: The 'TempPassword' parameter cannot be empty." - } - # This check is crucial. It runs if the user relies on the default systemContextBinding=$false or sets it explicitly. - if (-not $systemContextBinding) { - if ([string]::IsNullOrWhiteSpace($JumpCloudAPIKey) -or $JumpCloudAPIKey -eq 'YOURAPIKEY') { - throw "Parameter Validation Failed: 'JumpCloudAPIKey' must be set to a valid key when 'systemContextBinding' is false." - } - if ([string]::IsNullOrWhiteSpace($JumpCloudOrgID) -or $JumpCloudOrgID -eq 'YOURORGID') { - throw "Parameter Validation Failed: 'JumpCloudOrgID' must be set to a valid ID when 'systemContextBinding' is false." - } - } - return $true -} -function Get-MigrationUsersFromCsv { - [CmdletBinding()] - [OutputType([PSCustomObject[]])] - param( - # The full path to the discovery CSV file. - [Parameter(Mandatory = $true)] - [string]$csvPath, - [Parameter(Mandatory = $true)] - [boolean]$systemContextBinding - ) - begin { - if (-not (Test-Path -Path $csvPath -PathType Leaf)) { - throw "Validation Failed: The CSV file was not found at: '$csvPath'." - } - $ImportedCSV = Import-Csv -Path $csvPath -ErrorAction Stop - } - process { - # Begin by processing the CSV content headers, these should include the required values - $requiredHeaders = @("LocalComputerName", "SerialNumber", "JumpCloudUserName", "SID", "LocalPath") - $csvHeaders = $ImportedCSV[0].PSObject.Properties.Name - foreach ($header in $requiredHeaders) { - if ($header -notin $csvHeaders) { - throw "Validation Failed: The CSV is missing the required header: '$header'." - } - } - $usersToMigrate = New-Object System.Collections.ArrayList - $computerName = hostname - if ([string]::IsNullOrWhiteSpace($computerName)) { - $computerName = $env:COMPUTERNAME - } - try { - $serialNumber = (Get-WmiObject -Class Win32_BIOS).SerialNumber - } catch { - $serialNumber = (Get-CimInstance -Class Win32_BIOS).SerialNumber - } - $ValidDeviceRows = $ImportedCSV | Where-Object { - ((-not [string]::IsNullOrWhiteSpace($_.JumpCloudUserName))) -and - ($_.LocalComputerName -eq $computerName) -and - ($_.SerialNumber -eq $serialNumber) - } - $duplicateSids = $ValidDeviceRows | Group-Object -Property 'SID' | Where-Object { $_.Count -gt 1 } - if ($duplicateSids) { - throw "Validation Failed: Duplicate SID '$($duplicateSids[0].Name)' found for LocalComputerName '$($computerName)'." - } - foreach ($row in $ValidDeviceRows) { - if (($row.LocalComputerName -eq $computerName) -and ($row.SerialNumber -eq $serialNumber)) { - if ($systemContextBinding -and [string]::IsNullOrWhiteSpace($row.JumpCloudUserID)) { - throw "VALIDATION FAILED: on row $rowNum : 'JumpCloudUserID' cannot be empty when systemContextBinding is enabled. Halting script." - } - $requiredFields = "LocalPath", "SID" - foreach ($field in $requiredFields) { - if ([string]::IsNullOrWhiteSpace($row.$field)) { - throw "Validation Failed: Missing required data for field '$field'." - } - } - $usersToMigrate.Add([PSCustomObject]@{ - selectedUsername = $row.SID - LocalProfilePath = $row.LocalPath - JumpCloudUserName = $row.JumpCloudUserName - JumpCloudUserID = $row.JumpCloudUserID - }) | Out-Null - } - } - } - end { - if ($usersToMigrate.Count -eq 0) { - throw "Validation Failed: No users were found in the CSV matching this computer's name ('$computerName') and serial number ('$serialNumber')." - } - return $usersToMigrate - } -} -function Get-LatestADMUGUIExe { - [CmdletBinding()] - [OutputType([PSCustomObject[]])] - param( - # The full path to the discovery CSV file. - [Parameter(Mandatory = $false)] - [string]$destinationPath = "C:\Windows\Temp", - # Optional GitHub token for authenticated requests (helps avoid rate limiting) - [Parameter(Mandatory = $false)] - [string]$GitHubToken, - # Maximum number of retry attempts - [Parameter(Mandatory = $false)] - [int]$MaxRetries = 3, - # Delay between retries in seconds - [Parameter(Mandatory = $false)] - [int]$RetryDelaySeconds = 20 - ) - - begin { - $owner = "TheJumpCloud" - $repo = "jumpcloud-ADMU" - $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/latest" - # Setup headers for authenticated requests if token is provided - $headers = @{ - "Accept" = "application/vnd.github.v3+json" - } - if (-not [string]::IsNullOrEmpty($GitHubToken)) { - $headers["Authorization"] = "Bearer $GitHubToken" - Write-Host "Using authenticated GitHub API requests" -ForegroundColor Cyan - } - } - process { - $attempt = 0 - $success = $false - $lastError = $null - while ($attempt -lt $MaxRetries -and -not $success) { - $attempt++ - try { - if ($attempt -gt 1) { - Write-Host "Retry attempt $attempt of $MaxRetries..." -ForegroundColor Yellow - } - Write-Host "Querying GitHub API for the latest '$repo' release..." -ForegroundColor Yellow - # Get latest release data from the GitHub API - $latestRelease = Invoke-RestMethod -Uri $apiUrl -Headers $headers -ErrorAction Stop - # Find the specific GUI executable asset - $exeAsset = $latestRelease.assets | Where-Object { $_.name -eq 'gui_jcadmu.exe' } - if ($exeAsset) { - $downloadUrl = $exeAsset.browser_download_url - $fileName = $exeAsset.name - $fullPath = Join-Path -Path $destinationPath -ChildPath $fileName - Write-Host "Downloading '$fileName' (Version $($latestRelease.tag_name))..." -ForegroundColor Yellow - # Download with retry logic - $downloadAttempt = 0 - $downloadSuccess = $false - while ($downloadAttempt -lt $MaxRetries -and -not $downloadSuccess) { - $downloadAttempt++ - try { - Invoke-WebRequest -Uri $downloadUrl -OutFile $fullPath -ErrorAction Stop - $downloadSuccess = $true - } catch { - if ($downloadAttempt -lt $MaxRetries) { - Write-Host "Download failed. Retrying in $RetryDelaySeconds seconds..." -ForegroundColor Yellow - Start-Sleep -Seconds $RetryDelaySeconds - } else { - throw - } - } - } - Write-Host "Download complete! File saved to '$fullPath'." -ForegroundColor Green - $success = $true - } else { - throw "Could not find 'gui_jcadmu.exe' in the latest release." - } - } catch { - $lastError = $_ - $errorMessage = $_.Exception.Message - $isRateLimit = $errorMessage -match "rate limit" - $isNetworkError = $errorMessage -match "network|connection|timeout|unable to connect" - if ($isRateLimit) { - Write-Host "GitHub API rate limit exceeded." -ForegroundColor Yellow - if ([string]::IsNullOrEmpty($GitHubToken)) { - Write-Host "Hint: Provide a GitHub token via -GitHubToken parameter for higher rate limits." -ForegroundColor Cyan - } - } elseif ($isNetworkError) { - Write-Host "Network connectivity issue detected: $errorMessage" -ForegroundColor Yellow - } - if ($attempt -lt $MaxRetries) { - Write-Host "Waiting $RetryDelaySeconds seconds before retry..." -ForegroundColor Yellow - Start-Sleep -Seconds $RetryDelaySeconds - } else { - $errorDetail = if ($lastError.ErrorDetails.Message) { - $lastError.ErrorDetails.Message - } else { - $lastError.Exception.Message - } - throw "Operation failed after $MaxRetries attempts. Last error: $errorDetail" - } - } - } - } -} -function ConvertTo-ArgumentList { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [hashtable] - $InputHashtable - ) - # Initialize a generic list to hold the formatted arguments. - $argumentList = [System.Collections.Generic.List[string]]::new() - # Iterate through each key-value pair in the input hashtable. - foreach ($entry in $InputHashtable.GetEnumerator()) { - # Only process entries where the value is not null or an empty string. - if ($null -ne $entry.Value -and (-not ($entry.Value -is [string]) -or $entry.Value -ne '')) { - $key = $entry.Key - $value = $entry.Value - # Format the value. Booleans are converted to lowercase string literals like '$true'. - # Other types are used as-is (they will be converted to strings automatically). - $formattedValue = if ($value -is [bool]) { - '$' + $value.ToString().ToLower() - } else { - $value - } - # Construct the argument string in the format -Key:Value and add it to the list. - $argument = "-{0}:{1}" -f $key, $formattedValue - $argumentList.Add($argument) - } - } - return $argumentList -} -function Get-JcadmuGuiSha256 { - [CmdletBinding()] - param( - [Parameter(Mandatory = $false)] - [string]$GitHubToken, - [Parameter(Mandatory = $false)] - [int]$MaxRetries = 3, - [Parameter(Mandatory = $false)] - [int]$RetryDelaySeconds = 5 - ) - begin { - $apiUrl = "https://api.github.com/repos/TheJumpCloud/jumpcloud-ADMU/releases" - # Setup headers for authenticated requests if token is provided - $headers = @{ - "Accept" = "application/vnd.github.v3+json" - } - if (-not [string]::IsNullOrEmpty($GitHubToken)) { - $headers["Authorization"] = "Bearer $GitHubToken" - } - } - process { - $attempt = 0 - $success = $false - $lastError = $null - while ($attempt -lt $MaxRetries -and -not $success) { - $attempt++ - try { - if ($attempt -gt 1) { - Write-Host "Retry attempt $attempt of $MaxRetries for SHA256 retrieval..." -ForegroundColor Yellow - } - $releases = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers -ErrorAction Stop - if ($null -eq $releases -or $releases.Count -eq 0) { - throw "No releases were found for the repository." - } - $latestRelease = $releases[0] - $latestTag = $latestRelease.tag_name - # Find the specific asset within the 'assets' array - $targetAsset = $latestRelease.assets | Where-Object { $_.name -eq 'gui_jcadmu.exe' } - if ($targetAsset) { - $digest = $targetAsset.digest - if ($digest -and $digest.StartsWith('sha256:')) { - $sha256 = $digest.Split(':')[1] - $success = $true - return [PSCustomObject]@{ - TagName = $latestTag - SHA256 = $sha256 - } - } else { - throw "SHA256 digest not found or in unexpected format for 'gui_jcadmu.exe'." - } - } else { - throw "Asset 'gui_jcadmu.exe' not found in the latest release (Tag: $latestTag)." - } - } catch { - $lastError = $_ - $errorMessage = $_.Exception.Message - # Check for specific error types - $isRateLimit = $errorMessage -match "rate limit|403|forbidden" - $isNetworkError = $errorMessage -match "network|connection|timeout|unable to connect" - if ($isRateLimit) { - Write-Host "GitHub API access issue (rate limit or 403 Forbidden)." -ForegroundColor Yellow - if ([string]::IsNullOrEmpty($GitHubToken)) { - Write-Host "Hint: Provide a GitHub token via -GitHubToken parameter to avoid rate limiting." -ForegroundColor Cyan - } - } elseif ($isNetworkError) { - Write-Host "Network connectivity issue detected: $errorMessage" -ForegroundColor Yellow - } - if ($attempt -lt $MaxRetries) { - Write-Host "Waiting $RetryDelaySeconds seconds before retry..." -ForegroundColor Yellow - Start-Sleep -Seconds $RetryDelaySeconds - } else { - # Final attempt failed - $errorDetail = if ($lastError.ErrorDetails.Message) { - $lastError.ErrorDetails.Message - } else { - $lastError.Exception.Message - } - throw "An API error or network issue occurred after $MaxRetries attempts: $errorDetail" - } - } - } - } -} -function Test-ExeSHA { - param ( - [Parameter(Mandatory = $true)] - [string]$filePath, - [Parameter(Mandatory = $false)] - [string]$GitHubToken - ) - process { - if (-not (Test-Path -Path $filePath)) { - throw "The gui_jcadmu.exe file was not found at: '$filePath'." - } - # Pass GitHub token to Get-JcadmuGuiSha256 if available - if (-not [string]::IsNullOrEmpty($GitHubToken)) { - $releaseSHA256 = Get-JcadmuGuiSha256 -GitHubToken $GitHubToken - } else { - $releaseSHA256 = Get-JcadmuGuiSha256 - } - $releaseSHA256 = $releaseSHA256.SHA256 - # Get the SHA256 of the local file - $localFileHash = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash.ToLower() - Write-Host "[status] Official SHA256: $releaseSHA256" - Write-Host "[status] Local File SHA256: $localFileHash" - Write-Host "`nValidating the downloaded file against the official release hash..." - if ($localFileHash -eq $releaseSHA256.ToLower()) { - Write-Host "[status] SUCCESS: Hash validation passed! The local file matches the official release." - } else { - throw "[status] WARNING: HASH MISMATCH! The local file is different from the official release." - } - } -} -function Invoke-UserMigrationBatch { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [array]$UsersToMigrate, - - [Parameter(Mandatory = $true)] - [hashtable]$MigrationConfig - ) - # Initialize results tracking - $results = [PSCustomObject]@{ - TotalUsers = $UsersToMigrate.Count - SuccessfulMigrations = 0 - FailedMigrations = 0 - MigrationDetails = @() - SuccessfulUsers = @() - FailedUsers = @() - StartTime = Get-Date - EndTime = $null - Duration = $null - } - # Get the last user for domain leave logic - $lastUser = $UsersToMigrate | Select-Object -Last 1 - # Process each user migration - foreach ($user in $UsersToMigrate) { - $userStartTime = Get-Date - $isLastUser = ($user -eq $lastUser) - # Determine domain leave parameter for this user - $leaveDomainParam = if ($isLastUser -and $MigrationConfig.LeaveDomainAfterMigration) { $true } else { $false } - $removeMDMParam = if ($isLastUser -and $MigrationConfig.RemoveMDM) { $true } else { $false } - # Build migration parameters for this user - $migrationParams = @{ - JumpCloudUserName = $user.JumpCloudUserName - SelectedUserName = $user.selectedUsername - TempPassword = $MigrationConfig.TempPassword - UpdateHomePath = $MigrationConfig.UpdateHomePath - AutoBindJCUser = $MigrationConfig.AutoBindJCUser - PrimaryUser = $MigrationConfig.PrimaryUser - JumpCloudAPIKey = $MigrationConfig.JumpCloudAPIKey - BindAsAdmin = $MigrationConfig.BindAsAdmin - SetDefaultWindowsUser = $MigrationConfig.SetDefaultWindowsUser - LeaveDomain = $leaveDomainParam - RemoveMDM = $removeMDMParam - adminDebug = $true - ReportStatus = $MigrationConfig.ReportStatus - } - # Handle optional JumpCloudOrgID parameter - if (-not [string]::IsNullOrEmpty($MigrationConfig.JumpCloudOrgID)) { - $migrationParams.Add('JumpCloudOrgID', $MigrationConfig.JumpCloudOrgID) - } - # Handle system context binding parameters - if ($MigrationConfig.systemContextBinding -eq $true) { - $migrationParams.Remove('AutoBindJCUser') - $migrationParams.Remove('JumpCloudAPIKey') - $migrationParams.Remove('JumpCloudOrgID') - $migrationParams.Add('systemContextBinding', $true) - $migrationParams.Add('JumpCloudUserID', $user.JumpCloudUserID) - } - # Get domain status before migration - $domainStatus = Get-DomainStatus - Write-Host "[status] Domain status before migration:" - Write-Host "[status] Azure/EntraID status: $($domainStatus.AzureAD)" - Write-Host "[status] Local domain status: $($domainStatus.LocalDomain)" - Write-Host "[status] Begin Migration for JumpCloudUser: $($user.JumpCloudUserName)" - # Execute the migration - $migrationResult = Invoke-SingleUserMigration -User $user -MigrationParams $migrationParams -GuiJcadmuPath $MigrationConfig.guiJcadmuPath - # Track results - $userResult = [PSCustomObject]@{ - JumpCloudUserName = $user.JumpCloudUserName - SelectedUsername = $user.selectedUsername - Success = $migrationResult.Success - ErrorMessage = $migrationResult.ErrorMessage - DomainStatusBefore = $domainStatus - StartTime = $userStartTime - EndTime = Get-Date - Duration = (Get-Date) - $userStartTime - IsLastUser = $isLastUser - LeaveDomain = $leaveDomainParam - } - $results.MigrationDetails += $userResult - if ($migrationResult.Success) { - $results.SuccessfulMigrations++ - $results.SuccessfulUsers += $userResult - Write-Host "[status] Migration completed successfully for user: $($user.JumpCloudUserName)" - } else { - $results.FailedMigrations++ - $results.FailedUsers += $userResult - Write-Host "[status] Migration failed for user: $($user.JumpCloudUserName)" - } - } - $results.EndTime = Get-Date - $results.Duration = $results.EndTime - $results.StartTime - Write-Host "`nAll user migrations have been processed." - return $results -} -function Invoke-SingleUserMigration { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [PSCustomObject]$User, - - [Parameter(Mandatory = $true)] - [hashtable]$MigrationParams, - - [Parameter(Mandatory = $true)] - [string]$GuiJcadmuPath - ) - if (-not (Test-Path -Path $GuiJcadmuPath)) { - throw "The gui_jcadmu.exe file was not found at: '$GuiJcadmuPath'. Please ensure the file is present before running the migration." - } - $convertedParams = ConvertTo-ArgumentList -InputHashtable $MigrationParams - Write-Host "[status] Executing migration command..." - # Execute the migration - $result = & $GuiJcadmuPath $convertedParams - # get the exit code - $exitCode = $LASTEXITCODE - Write-Host "[status] Migration process completed with exit code: $exitCode" - Write-Host "`n[status] Migration output:" - $result | Out-Host - Write-Host "`n" - if ($exitCode -eq 0) { - # return true - return [PSCustomObject]@{ - Success = $true - ErrorMessage = $null - } - } else { - return [PSCustomObject]@{ - Success = $false - ErrorMessage = $result[-1] - } - } -} -function Get-DomainStatus { - [CmdletBinding()] - param() - try { - $ADStatus = dsregcmd.exe /status - $AzureADStatus = "Unknown" - $LocalDomainStatus = "Unknown" - foreach ($line in $ADStatus) { - if ($line -match "AzureADJoined : ") { - $AzureADStatus = ($line.TrimStart('AzureADJoined : ')) - } - if ($line -match "DomainJoined : ") { - $LocalDomainStatus = ($line.TrimStart('DomainJoined : ')) - } - } - return [PSCustomObject]@{ - AzureAD = $AzureADStatus - LocalDomain = $LocalDomainStatus - } - } catch { - Write-Host "[status] Error getting domain status: $($_.Exception.Message)" - return [PSCustomObject]@{ - AzureAD = "Error" - LocalDomain = "Error" - } - } -} -#endregion functionDefinitions - -#region validation -# validate dataSource -$confirmMigrationParameters = Confirm-MigrationParameter -dataSource $dataSource ` - -csvName $csvName ` - -GitHubUsername $GitHubUsername ` - -GitHubToken $GitHubToken ` - -GitHubRepoName $GitHubRepoName ` - -TempPassword $TempPassword ` - -LeaveDomain $LeaveDomain ` - -ForceReboot $ForceReboot ` - -UpdateHomePath $UpdateHomePath ` - -AutoBindJCUser $AutoBindJCUser ` - -PrimaryUser $PrimaryUser ` - -BindAsAdmin $BindAsAdmin ` - -SetDefaultWindowsUser $SetDefaultWindowsUser ` - -systemContextBinding $systemContextBinding ` - -JumpCloudAPIKey $JumpCloudAPIKey ` - -JumpCloudOrgID $JumpCloudOrgID ` - -postMigrationBehavior $postMigrationBehavior ` - -removeMDM $removeMDM ` - -ReportStatus $ReportStatus -if ($confirmMigrationParameters) { - Write-Host "[STATUS] Migration parameters validated successfully." -} -#endregion validation -#region dataImport -switch ($dataSource) { - 'csv' { - if (-not $csvName) { - Write-Host "[status] Required script variable 'csvName' not set, exiting..." - exit 1 - } - # check if the CSV file exists - # get the CSV data from the temp directory - $discoveryCSVLocation = "C:\Windows\Temp\$csvName" - if (-not (Test-Path -Path $discoveryCSVLocation)) { - Write-Host "[status] CSV file not found, exiting..." - exit 1 - } - } -} -# Call the function and store the result (which is either an array of users or $null) -$UsersToMigrate = Get-MigrationUsersFromCsv -CsvPath $discoveryCSVLocation -systemContextBinding $systemContextBinding -#endregion validation -#### End CSV Validation #### -# Run ADMU - -# If multiple users are planned to be migrated: set the force reboot / leave domain options to false: -if ($UsersToMigrate) { - #region logoffUsers - # Query User Sessions & logoff - # get rid of the > char & break out into a CSV type object - $loggedInUsers = (quser) -replace '^>', ' ' | ForEach-Object -Process { $_ -replace '\s{2,}', ',' } - # create a list for users - $processedUsers = @() - foreach ($obj in $loggedInUsers) { - # if missing an entry for one of: USERNAME,SESSIONNAME,ID,STATE,IDLE TIME OR LOGON TIME, add a comma - if ($obj.Split(',').Count -ne 6) { - # Write-Host ($obj -replace '(^[^,]+)', '$1,') - $processedUsers += ($obj -replace '(^[^,]+)', '$1,') - } else { - # Write-Host ($obj) - $processedUsers += $obj - } - } - $UsersList = $processedUsers | ConvertFrom-Csv - Write-Host "[status] Logging off users..." - foreach ($user in $UsersList) { - if (($user.username)) { - Write-Host "[status] Logging off user: $($user.username) with ID: $($user.ID)" - # Force Logout - logoff.exe $($user.ID) - } - } - #endregion logoffUsers - if ($LeaveDomain) { - $LeaveDomain = $false - Write-Host "[status] The Domain will attempt to be un-joined for the last user migrated on this system" - $LeaveDomainAfterMigration = $true - } - # if you force with the JumpCloud command, the results will never be written to the console, we always want to reboot/shutdown with the built in commands. - if ($ForceReboot) { - $ForceReboot = $false - Write-Host "[status] The system will $postMigrationBehavior after the last user is migrated" - $ForceRebootAfterMigration = $true - } -} else { - Write-Host "[status] No users to migrate, exiting..." - exit 1 -} -#region migration -$guiJcadmuPath = "C:\Windows\Temp\gui_jcadmu.exe" # Exe path -# Download the latest ADMU GUI executable -Get-LatestADMUGUIExe -# Validate the downloaded file against the official release hash -Test-ExeSHA -filePath $guiJcadmuPath -# Execute the migration batch processing -$migrationResults = Invoke-UserMigrationBatch -UsersToMigrate $UsersToMigrate -MigrationConfig @{ - TempPassword = $TempPassword - UpdateHomePath = $UpdateHomePath - AutoBindJCUser = $AutoBindJCUser - PrimaryUser = $PrimaryUser - JumpCloudAPIKey = $JumpCloudAPIKey - BindAsAdmin = $BindAsAdmin - SetDefaultWindowsUser = $SetDefaultWindowsUser - ReportStatus = $ReportStatus - JumpCloudOrgID = $JumpCloudOrgID - systemContextBinding = $systemContextBinding - LeaveDomainAfterMigration = $LeaveDomainAfterMigration - removeMDM = $removeMDM - guiJcadmuPath = $guiJcadmuPath -} -# Display results summary -Write-Host "`nMigration Results Summary:" -Write-Host "Total Users Processed: $($migrationResults.TotalUsers)" -Write-Host "Successful Migrations: $($migrationResults.SuccessfulMigrations)" -Write-Host "Failed Migrations: $($migrationResults.FailedMigrations)" -if ($migrationResults.FailedUsers.Count -gt 0) { - Write-Host "`nFailed Users:" - foreach ($failedUser in $migrationResults.FailedUsers) { - Write-Host " - $($failedUser.JumpCloudUserName)" - } - exit 1 -} else { - # process remainder of the script: - #region restart/shutdown - # If force restart was specified, we kick off a command to initiate the restart - # this ensures that the JumpCloud commands reports a success - if ($ForceRebootAfterMigration) { - # wait 20 seconds after migration to ensure the agent has time to associate the user to the device - Start-Sleep 20 - switch ($postMigrationBehavior) { - 'shutdown' { - Write-Host "[status] Shutting down the system with PowerShell..." - Stop-Computer -ComputerName localhost -force - } - 'restart' { - Write-Host "[status] Restarting the system with PowerShell..." - Restart-Computer -ComputerName localhost -force - } - } - } - #endregion restart/shutdown -} -#endregion migration +# This script is designed to be run from the JumpCloud Console as a command. It +# will be invoked by the JumpCloud Agent on the target system. +# The script will run the ADMU command to migrate a user to JumpCloud +#### +# Update Variables Below +#### +#region variables + +# Data source for migration users: "CSV" +$dataSource = 'CSV' + +# CSV variables - only required if dataSource is set to 'CSV' +# This is the name of the CSV uploaded to the JumpCloud command +$csvName = 'jcdiscovery.csv' + +# ADMU variables +$TempPassword = 'Temp123!Temp123!' +$LeaveDomain = $true +$ForceReboot = $true +$UpdateHomePath = $false +$AutoBindJCUser = $true +$PrimaryUser = $false +$BindAsAdmin = $false # Bind user as admin (default False) +$JumpCloudAPIKey = 'YOURAPIKEY' # This field is required if the device is not eligible to use the systemContext API/ the systemContextBinding variable is set to false +$JumpCloudOrgID = 'YOURORGID' # This field is required if you use a MTP API Key +$SetDefaultWindowsUser = $true # Set the default last logged on windows user to the JumpCloud user (default True) +$ReportStatus = $false # Report status back to JumpCloud Description (default False) + +# Option to shutdown or restart +# Restarting the system is the default behavior +# If you want to shutdown the system, set the postMigrationBehavior to Shutdown +# The 'shutdown' behavior performs a shutdown of the system in a much faster manner than 'restart' which can take 5 mins form the time the command is issued +$postMigrationBehavior = 'Restart' # Restart or Shutdown + +# Option to remove the existing MDM +$removeMDM = $false # Remove the existing MDM (default false) + +# option to bind using the systemContext API +$systemContextBinding = $false # Bind using the systemContext API (default False) +# If you want to bind using the systemContext API, set the systemContextBinding to true +# The systemContextBinding option is only available for devices that have enrolled a device using a JumpCloud Administrators Connect Key +# for more information, see the JumpCloud documentation: https://docs.jumpcloud.com/api/2.0/index.html#section/System-Context +#endregion variables +#### +# Do not edit below +#### +#region functionDefinitions +function Confirm-MigrationParameter { + [CmdletBinding()] + param( + [ValidateSet('CSV', 'Description')][string]$dataSource = 'Description', + [string]$csvName = 'jcdiscovery.csv', + [string]$TempPassword = 'Temp123!Temp123!', + [bool]$LeaveDomain = $true, + [bool]$ForceReboot = $true, + [bool]$UpdateHomePath = $false, + [bool]$AutoBindJCUser = $true, + [bool]$PrimaryUser = $false, + [bool]$BindAsAdmin = $false, + [bool]$SetDefaultWindowsUser = $true, + [bool]$removeMDM = $true, + [bool]$systemContextBinding = $false, + [string]$JumpCloudAPIKey = 'YOURAPIKEY', + [string]$JumpCloudOrgID = 'YOURORGID', + [bool]$ReportStatus = $false, + [ValidateSet('Restart', 'Shutdown')][string]$postMigrationBehavior = 'Restart' + ) + if ($dataSource -eq 'CSV' -and [string]::IsNullOrWhiteSpace($csvName)) { + throw "csvName required when dataSource is 'CSV'." + } + if ([string]::IsNullOrEmpty($TempPassword)) { throw "TempPassword cannot be empty." } + if (-not $systemContextBinding) { + if ([string]::IsNullOrWhiteSpace($JumpCloudAPIKey) -or $JumpCloudAPIKey -eq 'YOURAPIKEY') { + throw "JumpCloudAPIKey required when systemContextBinding is false." + } + if ([string]::IsNullOrWhiteSpace($JumpCloudOrgID) -or $JumpCloudOrgID -eq 'YOURORGID') { + throw "JumpCloudOrgID required when systemContextBinding is false." + } + } + return $true +} +function Get-MigrationUser { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('CSV', 'Description')] + [string]$source, + [Parameter(Mandatory = $false)] + [string]$csvName = 'jcdiscovery.csv', + [Parameter(Mandatory = $true)] + [boolean]$systemContextBinding + ) + if ($source -eq 'CSV') { + return Get-MgUserFromCSV -csvName $csvName -systemContextBinding $systemContextBinding + } elseif ($source -eq 'Description') { + return Get-MgUserFromDesc -systemContextBinding $systemContextBinding + } +} + +function Get-MgUserFromCSV { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory = $true)] + [string]$csvName, + [Parameter(Mandatory = $true)] + [boolean]$systemContextBinding + ) + begin { + $csvPath = "C:\Windows\Temp\$csvName" + if (-not (Test-Path -Path $csvPath -PathType Leaf)) { + throw "CSV file not found: '$csvPath'." + } + $ImportedCSV = Import-Csv -Path $csvPath -ErrorAction Stop + } + process { + $requiredHeaders = @("LocalComputerName", "SerialNumber", "JumpCloudUserName", "SID", "LocalPath") + $csvHeaders = $ImportedCSV[0].PSObject.Properties.Name + foreach ($header in $requiredHeaders) { + if ($header -notin $csvHeaders) { throw "CSV missing header: '$header'." } + } + $usersToMigrate = New-Object System.Collections.ArrayList + $computerName = hostname + if ([string]::IsNullOrWhiteSpace($computerName)) { $computerName = $env:COMPUTERNAME } + try { + $serialNumber = (Get-WmiObject -Class Win32_BIOS).SerialNumber + } catch { + $serialNumber = (Get-CimInstance -Class Win32_BIOS).SerialNumber + } + $ValidDeviceRows = $ImportedCSV | Where-Object { + ((-not [string]::IsNullOrWhiteSpace($_.JumpCloudUserName))) -and + ($_.LocalComputerName -eq $computerName) -and ($_.SerialNumber -eq $serialNumber) + } + $duplicateSids = $ValidDeviceRows | Group-Object -Property 'SID' | Where-Object { $_.Count -gt 1 } + if ($duplicateSids) { throw "Duplicate SID found: '$($duplicateSids[0].Name)'." } + foreach ($row in $ValidDeviceRows) { + if ($systemContextBinding -and [string]::IsNullOrWhiteSpace($row.JumpCloudUserID)) { + throw "JumpCloudUserID required for systemContextBinding." + } + $requiredFields = "LocalPath", "SID" + foreach ($field in $requiredFields) { + if ([string]::IsNullOrWhiteSpace($row.$field)) { + throw "Field '$field' empty for user '$($row.JumpCloudUserName)'." + } + } + $usersToMigrate.Add([PSCustomObject]@{ + SelectedUsername = $row.SID + LocalPath = $row.LocalPath + JumpCloudUserName = $row.JumpCloudUserName + JumpCloudUserID = $row.JumpCloudUserID + }) | Out-Null + } + } + end { + if ($usersToMigrate.Count -eq 0) { throw "No users found in CSV matching this computer." } + return $usersToMigrate + } +} + +function Get-MgUserFromDesc { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param([Parameter(Mandatory = $true)][boolean]$systemContextBinding) + process { + try { + Write-Host "[status] Retrieving system description..." + $systemDescription = Get-SystemDescription -systemContextBinding $systemContextBinding + } catch { + throw "Failed to retrieve system description: $_" + } + if ([string]::IsNullOrEmpty($systemDescription)) { Write-Host "[status] System description is empty."; return $null } + try { $users = $systemDescription | ConvertFrom-Json } catch { throw "Invalid JSON: $_" } + if ($users.GetType().Name -eq 'PSCustomObject') { $users = @($users) } + $usersToMigrate = New-Object System.Collections.ArrayList + foreach ($user in $users) { + if ([string]::IsNullOrWhiteSpace($user.sid)) { continue } + if ([string]::IsNullOrWhiteSpace($user.un)) { continue } + if ($user.st -eq 'Skip') { continue } + if ($user.st -ne 'Pending') { continue } + if ($systemContextBinding -and [string]::IsNullOrWhiteSpace($user.uid)) { throw "User '$($user.un)' missing 'uid'." } + [void]$usersToMigrate.Add([PSCustomObject]@{ + SelectedUsername = $user.sid + JumpCloudUserName = $user.un + LocalPath = $user.localPath + JumpCloudUserID = $user.uid + }) + Write-Host "[status] User queued: $($user.un)" + } + if ($usersToMigrate.Count -eq 0) { Write-Host "[status] No eligible users found."; return $null } + return @(, $usersToMigrate) + } +} +function Get-LatestADMUGUIExe { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory = $false)][string]$destinationPath = "C:\Windows\Temp", + [Parameter(Mandatory = $false)][string]$GitHubToken, + [Parameter(Mandatory = $false)][int]$MaxRetries = 3, + [Parameter(Mandatory = $false)][int]$RetryDelaySeconds = 20 + ) + begin { + $owner = "TheJumpCloud" + $repo = "jumpcloud-ADMU" + $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/latest" + $headers = @{"Accept" = "application/vnd.github.v3+json" } + if (-not [string]::IsNullOrEmpty($GitHubToken)) { + $headers["Authorization"] = "Bearer $GitHubToken" + Write-Host "Using authenticated GitHub API" -ForegroundColor Cyan + } + } + process { + $attempt = 0 + $success = $false + while ($attempt -lt $MaxRetries -and -not $success) { + $attempt++ + try { + if ($attempt -gt 1) { Write-Host "Retry attempt $attempt of $MaxRetries..." -ForegroundColor Yellow } + Write-Host "Querying GitHub for latest release..." -ForegroundColor Yellow + $latestRelease = Invoke-RestMethod -Uri $apiUrl -Headers $headers -ErrorAction Stop + $exeAsset = $latestRelease.assets | Where-Object { $_.name -eq 'gui_jcadmu.exe' } + if ($exeAsset) { + $downloadUrl = $exeAsset.browser_download_url + $fileName = $exeAsset.name + $fullPath = Join-Path -Path $destinationPath -ChildPath $fileName + Write-Host "Downloading '$fileName' (Version $($latestRelease.tag_name))..." -ForegroundColor Yellow + $dlAttempt = 0 + while ($dlAttempt -lt $MaxRetries) { + $dlAttempt++ + try { + Invoke-WebRequest -Uri $downloadUrl -OutFile $fullPath -ErrorAction Stop + Write-Host "Download complete!" -ForegroundColor Green + $success = $true + break + } catch { + if ($dlAttempt -lt $MaxRetries) { + Write-Host "Download failed. Retrying in $RetryDelaySeconds seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds $RetryDelaySeconds + } else { + throw "$($_.Exception.Message)" + } + } + } + } else { + throw "Asset 'gui_jcadmu.exe' not found in release." + } + } catch { + $errorMessage = $_.Exception.Message + if ($errorMessage -match "rate limit|403") { + Write-Host "GitHub API rate limit issue." -ForegroundColor Yellow + if ([string]::IsNullOrEmpty($GitHubToken)) { + Write-Host "Hint: Use -GitHubToken for higher limits." -ForegroundColor Cyan + } + } + if ($attempt -lt $MaxRetries) { + Write-Host "Waiting $RetryDelaySeconds seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds $RetryDelaySeconds + } else { + throw "Failed after $MaxRetries attempts: $errorMessage" + } + } + } + } +} +function ConvertTo-ArgumentList { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable] + $InputHashtable + ) + $argumentList = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $InputHashtable.GetEnumerator()) { + if ($null -ne $entry.Value -and (-not ($entry.Value -is [string]) -or $entry.Value -ne '')) { + $key = $entry.Key + $value = $entry.Value + $formattedValue = if ($value -is [bool]) { + '$' + $value.ToString().ToLower() + } else { + $value + } + $argument = "-{0}:{1}" -f $key, $formattedValue + $argumentList.Add($argument) + } + } + return $argumentList +} +function Get-JcadmuGuiSha256 { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)][string]$GitHubToken, + [Parameter(Mandatory = $false)][int]$MaxRetries = 3, + [Parameter(Mandatory = $false)][int]$RetryDelaySeconds = 5 + ) + begin { + $apiUrl = "https://api.github.com/repos/TheJumpCloud/jumpcloud-ADMU/releases" + $headers = @{"Accept" = "application/vnd.github.v3+json" } + if (-not [string]::IsNullOrEmpty($GitHubToken)) { + $headers["Authorization"] = "Bearer $GitHubToken" + } + } + process { + $attempt = 0 + while ($attempt -lt $MaxRetries) { + $attempt++ + try { + if ($attempt -gt 1) { Write-Host "Retry attempt $attempt..." -ForegroundColor Yellow } + $releases = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers -ErrorAction Stop + if ($null -eq $releases -or $releases.Count -eq 0) { throw "No releases found." } + $latestRelease = $releases[0] + $targetAsset = $latestRelease.assets | Where-Object { $_.name -eq 'gui_jcadmu.exe' } + if ($targetAsset -and $targetAsset.digest -match "sha256:") { + $sha256 = $targetAsset.digest.Split(':')[1] + return [PSCustomObject]@{ TagName = $latestRelease.tag_name; SHA256 = $sha256 } + } else { + throw "SHA256 digest not found for 'gui_jcadmu.exe'." + } + } catch { + if ($_.Exception.Message -match "rate limit|403") { + Write-Host "GitHub API rate limit issue." -ForegroundColor Yellow + } + if ($attempt -lt $MaxRetries) { + Write-Host "Retrying in $RetryDelaySeconds seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds $RetryDelaySeconds + } else { + throw "Failed after $MaxRetries attempts: $($_.Exception.Message)" + } + } + } + } +} +function Test-ExeSHA { + param ( + [Parameter(Mandatory = $true)][string]$filePath, + [Parameter(Mandatory = $false)][string]$GitHubToken + ) + process { + if (-not (Test-Path -Path $filePath)) { throw "File not found: '$filePath'." } + $releaseSHA256 = if ($GitHubToken) { (Get-JcadmuGuiSha256 -GitHubToken $GitHubToken).SHA256 } else { (Get-JcadmuGuiSha256).SHA256 } + $localFileHash = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash.ToLower() + Write-Host "[status] Official SHA256: $releaseSHA256" + Write-Host "[status] Local SHA256: $localFileHash" + if ($localFileHash -eq $releaseSHA256.ToLower()) { + Write-Host "[status] SUCCESS: Hash validation passed!" + } else { + throw "[status] Hash mismatch! File differs from official release." + } + } +} +function Invoke-UserMigrationBatch { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)][array]$UsersToMigrate, + [Parameter(Mandatory = $true)][hashtable]$MigrationConfig + ) + $results = [PSCustomObject]@{ + TotalUsers = $UsersToMigrate.Count + SuccessfulMigrations = 0 + FailedMigrations = 0 + MigrationDetails = @() + SuccessfulUsers = @() + FailedUsers = @() + StartTime = Get-Date + EndTime = $null + Duration = $null + } + $lastUser = $UsersToMigrate | Select-Object -Last 1 + foreach ($user in $UsersToMigrate) { + $userStartTime = Get-Date + $isLastUser = ($user -eq $lastUser) + $leaveDomainParam = if ($isLastUser -and $MigrationConfig.LeaveDomainAfterMigration) { $true } else { $false } + $removeMDMParam = if ($isLastUser -and $MigrationConfig.RemoveMDM) { $true } else { $false } + $migrationParams = @{ + JumpCloudUserName = $user.JumpCloudUserName + SelectedUserName = $user.selectedUsername + TempPassword = $MigrationConfig.TempPassword + UpdateHomePath = $MigrationConfig.UpdateHomePath + AutoBindJCUser = $MigrationConfig.AutoBindJCUser + PrimaryUser = $MigrationConfig.PrimaryUser + JumpCloudAPIKey = $MigrationConfig.JumpCloudAPIKey + BindAsAdmin = $MigrationConfig.BindAsAdmin + SetDefaultWindowsUser = $MigrationConfig.SetDefaultWindowsUser + LeaveDomain = $leaveDomainParam + RemoveMDM = $removeMDMParam + adminDebug = $true + ReportStatus = $MigrationConfig.ReportStatus + } + if (-not [string]::IsNullOrEmpty($MigrationConfig.JumpCloudOrgID)) { + $migrationParams.Add('JumpCloudOrgID', $MigrationConfig.JumpCloudOrgID) + } + if ($MigrationConfig.systemContextBinding -eq $true) { + $migrationParams.Remove('AutoBindJCUser') + $migrationParams.Remove('JumpCloudAPIKey') + $migrationParams.Remove('JumpCloudOrgID') + $migrationParams.Add('systemContextBinding', $true) + $migrationParams.Add('JumpCloudUserID', $user.JumpCloudUserID) + } + $domainStatus = Get-DomainStatus + Write-Host "[status] Domain status - Azure/EntraID: $($domainStatus.AzureAD), Local Domain: $($domainStatus.LocalDomain)" + Write-Host "[status] Begin migration for: $($user.JumpCloudUserName)" + $migrationResult = Invoke-SingleUserMigration -User $user -MigrationParams $migrationParams -GuiJcadmuPath $MigrationConfig.guiJcadmuPath + $userResult = [PSCustomObject]@{ + JumpCloudUserName = $user.JumpCloudUserName + SelectedUsername = $user.selectedUsername + Success = $migrationResult.Success + ErrorMessage = $migrationResult.ErrorMessage + DomainStatusBefore = $domainStatus + StartTime = $userStartTime + EndTime = Get-Date + Duration = (Get-Date) - $userStartTime + IsLastUser = $isLastUser + LeaveDomain = $leaveDomainParam + } + $results.MigrationDetails += $userResult + if ($migrationResult.Success) { + $results.SuccessfulMigrations++ + $results.SuccessfulUsers += $userResult + Write-Host "[status] Migration successful: $($user.JumpCloudUserName)" + } else { + $results.FailedMigrations++ + $results.FailedUsers += $userResult + Write-Host "[status] Migration failed: $($user.JumpCloudUserName)" + } + } + $results.EndTime = Get-Date + $results.Duration = $results.EndTime - $results.StartTime + return $results +} +function Invoke-SingleUserMigration { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)][PSCustomObject]$User, + [Parameter(Mandatory = $true)][hashtable]$MigrationParams, + [Parameter(Mandatory = $true)][string]$GuiJcadmuPath + ) + if (-not (Test-Path -Path $GuiJcadmuPath)) { throw "File not found: '$GuiJcadmuPath'." } + $convertedParams = ConvertTo-ArgumentList -InputHashtable $MigrationParams + Write-Host "[status] Executing migration command..." + $result = & $GuiJcadmuPath $convertedParams + $exitCode = $LASTEXITCODE + Write-Host "[status] Migration completed with exit code: $exitCode" + Write-Host "`n[status] Migration output:" + $result | Out-Host + return [PSCustomObject]@{ + Success = ($exitCode -eq 0) + ErrorMessage = if ($exitCode -ne 0) { $result[-1] } else { $null } + } +} +function Get-DomainStatus { + [CmdletBinding()] + param() + try { + $ADStatus = dsregcmd.exe /status + $AzureADStatus = "Unknown" + $LocalDomainStatus = "Unknown" + foreach ($line in $ADStatus) { + if ($line -match "AzureADJoined : ") { $AzureADStatus = ($line.TrimStart('AzureADJoined : ')) } + if ($line -match "DomainJoined : ") { $LocalDomainStatus = ($line.TrimStart('DomainJoined : ')) } + } + return [PSCustomObject]@{ AzureAD = $AzureADStatus; LocalDomain = $LocalDomainStatus } + } catch { + Write-Host "[status] Error getting domain status: $($_.Exception.Message)" + return [PSCustomObject]@{ AzureAD = "Error"; LocalDomain = "Error" } + } +} +function Get-SystemDescription { + param([bool]$systemContextBinding) + if (-not $systemContextBinding) { throw "Description source requires systemContextBinding=`$true" } + try { + $cfg = Get-Content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' + $key = [regex]::Match($cfg, 'systemKey["]?:["]?(\w+)').Groups[1].Value + if ([string]::IsNullOrWhiteSpace($key)) { throw "No systemKey" } + $host_match = [regex]::Match($cfg, 'agentServerHost["]?:["]?agent\.(\w+)\.jumpcloud\.com').Groups[1].Value + $url = if ($host_match -eq "eu") { "https://console.jumpcloud.eu" }else { "https://console.jumpcloud.com" } + $privKey = 'C:\Program Files\JumpCloud\Plugins\Contrib\client.key' + if (-not(Test-Path $privKey)) { throw "Key not found" } + if ($PSVersionTable.PSVersion.Major -eq 5) { + if (-not([System.Management.Automation.PSTypeName]'RSAEncryption.RSAEncryptionProvider').Type) { + $rsaType = @' +using System;using System.Collections.Generic;using System.IO;using System.Net;using System.Runtime.InteropServices;using System.Security;using System.Security.Cryptography;using System.Text;namespace RSAEncryption{public class RSAEncryptionProvider{public static RSACryptoServiceProvider GetRSAProviderFromPemFile(String pemfile,SecureString p=null){const String h="-----BEGIN PUBLIC KEY-----";const String f="-----END PUBLIC KEY-----";bool isPrivate=true;byte[]pk=null;if(!File.Exists(pemfile)){throw new Exception("key not found");}string ps=File.ReadAllText(pemfile).Trim();if(ps.StartsWith(h)&&ps.EndsWith(f)){isPrivate=false;}if(isPrivate){pk=ConvertPrivateKeyToBytes(ps,p);if(pk==null){return null;}return DecodeRSAPrivateKey(pk);}return null;}static byte[]ConvertPrivateKeyToBytes(String i,SecureString p=null){const String ph="-----BEGIN RSA PRIVATE KEY-----";const String pf="-----END RSA PRIVATE KEY-----";String ps=i.Trim();byte[]bk;if(!ps.StartsWith(ph)||!ps.EndsWith(pf)){return null;}StringBuilder sb=new StringBuilder(ps);sb.Replace(ph,"");sb.Replace(pf,"");String pvs=sb.ToString().Trim();try{bk=Convert.FromBase64String(pvs);return bk;}catch(System.FormatException){StringReader sr=new StringReader(pvs);if(!sr.ReadLine().StartsWith("Proc-Type"))return null;String sl=sr.ReadLine();if(!sl.StartsWith("DEK-Info"))return null;String ss=sl.Substring(sl.IndexOf(",")+1).Trim();byte[]salt=new byte[ss.Length/2];for(int idx=0;idx', ' ' | ForEach-Object -Process { $_ -replace '\s{2,}', ',' } +$processedUsers = @() +foreach ($obj in $loggedInUsers) { + $processedUsers += if ($obj.Split(',').Count -ne 6) { $obj -replace '(^[^,]+)', '$1,' } else { $obj } +} +$UsersList = $processedUsers | ConvertFrom-Csv +Write-Host "[status] Logging off users..." +foreach ($user in $UsersList) { + if ($user.username) { + Write-Host "[status] Logging off: $($user.username) (ID: $($user.ID))" + logoff.exe $($user.ID) + } +} +#endregion logoffUsers +if ($LeaveDomain) { + $LeaveDomain = $false + Write-Host "[status] Domain will be un-joined for last user migrated" + $LeaveDomainAfterMigration = $true +} +if ($ForceReboot) { + $ForceReboot = $false + Write-Host "[status] System will $postMigrationBehavior after last user is migrated" + $ForceRebootAfterMigration = $true +} +#endregion logoffUsers (implied) +#region migration +$guiJcadmuPath = "C:\Windows\Temp\gui_jcadmu.exe" +Get-LatestADMUGUIExe +Test-ExeSHA -filePath $guiJcadmuPath +$migrationResults = Invoke-UserMigrationBatch -UsersToMigrate $UsersToMigrate -MigrationConfig @{ + TempPassword = $TempPassword + UpdateHomePath = $UpdateHomePath + AutoBindJCUser = $AutoBindJCUser + PrimaryUser = $PrimaryUser + JumpCloudAPIKey = $JumpCloudAPIKey + BindAsAdmin = $BindAsAdmin + SetDefaultWindowsUser = $SetDefaultWindowsUser + ReportStatus = $ReportStatus + JumpCloudOrgID = $JumpCloudOrgID + systemContextBinding = $systemContextBinding + LeaveDomainAfterMigration = $LeaveDomainAfterMigration + removeMDM = $removeMDM + guiJcadmuPath = $guiJcadmuPath +} +Write-Host "`nResults - Total: $($migrationResults.TotalUsers), Success: $($migrationResults.SuccessfulMigrations), Failed: $($migrationResults.FailedMigrations)" +if ($migrationResults.FailedUsers.Count -gt 0) { + Write-Host "`nFailed Users:" + foreach ($failedUser in $migrationResults.FailedUsers) { Write-Host " - $($failedUser.JumpCloudUserName)" } + exit 1 +} else { + #region restart/shutdown + if ($ForceRebootAfterMigration) { + Start-Sleep 20 + switch ($postMigrationBehavior) { + 'shutdown' { Write-Host "[status] Shutting down..."; Stop-Computer -ComputerName localhost -force } + 'restart' { Write-Host "[status] Restarting..."; Restart-Computer -ComputerName localhost -force } + } + } + #endregion restart/shutdown +} +#endregion migration exit 0 \ No newline at end of file diff --git a/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/DeviceInit/DeviceQuery.ps1 b/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/DeviceInit/DeviceQuery.ps1 new file mode 100644 index 00000000..4b06c188 --- /dev/null +++ b/jumpcloud-ADMU-Advanced-Deployment/InvokeFromJCAgent/DeviceInit/DeviceQuery.ps1 @@ -0,0 +1,446 @@ +#region functionDefinitions +function Confirm-ExecutionPolicy { + begin { + $s = $true + $c = Get-ExecutionPolicy -List + $l = ($c -split "`n" | ? { $_.Trim() -ne "" } -NotMatch '^-{5}') -notmatch 'Scope' + $p = [PSCustomObject]@{MachinePolicy = ""; UserPolicy = ""; Process = ""; CurrentUser = ""; LocalMachine = "" }; $r = '@\{Scope=(.+?); ExecutionPolicy=(.+?)\}' + } + process { + try { + foreach ($ln in $l) { + if ($ln -match $r) { + $sc = $matches[1] + $ep = $matches[2].Trim() + switch ($sc) { + "MachinePolicy" { + $p.MachinePolicy = $ep + } + "UserPolicy" { + $p.UserPolicy = $ep + } + "Process" { + $p.Process = $ep + } + "CurrentUser" { + $p.CurrentUser = $ep + } + "LocalMachine" { + $p.LocalMachine = $ep + } + } + } + } + if ($p.MachinePolicy -in "Restricted", "AllSigned", "RemoteSigned") { + throw "MachinePolicy: $($p.MachinePolicy). Change via GPO." + } + if ($p.MachinePolicy -eq "Unrestricted") { + Write-Host "[status] MachinePolicy: Unrestricted" + return $true + } + if ($p.Process -in "Restricted", "AllSigned", "RemoteSigned", "Undefined") { + Write-Host "[status] Setting Process to Bypass"; Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force + } + if ($p.LocalMachine -in "Restricted", "AllSigned", "RemoteSigned", "Undefined") { + Write-Host "[status] Setting LocalMachine to Bypass"; Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force + } + } catch { + throw "ExecutionPolicy error: $_" + return $false + } + } + end { return $s } +} + +function Get-System { + param( + [bool]$systemContextBinding, + [int]$maxRetries = 3, + [int]$retryDelaySeconds = 1 + ) + if (-not $systemContextBinding) { throw "Description source requires systemContextBinding=`$true" } + + $retryCount = 0 + $lastError = $null + + while ($retryCount -lt $maxRetries) { + try { + $cfg = Get-Content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' + $key = [regex]::Match($cfg, 'systemKey["]?:["]?(\w+)').Groups[1].Value + if ([string]::IsNullOrWhiteSpace($key)) { throw "No systemKey" } + $host_match = [regex]::Match($cfg, 'agentServerHost["]?:["]?agent\.(\w+)\.jumpcloud\.com').Groups[1].Value + $url = if ($host_match -eq "eu") { "https://console.jumpcloud.eu" }else { "https://console.jumpcloud.com" } + $privKey = 'C:\Program Files\JumpCloud\Plugins\Contrib\client.key' + if (-not(Test-Path $privKey)) { throw "Key not found" } + if ($PSVersionTable.PSVersion.Major -eq 5) { + if (-not([System.Management.Automation.PSTypeName]'RSAEncryption.RSAEncryptionProvider').Type) { + $rsaType = @' +using System;using System.Collections.Generic;using System.IO;using System.Net;using System.Runtime.InteropServices;using System.Security;using System.Security.Cryptography;using System.Text;namespace RSAEncryption{public class RSAEncryptionProvider{public static RSACryptoServiceProvider GetRSAProviderFromPemFile(String pemfile,SecureString p=null){const String h="-----BEGIN PUBLIC KEY-----";const String f="-----END PUBLIC KEY-----";bool isPrivate=true;byte[]pk=null;if(!File.Exists(pemfile)){throw new Exception("key not found");}string ps=File.ReadAllText(pemfile).Trim();if(ps.StartsWith(h)&&ps.EndsWith(f)){isPrivate=false;}if(isPrivate){pk=ConvertPrivateKeyToBytes(ps,p);if(pk==null){return null;}return DecodeRSAPrivateKey(pk);}return null;}static byte[]ConvertPrivateKeyToBytes(String i,SecureString p=null){const String ph="-----BEGIN RSA PRIVATE KEY-----";const String pf="-----END RSA PRIVATE KEY-----";String ps=i.Trim();byte[]bk;if(!ps.StartsWith(ph)||!ps.EndsWith(pf)){return null;}StringBuilder sb=new StringBuilder(ps);sb.Replace(ph,"");sb.Replace(pf,"");String pvs=sb.ToString().Trim();try{bk=Convert.FromBase64String(pvs);return bk;}catch(System.FormatException){StringReader sr=new StringReader(pvs);if(!sr.ReadLine().StartsWith("Proc-Type"))return null;String sl=sr.ReadLine();if(!sl.StartsWith("DEK-Info"))return null;String ss=sl.Substring(sl.IndexOf(",")+1).Trim();byte[]salt=new byte[ss.Length/2];for(int idx=0;idx= 1000) and AD users (not machine users) + $users = $users | Where-Object { [int64]$_.uid -ge 1000 } + $adUsers = $users | Where-Object { $_.uuid -notmatch $mSID } + + # If no AD users found and localUsers is set, use all standard users + if (($adUsers.Count -eq 0) -and $localUsers) { + Write-Host "[status] No AD users found, using standard users for testing..." + $adUsers = $users | Where-Object { $_.type -eq 'local' } + } + + # get the profileList from registry + $profileListPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" + $profileList = Get-ChildItem -Path $profileListPath + + # Create ADMU user objects + $admuUsers = New-Object system.Collections.ArrayList + foreach ($aU in $adUsers) { + try { + # Get last logon time from Windows user profile + $lastLogin = $null + try { + $userProfileData = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID = '$($aU.uuid)'" -ErrorAction SilentlyContinue + if ($userProfileData -and $userProfileData.LastUseTime) { + $lastLogin = [DateTime]$userProfileData.LastUseTime + $lastLogin = $lastLogin.ToUniversalTime().ToString('O') # or .ToString('u') + } + } catch { + Write-Host "[status] Could not retrieve last logon time for user $($aU.uuid): $_" + } + + # validate the user has not been previously migrated if the profileImagePath for that user ends in .ADMU it's been migrated already + $userProfile = $profileList | Where-Object { + $sid = $_.PSChildName + $sid -eq $aU.uuid + } + if ($userProfile) { + Write-Host "[status] Found profile for user $($aU.uuid), checking migration status..." + $profilePath = (Get-ItemProperty -Path $userProfile.PSPath).ProfileImagePath + if ($profilePath -and $profilePath.EndsWith(".ADMU")) { + Write-Host "user previously migrated, skipping user: $($aU.uuid)" + $uObj = [PSCustomObject]@{ + st = 'Complete' + msg = 'User previously migrated' + sid = $aU.uuid + localPath = $aU.directory + un = $null + uid = $null + lastLogin = $lastLogin + } + } else { + Write-Host "user not yet migrated, marking as pending: $($aU.uuid)" + $uObj = [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = $aU.uuid + localPath = $aU.directory + un = $null + uid = $null + lastLogin = $lastLogin + } + } + $admuUsers.add($uObj) | Out-Null + } else { + Write-Host "[status] No profile found for user $($aU.uuid), skipping..." + } + } catch { + Write-Host "[status] Error processing user $($aU.uuid): $_" + continue + } + } + + return @(, $admuUsers) + } catch { + throw "Failed to retrieve ADMU users: $_" + } +} +function Set-SystemDesc { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject[]]$ADMUUsers + ) + + try { + $sDescRaw = $null + $result = [PSCustomObject]@{ + MergedUsers = @() + Status = $null + Updated = $false + Error = $null + } + + # Retrieve existing system description (keep as raw string) + try { + $sRet = Get-System -systemContextBinding $true + $sDescRaw = $sRet.description + } catch { + Write-Host "[status] Could not retrieve description: $_" + } + + $merged = @() + $needsUpdate = $false + + # Check if description is null or whitespace + if ([string]::IsNullOrWhiteSpace($sDescRaw)) { + Write-Host "[status] No description found, creating..." + $merged = @($ADMUUsers) + $needsUpdate = $true + } else { + try { + # Try to parse as JSON + $eData = $sDescRaw | ConvertFrom-Json + + # Normalize to array + $eUsers = if ($eData -is [array]) { + $eData + } else { + @($eData) + } + + # Validate that objects have ADMU properties (st, msg, sid, localPath, un, uid) + $isValidADMU = $true + foreach ($item in $eUsers) { + if (-not ($item | Get-Member -Name 'st' -ErrorAction SilentlyContinue) -or + -not ($item | Get-Member -Name 'msg' -ErrorAction SilentlyContinue) -or + -not ($item | Get-Member -Name 'sid' -ErrorAction SilentlyContinue) -or + -not ($item | Get-Member -Name 'localPath' -ErrorAction SilentlyContinue) -or + -not ($item | Get-Member -Name 'un' -ErrorAction SilentlyContinue) -or + -not ($item | Get-Member -Name 'uid' -ErrorAction SilentlyContinue)) { + $isValidADMU = $false + break + } + } + + if ($isValidADMU) { + # Valid ADMU objects - merge with new users + Write-Host "[status] Merging with existing users..." + $merged = $eUsers + $newUsers = @() + foreach ($aU in $ADMUUsers) { + if (-not($eUsers | Where-Object { $_.sid -eq $aU.sid })) { + $newUsers += $aU + $needsUpdate = $true + } + } + $merged += $newUsers + + # Check for status updates on existing users (e.g., Complete status from re-running on previously migrated users) + foreach ($aU in $ADMUUsers) { + $existingUser = $merged | Where-Object { $_.sid -eq $aU.sid } + if (($existingUser -and $existingUser.st -ne $aU.st) -or ($existingUser -and $aU.st -eq 'Complete' -and $aU.msg -eq 'User previously migrated')) { + Write-Host "[status] Updating status for user $($aU.sid) from '$($existingUser.st)' to '$($aU.st)'..." + $existingUser.st = $aU.st + $existingUser.msg = $aU.msg + $needsUpdate = $true + } + } + + $merged = $merged | Where-Object { $_.st -ne 'Skip' } + } else { + # Valid JSON but not ADMU objects - replace + Write-Host "[status] Description contains non-ADMU objects, replacing..." + $merged = @($ADMUUsers) + $needsUpdate = $true + } + } catch { + # Invalid JSON - replace + Write-Host "[status] Invalid JSON, replacing..." + $merged = @($ADMUUsers) + $needsUpdate = $true + } + } + + # Update system description if needed + if ($needsUpdate -and $merged.Count -gt 0) { + Write-Host "[status] Updating description..." + Set-System -prop "Description" -payload $merged + $result.Updated = $true + } + + # Calculate ADMU status + $pending = @($merged | Where-Object { $_.st -eq 'Pending' }) + $errors = @($merged | Where-Object { $_.st -eq 'Error' }) + $skipped = @($merged | Where-Object { $_.st -eq 'Skip' }) + $complete = @($merged | Where-Object { $_.st -eq 'Complete' }) + + # Check for unknown/custom states (anything that's not Error, Pending, Complete, or Skip) + $inProgress = @($merged | Where-Object { $_.st -notin @('Error', 'Pending', 'Complete', 'Skip') }) + + $amuStatus = if ($errors.Count -gt 0) { + 'Error' + } elseif ($inProgress.Count -gt 0) { + 'InProgress' + } elseif ($pending.Count -gt 0) { + 'Pending' + } else { + 'Complete' + } + + Write-Host "[status] Setting ADMU status to $amuStatus..." + Set-System -prop "Attributes" -payload @{ "name" = "admu"; "value" = "$amuStatus" } + + $result.MergedUsers = @(, $merged) + $result.Status = $amuStatus + + return $result + } catch { + $errorMsg = "Failed to set system description: $_" + Write-Host "[status] $errorMsg" + return [PSCustomObject]@{ + MergedUsers = @() + Status = $null + Updated = $false + Error = $errorMsg + } + } +} +#endregion functionDefinitions +if (-not(Confirm-ExecutionPolicy)) { throw "ExecutionPolicy failed"; exit 1 } +# retrieve JumpCloud installation path +$admuUsers = Get-ADMUUser +$descResult = Set-SystemDesc -ADMUUsers $admuUsers +if ($descResult.Error) { + Write-Host "[ERROR] $($descResult.Error)" +} else { + Write-Host "[status] Device initialization complete." +} \ No newline at end of file diff --git a/jumpcloud-ADMU/Docs/Start-Migration.md b/jumpcloud-ADMU/Docs/Start-Migration.md index e983fe9c..0981832e 100644 --- a/jumpcloud-ADMU/Docs/Start-Migration.md +++ b/jumpcloud-ADMU/Docs/Start-Migration.md @@ -403,4 +403,4 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## RELATED LINKS -[https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Migration](https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Migration) +[https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Migration](https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Migration) diff --git a/jumpcloud-ADMU/Docs/Start-Reversion.md b/jumpcloud-ADMU/Docs/Start-Reversion.md new file mode 100644 index 00000000..b45748dd --- /dev/null +++ b/jumpcloud-ADMU/Docs/Start-Reversion.md @@ -0,0 +1,201 @@ +--- +external help file: JumpCloud.ADMU-help.xml +Module Name: JumpCloud.ADMU +online version: https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Reversion +schema: 2.0.0 +--- + +# Start-Reversion + +## SYNOPSIS +Reverts a user migration by restoring original registry files for a specified Windows SID. + +## SYNTAX + +``` +Start-Reversion [-UserSID] [[-TargetProfileImagePath] ] [-form ] [-UserName ] + [-ProfileSize ] [-DryRun] [-Force] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +This function reverts a user migration by: +1. +Looking up the account SID in the Windows registry ProfileList +2. +Identifying the .ADMU profile path +3. +Restoring original NTUSER.DAT and UsrClass.dat files from backups +4. +Preserving migrated files with _migrated suffix for rollback purposes + +## EXAMPLES + +### EXAMPLE 1 +``` +Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" +Reverts the migration for the specified user SID using the registry profile path. +``` + +### EXAMPLE 2 +``` +Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" -TargetProfileImagePath "C:\Users\john.doe" +Reverts the migration using a specific target profile path instead of the registry value. +``` + +### EXAMPLE 3 +``` +Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" -DryRun +Shows what would be reverted without making actual changes. +``` + +## PARAMETERS + +### -UserSID +The Windows Security Identifier (SID) of the user account to revert. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetProfileImagePath +The actual profile path to revert. +If not specified, will use the path from the registry. +This path will be validated to ensure it exists and is associated with the UserSID. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -form +{{ Fill form Description }} + +```yaml +Type: System.Boolean +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -UserName +{{ Fill UserName Description }} + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProfileSize +{{ Fill ProfileSize Description }} + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DryRun +Shows what actions would be performed without actually executing them. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force +Bypasses confirmation prompts and forces the revert operation. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### [PSCustomObject] Returns revert operation results with success status and details. +## NOTES + +## RELATED LINKS diff --git a/jumpcloud-ADMU/JumpCloud.ADMU.psd1 b/jumpcloud-ADMU/JumpCloud.ADMU.psd1 index 5eb1786f..0529e956 100644 --- a/jumpcloud-ADMU/JumpCloud.ADMU.psd1 +++ b/jumpcloud-ADMU/JumpCloud.ADMU.psd1 @@ -3,7 +3,7 @@ # # Generated by: JumpCloud Customer Tools Team # -# Generated on: 12/15/2025 +# Generated on: 1/15/2026 # @{ @@ -12,7 +12,7 @@ RootModule = 'JumpCloud.ADMU.psm1' # Version number of this module. -ModuleVersion = '2.11.0' +ModuleVersion = '2.12.0' # Supported PSEditions # CompatiblePSEditions = @() @@ -69,7 +69,7 @@ Description = 'Powershell Module to run JumpCloud Active Directory Migration Uti # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = @('Start-Migration' , 'Start-Reversion') +FunctionsToExport = 'Start-Migration', 'Start-Reversion' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = '*' diff --git a/jumpcloud-ADMU/Powershell/Private/DisplayForms/Build-MigrationDescription.ps1 b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Build-MigrationDescription.ps1 new file mode 100644 index 00000000..32a23710 --- /dev/null +++ b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Build-MigrationDescription.ps1 @@ -0,0 +1,120 @@ +function Build-MigrationDescription { + <# + .SYNOPSIS + Builds or updates a migration description object for the device. + + .DESCRIPTION + Creates a standardized description array with user migration status. + Handles both creating new descriptions and updating existing ones. + + .PARAMETER UserSID + The SID of the user being migrated. + + .PARAMETER MigrationUsername + The username of the user being migrated. + + .PARAMETER StatusMessage + The status message to record. + + .PARAMETER Percent + The progress percentage (or "ERROR" for failures). + + .PARAMETER LocalPath + The local user profile path. + + .PARAMETER authMethod + The authentication method used for reporting. + #> + param( + + [Parameter(Mandatory = $true)] + [string]$UserSID, + + [Parameter(Mandatory = $true)] + [string]$MigrationUsername, + + [Parameter(Mandatory = $true)] + [string]$StatusMessage, + + [Parameter(Mandatory = $true)] + [string]$Percent, + + [Parameter(Mandatory = $false)] + [string]$LocalPath, + + [Parameter(Mandatory = $false)] + [string]$authMethod + ) + + # Determine the status value based on percent + $statusValue = if ($Percent -eq "ERROR") { + "Failed" + } elseif ($Percent -eq "100%") { + "Completed" + } else { + "InProgress" + } + + # determine the auth method + switch ($authMethod) { + "systemcontextapi" { + # get the systemDescription with system context api + $sysContextResult = Invoke-SystemContextAPI -Method GET -Endpoint 'Systems' + $ExistingDescription = $sysContextResult.description + } + "apikey" { + # get the systemDescription with api key + $apiKeyResult = Invoke-SystemAPI -JcApiKey $script:JumpCloudAPIKey -jcOrgID $script:JumpCloudOrgID -systemId $script:validatedSystemID -method "GET" + $ExistingDescription = $apiKeyResult.description + } + "none" { + # if no auth method, exit function return null + Write-ToLog -Message "Error fetching existing description: $_" -Level Warning + return $null + } + } + # Initialize or update description array + if (-not [string]::IsNullOrEmpty($ExistingDescription)) { + try { + $description = $ExistingDescription | ConvertFrom-Json + # Ensure it's always an array + if ($description -isnot [array]) { $description = @($description) } + # find the userSID in the existing description + $foundUser = $description | Where-Object { $_.sid -eq $UserSID } + if ($foundUser) { + # only update the message and status + $foundUser.msg = $StatusMessage + $foundUser.st = $statusValue + } else { + # User not found in existing description, add new entry + $description += [PSCustomObject]@{ + sid = $UserSID + un = $MigrationUsername + localPath = if ($LocalPath) { $LocalPath.Replace('\', '/') } else { $null } + msg = $StatusMessage + st = $statusValue + uid = $null + lastLogin = $null + } + } + } catch { + Write-ToLog -Message "Error parsing existing system description JSON: $_" -Level Warning + # Fall through to create new description + $description = $null + } + } + # Create new description if not already initialized - always as array + if (-not $description) { + $description = @([PSCustomObject]@{ + sid = $UserSID + un = $MigrationUsername + localPath = if ($LocalPath) { $LocalPath.Replace('\', '/') } else { $null } + msg = $StatusMessage + st = $statusValue + uid = $null + lastLogin = $null + }) + } + # Ensure return is always an array (use unary comma for PowerShell 5.1 compatibility) + return @(, $description) +} diff --git a/jumpcloud-ADMU/Powershell/Private/DisplayForms/Form.ps1 b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Form.ps1 index cbdeb988..1e7621b1 100644 --- a/jumpcloud-ADMU/Powershell/Private/DisplayForms/Form.ps1 +++ b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Form.ps1 @@ -33,7 +33,7 @@ Function Show-SelectionForm { @@ -919,7 +919,7 @@ Function Show-SelectionForm { $script:ProgressBar = New-ProgressForm Write-ToProgress -form $true -ProgressBar $script:ProgressBar -status "Initializing" -username $($lvMigratedAccounts.SelectedItem.UserName) -newLocalUsername "N/A" -profileSize "Calculating" -LocalPath $localPath - Start-Reversion -UserSid $($lvMigratedAccounts.SelectedItem.SID) -form $true -LocalPath $localPath -force + Start-Reversion -UserSid $($lvMigratedAccounts.SelectedItem.SID) -form $true -TargetProfileImagePath $localPath -force } else { # MIGRATION LOGIC # Only runs if button text is NOT "Restore Profile" @@ -1096,4 +1096,4 @@ Function Show-SelectionForm { Return $FormResults } -} \ No newline at end of file +} diff --git a/jumpcloud-ADMU/Powershell/Private/DisplayForms/ProgressForm.ps1 b/jumpcloud-ADMU/Powershell/Private/DisplayForms/ProgressForm.ps1 index 54aedb09..1b46a103 100644 --- a/jumpcloud-ADMU/Powershell/Private/DisplayForms/ProgressForm.ps1 +++ b/jumpcloud-ADMU/Powershell/Private/DisplayForms/ProgressForm.ps1 @@ -22,7 +22,7 @@ function New-ProgressForm { diff --git a/jumpcloud-ADMU/Powershell/Private/DisplayForms/Write-ToProgress.ps1 b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Write-ToProgress.ps1 index 7f7fb01d..9478fc3b 100644 --- a/jumpcloud-ADMU/Powershell/Private/DisplayForms/Write-ToProgress.ps1 +++ b/jumpcloud-ADMU/Powershell/Private/DisplayForms/Write-ToProgress.ps1 @@ -67,31 +67,37 @@ function Write-ToProgress { Update-ProgressForm -progressBar $progressBar -percentComplete $PercentComplete -Status $statusMessage -logLevel $logLevel } } else { - Write-Progress -Activity "Migration Progress" -percentComplete $PercentComplete -status $statusMessage + Write-Progress -Activity "Migration Progress" -PercentComplete $PercentComplete -Status $statusMessage if ($SystemDescription.reportStatus) { if ($logLevel -eq "Error") { $statusMessage = "Error occurred during migration. Please check (C:\Windows\Temp\jcadmu.log) for more information." - $Percent = "ERROR" + $percent = "ERROR" } else { - # We use the clean string we extracted in Step 2. $percent = [math]::Round($PercentComplete) $percent = "$percent%" } Write-ToLog -Message "Migration status updated: $statusMessage" -level Info - $description = [PSCustomObject]@{ - MigrationStatus = $statusMessage - MigrationPercentage = $percent - UserSID = $SystemDescription.UserSID - MigrationUsername = $SystemDescription.MigrationUsername - UserID = $SystemDescription.UserID - DeviceID = $SystemDescription.DeviceID + # Build the migration description object + if ($SystemDescription.ValidatedSystemContextAPI) { + $authMethod = "SystemContextAPI" + } elseif ($SystemDescription.ValidatedApiKey) { + $authMethod = "ApiKey" + } else { + $authMethod = "None" } + $descriptionArray = Build-MigrationDescription -UserSID $SystemDescription.UserSID -MigrationUsername $SystemDescription.MigrationUsername -StatusMessage $statusMessage -Percent $percent -LocalPath $LocalPath -AuthMethod $authMethod + + # Send to appropriate API endpoint if ($SystemDescription.ValidatedSystemContextAPI) { - Invoke-SystemContextAPI -Method PUT -Endpoint 'Systems' -Body @{'description' = ($description | ConvertTo-Json -Compress) } | Out-Null + try { + Invoke-SystemContextAPI -Method PUT -Endpoint 'Systems' -Body @{'description' = ($descriptionArray | ConvertTo-Json) } | Out-Null + } catch { + Write-ToLog -Message "Error occurred while reporting migration progress via SystemContextAPI: $_" -Level Error + } } elseif ($SystemDescription.ValidatedApiKey) { try { - Invoke-SystemPut -JcApiKey $SystemDescription.JCApiKey -jcOrgID $SystemDescription.JumpCloudOrgID -systemId $SystemDescription.DeviceID -Body @{'description' = ($description | ConvertTo-Json -Compress) } + Invoke-SystemAPI -JcApiKey $SystemDescription.JCApiKey -jcOrgID $SystemDescription.JumpCloudOrgID -systemId $SystemDescription.DeviceID -Body @{'description' = ($descriptionArray | ConvertTo-Json) } } catch { Write-ToLog -Message "Error occurred while reporting migration progress to API: $_" -Level Error } diff --git a/jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemPut.ps1 b/jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemAPI.ps1 similarity index 68% rename from jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemPut.ps1 rename to jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemAPI.ps1 index aa45946b..c6d64444 100644 --- a/jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemPut.ps1 +++ b/jumpcloud-ADMU/Powershell/Private/JumpCloudApi/Invoke-SystemAPI.ps1 @@ -1,4 +1,4 @@ -function Invoke-SystemPut { +function Invoke-SystemAPI { param ( [Parameter(Mandatory = $true)] [string]$jcApiKey, @@ -6,8 +6,10 @@ function Invoke-SystemPut { [string]$jcOrgID, [Parameter(Mandatory = $true)] [string]$systemId, - [Parameter(Mandatory = $true)] - [object]$Body + [Parameter(Mandatory = $false)] + [object]$Body, + [Parameter(Mandatory = $false)] + [string]$method = "PUT" ) $uri = "$($global:JCUrl)/api/systems/$systemId" @@ -23,7 +25,12 @@ function Invoke-SystemPut { $retryCount = 0 do { try { - $response = Invoke-RestMethod -Uri $uri -Method Put -Headers $Headers -Body ($Body | ConvertTo-Json) + if ($Body) { + $bodyContent = $Body | ConvertTo-Json + } else { + $bodyContent = $null + } + $response = Invoke-RestMethod -Uri $uri -Method $method -Headers $Headers -Body $bodyContent $retry = $false } catch { if ($_.Exception.Message -like "*The remote name could not be resolved*") { @@ -33,7 +40,7 @@ function Invoke-SystemPut { $retry = $true } else { $ErrorMessage = $_.Exception.Message - Write-ToLog "Failed to update system: $($ErrorMessage)" -Level Warning -Step "Invoke-SystemPut" + Write-ToLog "Failed to update system: $($ErrorMessage)" -Level Warning -Step "Invoke-SystemAPI" # exit the loop $retry = $false $success = $false @@ -41,6 +48,9 @@ function Invoke-SystemPut { } } while ($retry -and $retryCount -lt $maxRetries) if ($retryCount -eq $maxRetries) { - Write-ToLog "Failed to resolve 'console.jumpcloud.com' after $maxRetries attempts." -Level Warning -Step "Invoke-SystemPut" + Write-ToLog "Failed to resolve 'console.jumpcloud.com' after $maxRetries attempts." -Level Warning -Step "Invoke-SystemAPI" + } + if ($response) { + return $response } } \ No newline at end of file diff --git a/jumpcloud-ADMU/Powershell/Private/SystemContext/Invoke-SystemContextAPI.ps1 b/jumpcloud-ADMU/Powershell/Private/SystemContext/Invoke-SystemContextAPI.ps1 index a54d8e48..b7fbba98 100644 --- a/jumpcloud-ADMU/Powershell/Private/SystemContext/Invoke-SystemContextAPI.ps1 +++ b/jumpcloud-ADMU/Powershell/Private/SystemContext/Invoke-SystemContextAPI.ps1 @@ -1,4 +1,4 @@ -Function Invoke-SystemContextAPI { +function Invoke-SystemContextAPI { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] @@ -33,12 +33,12 @@ Function Invoke-SystemContextAPI { ) begin { - # validate body + # validate body - property-based updates have special handling if ($PSBoundParameters.ContainsKey('body') -and $endpoint -ne 'systems') { throw "The 'body' parameter can only be used with the 'systems' endpoint." } try { - $config = get-content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' + $config = Get-Content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' $systemKeyRegex = 'systemKey":"(\w+)"' $systemKey = [regex]::Match($config, $systemKeyRegex).Groups[1].Value $agentServerHostRegex = '"agentServerHost":"agent\.(\w+)\.jumpcloud\.com"' @@ -49,7 +49,7 @@ Function Invoke-SystemContextAPI { Write-ToLog -Message "Determined JumpCloud Region based on agentServerHost: EU." Set-JCUrl -Region "EU" } - Default { + default { Write-ToLog -Message "Determined JumpCloud Region based on agentServerHost: US." Set-JCUrl -Region "US" } @@ -59,11 +59,16 @@ Function Invoke-SystemContextAPI { throw "Could not get systemKey from jcagent.conf or determine JumpCloud Region." } + # convert $method .toUpper() for consistency + $method = $method.ToUpper() + # Referenced Library for RSA - Switch ($PSVersionTable.PSVersion.Major) { + switch ($PSVersionTable.PSVersion.Major) { '5' { # https://github.com/wing328/PSPetstore/blob/87a2c455a7c62edcfc927ff5bf4955b287ef483b/src/PSOpenAPITools/Private/RSAEncryptionProvider.cs - Add-Type -typedef @" + if (-not([System.Management.Automation.PSTypeName]'RSAEncryption.RSAEncryptionProvider').Type) { + + Add-Type -typedef @" using System; using System.Collections.Generic; using System.IO; @@ -337,12 +342,131 @@ Function Invoke-SystemContextAPI { } "@ + } } - Default { + default { Write-Verbose "PowerShell version: $($PSVersionTable.PSVersion)" } } + # Handle property-based updates by detecting properties in body + if ($PSBoundParameters.ContainsKey('body') -and $endpoint -eq 'systems') { + # Parse body to detect which property is being updated + $detectedProperty = $null + + if ($body -is [hashtable]) { + if ($body.ContainsKey('description')) { + $detectedProperty = 'description' + $bodyContent = $body.description + } elseif ($body.ContainsKey('attributes')) { + $detectedProperty = 'attributes' + $bodyContent = $body.attributes + } + } elseif ($body -is [string]) { + try { + $parsedBody = ConvertFrom-Json $body + if ($parsedBody.PSObject.Properties.Name -contains 'description') { + $detectedProperty = 'description' + $bodyContent = $parsedBody.description + } elseif ($parsedBody.PSObject.Properties.Name -contains 'attributes') { + $detectedProperty = 'attributes' + $bodyContent = $parsedBody.attributes + } + } catch { + # If parsing fails, treat as generic body + } + } + + # Special handling for attributes - must GET current, merge, then PUT + if ($detectedProperty -eq 'attributes' -and $method -eq 'PUT') { + Write-ToLog -Message "Processing attributes update - fetching current attributes..." -Level Verbose + + $requestURL = "/api/systems/$systemKey" + $PrivateKeyFilePath = 'C:\Program Files\JumpCloud\Plugins\Contrib\client.key' + + $getMethod = "GET" + $now = (Get-Date -Date ((Get-Date).ToUniversalTime()) -UFormat "+%a, %d %h %Y %H:%M:%S GMT") + $signstr = "$getMethod $requestURL HTTP/1.1`ndate: $now" + $enc = [system.Text.Encoding]::UTF8 + $data = $enc.GetBytes($signstr) + $sha = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider + $hashResult = $sha.ComputeHash($data) + $hashAlgo = [System.Security.Cryptography.HashAlgorithmName]::SHA256 + + switch ($PSVersionTable.PSVersion.Major) { + '5' { + [System.Security.Cryptography.RSA]$rsa = [RSAEncryption.RSAEncryptionProvider]::GetRSAProviderFromPemFile($PrivateKeyFilePath) + } + default { + $pem = Get-Content -Path $PrivateKeyFilePath -Raw + $rsa = [System.Security.Cryptography.RSA]::Create() + $rsa.ImportFromPem($pem) + } + } + + $signedBytes = $rsa.SignHash($hashResult, $hashAlgo, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $signature = [Convert]::ToBase64String($signedBytes) + + $headers = @{ + Accept = "application/json" + Date = "$now" + Authorization = "Signature keyId=`"system/$($systemKey)`",headers=`"request-line date`",algorithm=`"rsa-sha256`",signature=`"$($signature)`"" + } + + try { + $currentSystem = Invoke-RestMethod -Method GET -Uri "$($global:JCUrl)$requestURL" -ContentType 'application/json' -Headers $headers + $currentAttributes = $currentSystem.attributes + Write-ToLog -Message "Current attributes retrieved successfully" -Level Verbose + } catch { + Write-ToLog -Message "Failed to retrieve current system attributes: $_" -Level Error + throw "Failed to retrieve current system attributes: $_" + } + + # Parse incoming attributes to map format + $newAttributesMap = @{} + if ($bodyContent -is [string]) { + $newAttributesMap = ConvertFrom-Json $bodyContent + } else { + $newAttributesMap = $bodyContent + } + + # Start with existing attributes + $mergedAttributes = @() + if ($null -ne $currentAttributes) { + foreach ($attr in $currentAttributes) { + $mergedAttributes += $attr + } + } + + # Update or add new attributes + foreach ($key in $newAttributesMap.Keys) { + $existingAttr = $mergedAttributes | Where-Object { $_.name -eq $key } + + # Check if the value is null - if so, remove the attribute + if ($null -eq $newAttributesMap[$key]) { + if ($existingAttr) { + $mergedAttributes = $mergedAttributes | Where-Object { $_.name -ne $key } + Write-ToLog -Message "Removed attribute: $key" -Level Verbose + } + } elseif ($existingAttr) { + # Update existing attribute - convert value to string + $existingAttr.value = [string]$newAttributesMap[$key] + Write-ToLog -Message "Updated attribute: $key = $($existingAttr.value)" -Level Verbose + } else { + # Add new attribute - convert value to string + $newAttr = @{ + name = $key + value = [string]$newAttributesMap[$key] + } + $mergedAttributes += $newAttr + Write-ToLog -Message "Added new attribute: $key = $($newAttr.value)" -Level Verbose + } + } + + # Replace body with merged attributes for the PUT request + $body = @{ attributes = $mergedAttributes } + } + } # Validate the method and endpoint combination switch ($endpoint) { @@ -389,7 +513,7 @@ Function Invoke-SystemContextAPI { if ($PSCmdlet.ParameterSetName -eq 'association') { switch ($endpoint) { "systems/associations" { - If ($method -eq 'POST') { + if ($method -eq 'POST') { $form = @{ "id" = "$id" "type" = "$type" @@ -409,7 +533,7 @@ Function Invoke-SystemContextAPI { } "systemgroups/members" { - If ($method -eq 'POST') { + if ($method -eq 'POST') { $form = @{ "id" = "$id" "type" = "$type" @@ -421,7 +545,7 @@ Function Invoke-SystemContextAPI { } } } - Default {} + default {} } } # convert body to JSON @@ -451,7 +575,7 @@ Function Invoke-SystemContextAPI { # Load the RSA Encryption Provider [System.Security.Cryptography.RSA]$rsa = [RSAEncryption.RSAEncryptionProvider]::GetRSAProviderFromPemFile($PrivateKeyFilePath) } - Default { + default { # For PowerShell 7+ we can use the native RSA $pem = Get-Content -Path $PrivateKeyFilePath -Raw $rsa = [System.Security.Cryptography.RSA]::Create() @@ -485,6 +609,7 @@ Function Invoke-SystemContextAPI { $success = $false } else { Write-ToLog "Failed to get system: $($_.Exception.Message)" -Level Warning -Step "Invoke-SystemContextAPI" + Write-Host "Failed to get system: $($_.Exception.Message)" # set success to true & exit the loop $success = $true } @@ -566,7 +691,7 @@ Function Invoke-SystemContextAPI { Write-ToLog "Failed to resolve 'console.jumpcloud.com' after $maxRetries attempts." -Level Verbose -Step "Invoke-SystemContextAPI" } } - Default { + default { 'Invalid method specified. Valid methods are: GET, PUT, POST, DELETE.' } } diff --git a/jumpcloud-ADMU/Powershell/Public/Start-Migration.ps1 b/jumpcloud-ADMU/Powershell/Public/Start-Migration.ps1 index 2b43e682..71c97f18 100644 --- a/jumpcloud-ADMU/Powershell/Public/Start-Migration.ps1 +++ b/jumpcloud-ADMU/Powershell/Public/Start-Migration.ps1 @@ -141,8 +141,6 @@ function Start-Migration { if ($invalidStringParams -or $trueBoolParams) { $allInvalidParams = $invalidStringParams + $trueBoolParams throw "The 'SystemContextBinding' parameter cannot be used with the following parameters: $($allInvalidParams -join ', '). Please remove these parameters when running SystemContextBinding and try again." - - } if (-not $PSBoundParameters.ContainsKey('JumpCloudUserID')) { throw "The 'SystemContextBinding' parameter requires the 'JumpCloudUserID' parameter to be set." @@ -177,7 +175,7 @@ function Start-Migration { $AGENT_INSTALLER_URL = "https://cdn02.jumpcloud.com/production/jcagent-msi-signed.msi" $AGENT_INSTALLER_PATH = "$windowsDrive\windows\Temp\JCADMU\jcagent-msi-signed.msi" $AGENT_CONF_PATH = "$($AGENT_PATH)\Plugins\Contrib\jcagent.conf" - $admuVersion = "2.11.0" + $admuVersion = "2.12.0" $script:JumpCloudUserID = $JumpCloudUserID $script:AdminDebug = $AdminDebug $isForm = $PSCmdlet.ParameterSetName -eq "form" @@ -647,23 +645,27 @@ function Start-Migration { } $admuTracker.install.pass = $true - Write-ToLog -Message ("Validating JumpCloud Connectivity...") # Validate JumpCloud Connectivity if Agent is installed and AutoBindJCUser is selected - if ($AgentService -and $autobindJCUser) { + if ($AgentService -and ($autobindJCUser -or $systemContextBinding)) { + Write-ToLog -Message ("Validating JumpCloud Connectivity...") -MigrationStep + # Object to pass in to the Write- - Write-ToLog -Message ("JumpCloud Agent is installed, confirming connectivity to JumpCloud...") - Write-ToProgress -ProgressBar $ProgressBar -Status "validateJCConnectivity" -form $isForm -StatusMap $admuTracker + Write-ToLog -Message ("JumpCloud Agent is installed, confirming connectivity to JumpCloud...") -level Info + Write-ToProgress -ProgressBar $ProgressBar -Status "validateJCConnectivity" -form $isForm -StatusMap $admuTracker -localPath $oldUserProfileImagePath $confirmAPIResult = Confirm-API -JcApiKey $JumpCloudAPIKey -JcOrgId $JumpCloudOrgID -SystemContextBinding $systemContextBinding Write-ToLog -Message ("Confirm-API Results:`nType: $($confirmAPIResult.type)`nValid: $($confirmAPIResult.isValid)`nSystemID: $($confirmAPIResult.ValidatedID)") if ($confirmAPIResult.type -eq 'SystemContext' -and $confirmAPIResult.isValid -and $confirmAPIResult.ValidatedID) { Write-ToLog -Message ("Validated SystemContext API with ID: $($confirmAPIResult.ValidatedID)") - $validatedSystemID = $confirmAPIResult.ValidatedID - $validatedSystemContextAPI = $true + $script:validatedSystemID = $confirmAPIResult.ValidatedID + $script:validatedSystemContextAPI = $true } elseif ($confirmAPIResult.type -eq 'API' -and $confirmAPIResult.isValid -and $confirmAPIResult.ValidatedID) { Write-ToLog -Message ("Validated JC API Key") - $validatedApiKey = $true - $validatedSystemID = $confirmAPIResult.ValidatedID + $script:validatedApiKey = $true + $script:validatedSystemID = $confirmAPIResult.ValidatedID + # set script variables for APIKEY + ORGID + $script:JumpCloudAPIKey = $JumpCloudAPIKey + $script:JumpCloudOrgID = $JumpCloudOrgID } else { Write-ToLog -Message ("Could not validate API Key or SystemContext API, please check your parameters and try again.") -Level Error Write-ToProgress -ProgressBar $ProgressBar -Status "Could not validate API Key or SystemContext API" -form $isForm -logLevel Error @@ -677,13 +679,18 @@ function Start-Migration { UserSID = $SelectedUserSID MigrationUsername = $JumpCloudUserName UserID = $script:JumpCloudUserID - DeviceID = $validatedSystemID - ValidatedSystemContextAPI = $validatedSystemContextAPI - ValidatedApiKey = $validatedApiKey - JCApiKey = $JumpCloudAPIKey - OrgID = $JumpCloudOrgID + DeviceID = $script:validatedSystemID + ValidatedSystemContextAPI = $script:validatedSystemContextAPI + ValidatedApiKey = $script:validatedApiKey + JCApiKey = $script:JumpCloudAPIKey + OrgID = $script:JumpCloudOrgID reportStatus = $reportStatus } + + if ($script:validatedSystemContextAPI) { + # update the 'admu' attribute object to inform dynamic groups that the system migration status is "InProgress" + $attributeSet = Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{'admu' = 'InProgress' } } + } } } # endRegion Validate JumpCloud Connectivity @@ -1483,7 +1490,7 @@ function Start-Migration { #region AutoBindUserToJCSystem if ($AutoBindJCUser -eq $true) { - $bindResult = Set-JCUserToSystemAssociation -JcApiKey $JumpCloudAPIKey -JcOrgId $ValidatedJumpCloudOrgId -JcUserID $script:JumpCloudUserId -BindAsAdmin $BindAsAdmin -UserAgent $UserAgent + $bindResult = Set-JCUserToSystemAssociation -JcApiKey $script:JumpCloudAPIKey -JcOrgId $ValidatedJumpCloudOrgId -JcUserID $script:JumpCloudUserId -BindAsAdmin $BindAsAdmin -UserAgent $UserAgent Write-ToProgress -ProgressBar $ProgressBar -Status "autoBind" -form $isForm -SystemDescription $systemDescription -StatusMap $admuTracker if ($bindResult) { Write-ToLog -Message:('JumpCloud automatic bind step succeeded for user ' + $JumpCloudUserName) @@ -1495,7 +1502,7 @@ function Start-Migration { $primaryUserBody = @{ "primarySystemUser.id" = $script:JumpCloudUserId } - Invoke-SystemPut -JcApiKey $JumpCloudAPIKey -JcOrgId $ValidatedJumpCloudOrgId -systemID $validatedSystemID -Body $primaryUserBody + Invoke-SystemAPI -JcApiKey $script:JumpCloudAPIKey -JcOrgId $ValidatedJumpCloudOrgId -systemID $script:validatedSystemID -Body $primaryUserBody } } else { Write-ToLog -Message:('JumpCloud automatic bind step failed, Api Key or JumpCloud username is incorrect.') -Level Warning @@ -1723,11 +1730,24 @@ function Start-Migration { Write-ToLog -Message "User $selectedUserName was migrated to $JumpCloudUserName" Write-ToLog -Message "Please login as $JumpCloudUserName to complete the migration and initialize the windows built in app setup." Write-ToProgress -ProgressBar $ProgressBar -Status "migrationComplete" -form $isForm -SystemDescription $systemDescription -StatusMap $admuTracker + if ($reportStatus) { + if ($validatedSystemContextAPI) { + # update the 'admu' attribute object to inform dynamic groups that the system migration status is "Complete" + $attributeSet = Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{'admu' = "Complete" } } + } + } } else { Write-ToLog -Message ("ADMU encountered the following errors: $($admuTracker.Keys | Where-Object { $admuTracker[$_].fail -eq $true })") -Level Warning Write-ToLog -Message ("The following migration steps were reverted to their original state: $FixedErrors") -Level Warning Write-ToLog -Message ('Script finished with errors; Log file location: ' + $jcAdmuLogFile) -Level Warning Write-ToProgress -ProgressBar $ProgressBar -Status $Script:ErrorMessage -form $isForm -logLevel "Error" -SystemDescription $systemDescription + + if ($reportStatus) { + if ($validatedSystemContextAPI) { + # update the 'admu' attribute object to inform dynamic groups that the system migration status is "Error" + $attributeSet = Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{'admu' = "Error" } } + } + } #region exeExitCode throw "JumpCloud ADMU was unable to migrate $selectedUserName" #endregion exeExitCode diff --git a/jumpcloud-ADMU/Powershell/Public/Start-Reversion.ps1 b/jumpcloud-ADMU/Powershell/Public/Start-Reversion.ps1 index 4c5fa632..b5abe6df 100644 --- a/jumpcloud-ADMU/Powershell/Public/Start-Reversion.ps1 +++ b/jumpcloud-ADMU/Powershell/Public/Start-Reversion.ps1 @@ -1,4 +1,4 @@ -Function Start-Reversion { +function Start-Reversion { <# .SYNOPSIS Reverts a user migration by restoring original registry files for a specified Windows SID. @@ -57,14 +57,12 @@ Function Start-Reversion { [Parameter(Mandatory = $false)] [string]$ProfileSize, [Parameter(Mandatory = $false)] - [string]$LocalPath, - [Parameter(Mandatory = $false)] [switch]$DryRun, [Parameter(Mandatory = $false)] [switch]$Force ) - Begin { + begin { Write-ToLog -Message "Begin Revert Migration" -MigrationStep -Level Info # Initialize result object @@ -82,7 +80,12 @@ Function Start-Reversion { RegistryUpdated = $false } $account = New-Object System.Security.Principal.SecurityIdentifier($UserSID) - $domainUser = ($account.Translate([System.Security.Principal.NTAccount])).Value + try { + $domainUser = ($account.Translate([System.Security.Principal.NTAccount])).Value + + } catch { + throw "UserSID provided could not be translated" + } # Regex pattern to identify .ADMU profile paths $admuPathPattern = '\.ADMU$' @@ -133,8 +136,7 @@ Function Start-Reversion { } } - - $profileSize = Get-ProfileSize -ProfilePath $LocalPath + $profileSize = Get-ProfileSize -ProfilePath $TargetProfileImagePath # Prefer the progress form created in Form.ps1 so updates apply to the first window the user sees if ((-not $script:ProgressBar) -and ($form)) { @@ -142,12 +144,12 @@ Function Start-Reversion { } } - Process { + process { try { #region Validate Registry and Determine Profile Path Write-ToLog -Message "Looking up profile information for SID: $UserSID" -Level Info -Step "Revert-Migration" # Casing fixed to 'revertInit' - Write-ToProgress -form $form -Status "revertInit" -ProgressBar $ProgressBar -ProfileSize $profileSize -LocalPath $LocalPath -StatusMap $revertMessageMap + Write-ToProgress -form $form -Status "revertInit" -ProgressBar $ProgressBar -ProfileSize $profileSize -LocalPath $TargetProfileImagePath -StatusMap $revertMessageMap # Get profile information from registry for validation $profileRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$UserSID" @@ -217,7 +219,7 @@ Function Start-Reversion { # Get the most recent ACL backup file path if ($aclBackupFiles.Count -eq 0) { - Throw "No ACL backup files found in directory: $aclBackupDir for SID: $UserSID. Cannot proceed with revert." + throw "No ACL backup files found in directory: $aclBackupDir for SID: $UserSID. Cannot proceed with revert." } else { Write-ToLog -Message "Found ACL backup files in $aclBackupDir" -Level Info -Step "Revert-Migration" } @@ -249,14 +251,14 @@ Function Start-Reversion { } # Validate that the UsrClass and NTUSER original files were found if ($registryFiles.Type -notcontains "NTUSER") { - Throw "No NTUser.DAT backup files found in directory: $profileImagePath for SID: $UserSID. Cannot proceed with revert." + throw "No NTUser.DAT backup files found in directory: $profileImagePath for SID: $UserSID. Cannot proceed with revert." } # UsrClass.dat files in AppData $appDataPath = Join-Path $profileImagePath "AppData\Local\Microsoft\Windows" $usrClassCurrent = Join-Path $appDataPath "UsrClass.dat" $usrClassOriginalPattern = Join-Path $appDataPath "UsrClass_original_*.dat" - $usrClassOriginalFiles = Get-ChildItem -Path $usrClassOriginalPattern -Force | Where-Object { $_.Name -match "UsrClass_original_*" } + $usrClassOriginalFiles = Get-ChildItem -Path $usrClassOriginalPattern -Force | Where-Object { $_.Name -match "UsrClass_original_*" } if ($usrClassOriginalFiles.Count -eq 0) { Write-ToLog -Message "Warning: No original UsrClass.dat backup found in $appDataPath" -Level Warning -Step "Revert-Migration" @@ -276,7 +278,7 @@ Function Start-Reversion { } if ($registryFiles.Type -notcontains "UsrClass") { - Throw "No UsrClass.dat backup files found in directory: $profileImagePath for SID: $UserSID. Cannot proceed with revert." + throw "No UsrClass.dat backup files found in directory: $profileImagePath for SID: $UserSID. Cannot proceed with revert." } #endregion Identify Registry Files to Revert @@ -519,7 +521,7 @@ Function Start-Reversion { } } - End { + end { $revertResult.EndTime = Get-Date $duration = $revertResult.EndTime - $revertResult.StartTime diff --git a/jumpcloud-ADMU/Powershell/Tests/Private/DisplayForms/Build-MigrationDescription.Acceptance.Tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Private/DisplayForms/Build-MigrationDescription.Acceptance.Tests.ps1 new file mode 100644 index 00000000..00b10d15 --- /dev/null +++ b/jumpcloud-ADMU/Powershell/Tests/Private/DisplayForms/Build-MigrationDescription.Acceptance.Tests.ps1 @@ -0,0 +1,237 @@ +Describe "Build-MigrationDescription Acceptance Tests" -Tag "InstallJC" { + BeforeAll { + # import all functions + $currentPath = $PSScriptRoot # Start from the current script's directory. + $TargetDirectory = "helperFunctions" + $FileName = "Import-AllFunctions.ps1" + while ($currentPath -ne $null) { + $filePath = Join-Path -Path $currentPath $TargetDirectory + if (Test-Path $filePath) { + # File found! Return the full path. + $helpFunctionDir = $filePath + break + } + + # Move one directory up. + $currentPath = Split-Path $currentPath -Parent + } + . "$helpFunctionDir\$fileName" + + # get the system key + $config = Get-Content "C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf" + $regex = 'systemKey\":\"(\w+)\"' + $systemKey = [regex]::Match($config, $regex).Groups[1].Value + + Connect-JCOnline -JumpCloudApiKey $env:PESTER_APIKEY -JumpCloudOrgId $env:PESTER_ORGID -force + } + Context "When the system description is null" { + BeforeEach { + # Set the current systemID description to null + Set-JCSystem -SystemID $systemKey -description $null + } + It "Sets the device description to a json list containing on object with the provided parameters and the API key auth method" { + $script:JumpCloudAPIKey = $env:PESTER_APIKEY + $script:JumpCloudOrgID = $env:PESTER_ORGID + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-1001" -MigrationUsername "testuser" -StatusMessage "Migration started" -Percent "0%" -LocalPath "C:\Users\testuser" -AuthMethod "apikey" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-1001" + $firstEntry.un | Should -Be "testuser" + $firstEntry.uid | Should -Be $null + $firstEntry.localPath | Should -Be "C:/Users/testuser" + $firstEntry.msg | Should -Be "Migration started" + $firstEntry.st | Should -Be "inProgress" + } + It "Sets the device description to a json list containing on object with the provided parameters and the SystemContextAPI auth method" { + $script:validatedSystemContextAPI = $true + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-1002" -MigrationUsername "anotheruser" -StatusMessage "Migration started" -Percent "0%" -LocalPath "C:\Users\anotheruser" -AuthMethod "systemcontextapi" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-1002" + $firstEntry.un | Should -Be "anotheruser" + $firstEntry.uid | Should -Be $null + $firstEntry.localPath | Should -Be "C:/Users/anotheruser" + $firstEntry.msg | Should -Be "Migration started" + $firstEntry.st | Should -Be "inProgress" + } + } + Context "When the system description has existing migration data for the current user" { + BeforeEach { + # Set the current systemID description to a json list with one user object + $initialDescription = @( + @{ + sid = "S-1-5-21-1234567890-123456789-123456789-2001" + un = "existinguser" + localPath = "C:/Users/existinguser" + msg = "Previous migration completed" + st = "completed" + uid = 42 + } + ) | ConvertTo-Json + + Set-JCSystem -SystemID $systemKey -description $initialDescription + } + It "Updates the existing user object when the SID matches and preserves the UID with API key auth method" { + $script:JumpCloudAPIKey = $env:PESTER_APIKEY + $script:JumpCloudOrgID = $env:PESTER_ORGID + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-2001" -MigrationUsername "existinguser" -StatusMessage "Re-migration started" -Percent "50%" -LocalPath "C:\Users\existinguser" -AuthMethod "apikey" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-2001" + $firstEntry.un | Should -Be "existinguser" + $firstEntry.uid | Should -Be 42 + $firstEntry.localPath | Should -Be "C:/Users/existinguser" + $firstEntry.msg | Should -Be "Re-migration started" + $firstEntry.st | Should -Be "inProgress" + } + It "Updates the existing user object when the SID matches and preserves the UID with SystemContextAPI auth method" { + $script:validatedSystemContextAPI = $true + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-2001" -MigrationUsername "existinguser" -StatusMessage "Re-migration started" -Percent "50%" -LocalPath "C:\Users\existinguser" -AuthMethod "systemcontextapi" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-2001" + $firstEntry.un | Should -Be "existinguser" + $firstEntry.uid | Should -Be 42 + $firstEntry.localPath | Should -Be "C:/Users/existinguser" + $firstEntry.msg | Should -Be "Re-migration started" + $firstEntry.st | Should -Be "inProgress" + } + } + Context "When the system description has existing data for multiple users" { + BeforeEach { + # Set the current systemID description to a json list with multiple user objects + $initialDescription = @( + @{ + sid = "S-1-5-21-1234567890-123456789-123456789-3001" + un = "userone" + localPath = "C:/Users/userone" + msg = "Migration completed" + st = "completed" + uid = 101 + } + @{ + sid = "S-1-5-21-1234567890-123456789-123456789-3002" + un = "usertwo" + localPath = "C:/Users/usertwo" + msg = "Migration in progress" + st = "inProgress" + uid = 102 + } + ) | ConvertTo-Json + + $setSystem = Set-JCSystem -SystemID $systemKey -description $initialDescription + $setSystemDescription = $setSystem | Select-Object -ExpandProperty Description | ConvertFrom-Json + } + It "Updates only the matching user object and preserves UIDs with API key auth method" { + $script:JumpCloudAPIKey = $env:PESTER_APIKEY + $script:JumpCloudOrgID = $env:PESTER_ORGID + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-3002" -MigrationUsername "usertwo" -StatusMessage "Migration completed" -Percent "100%" -LocalPath "C:\Users\usertwo" -AuthMethod "apikey" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 2 + + $firstEntry = $result | Where-Object { $_.sid -eq "S-1-5-21-1234567890-123456789-123456789-3001" } + $firstEntry.un | Should -Be "userone" + $firstEntry.uid | Should -Be 101 + $firstEntry.localPath | Should -Be "C:/Users/userone" + + $matchingEntry = $result | Where-Object { $_.sid -eq "S-1-5-21-1234567890-123456789-123456789-3002" } + $matchingEntry.un | Should -Be "usertwo" + $matchingEntry.uid | Should -Be 102 + $matchingEntry.localPath | Should -Be "C:/Users/usertwo" + $matchingEntry.msg | Should -Be "Migration completed" + $matchingEntry.st | Should -Be "completed" + } + It "Updates only the matching user object and preserves UIDs with SystemContextAPI auth method" { + $script:validatedSystemContextAPI = $true + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-3002" -MigrationUsername "usertwo" -StatusMessage "Migration completed" -Percent "100%" -LocalPath "C:\Users\usertwo" -AuthMethod "systemcontextapi" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 2 + + $firstEntry = $result | Where-Object { $_.sid -eq "S-1-5-21-1234567890-123456789-123456789-3001" } + $firstEntry.un | Should -Be "userone" + $firstEntry.uid | Should -Be 101 + $firstEntry.localPath | Should -Be "C:/Users/userone" + + $matchingEntry = $result | Where-Object { $_.sid -eq "S-1-5-21-1234567890-123456789-123456789-3002" } + $matchingEntry.un | Should -Be "usertwo" + $matchingEntry.uid | Should -Be 102 + $matchingEntry.localPath | Should -Be "C:/Users/usertwo" + $matchingEntry.msg | Should -Be "Migration completed" + $matchingEntry.st | Should -Be "completed" + } + } + Context "When the system description has existing data but no user SIDs" { + BeforeEach { + # Set the current systemID description to a json list with one user object without SID + Set-JCSystem -SystemID $systemKey -description "Existing non-JSON description data" + } + It "Replaces the existing description with specified data using API key auth method" { + $script:JumpCloudAPIKey = $env:PESTER_APIKEY + $script:JumpCloudOrgID = $env:PESTER_ORGID + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-1001" -MigrationUsername "testuser" -StatusMessage "Migration started" -Percent "0%" -LocalPath "C:\Users\testuser" -AuthMethod "apikey" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-1001" + $firstEntry.un | Should -Be "testuser" + $firstEntry.uid | Should -Be $null + $firstEntry.localPath | Should -Be "C:/Users/testuser" + $firstEntry.msg | Should -Be "Migration started" + $firstEntry.st | Should -Be "inProgress" + } + It "Replaces the existing description with specified data using SystemContextAPI auth method" { + $script:validatedSystemContextAPI = $true + $script:validatedSystemID = $systemKey + + $result = Build-MigrationDescription -UserSID "S-1-5-21-1234567890-123456789-123456789-1002" -MigrationUsername "anotheruser" -StatusMessage "Migration started" -Percent "0%" -LocalPath "C:\Users\anotheruser" -AuthMethod "systemcontextapi" + + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType System.Object + $result.Count | Should -Be 1 + $firstEntry = $result[0] + $firstEntry.sid | Should -Be "S-1-5-21-1234567890-123456789-123456789-1002" + $firstEntry.un | Should -Be "anotheruser" + $firstEntry.uid | Should -Be $null + $firstEntry.localPath | Should -Be "C:/Users/anotheruser" + $firstEntry.msg | Should -Be "Migration started" + $firstEntry.st | Should -Be "inProgress" + } + } +} diff --git a/jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemPut.Acceptance.Tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemAPI.Acceptance.Tests.ps1 similarity index 90% rename from jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemPut.Acceptance.Tests.ps1 rename to jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemAPI.Acceptance.Tests.ps1 index 19168e31..2f003d38 100644 --- a/jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemPut.Acceptance.Tests.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/Private/JumpCloudApi/Invoke-SystemAPI.Acceptance.Tests.ps1 @@ -1,4 +1,4 @@ -Describe 'Invoke-SystemPut' -Tags 'InstallJC' { +Describe 'Invoke-SystemAPI' -Tags 'InstallJC' { BeforeAll { # import all functions @@ -41,7 +41,7 @@ Describe 'Invoke-SystemPut' -Tags 'InstallJC' { } It 'should call the JumpCloud API with the correct parameters without an Org ID' { - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw # Get the description $systemDesc = Get-JcSdkSystem -id $systemId | Select-Object -ExpandProperty Description @@ -61,7 +61,7 @@ Describe 'Invoke-SystemPut' -Tags 'InstallJC' { It 'should include the x-org-id header when a JumpCloudOrgID is provided' { - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw $systemDesc = Get-JcSdkSystem -id $systemId | Select-Object -ExpandProperty Description $systemDesc | Should -Not -BeNullOrEmpty @@ -81,34 +81,34 @@ Describe 'Invoke-SystemPut' -Tags 'InstallJC' { Context 'When the API call fails' { It 'should catch the exception with invalid api key' { - { Invoke-SystemPut -JcApiKey 'key' -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey 'key' -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw $lastLogLine = Get-Content C:\Windows\Temp\jcadmu.log -tail 1 $lastLogLine | Should -Match "401" $lastLogLine | Should -Match "Unauthorized" } It 'Should throw when providing invalid ORGID' { - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -JcOrgID 'invalid-org-id' -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -JcOrgID 'invalid-org-id' -systemId $systemId -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw $lastLogLine = Get-Content C:\Windows\Temp\jcadmu.log -tail 1 $lastLogLine | Should -Match "400" $lastLogLine | Should -Match "Bad Request" } It 'Should throw when passing invalid body' { - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId $systemId -Body 'Invalid' | Should -Not -Throw } + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId $systemId -Body 'Invalid' | Should -Not -Throw } $lastLogLine = Get-Content C:\Windows\Temp\jcadmu.log -tail 1 $lastLogLine | Should -Match "400" $lastLogLine | Should -Match "Bad Request" } It 'Should throw when passing invalid systemId' { - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId 'Invalid' -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -JcOrgID $env:PESTER_ORGID -systemId 'Invalid' -Body @{'description' = ($description | ConvertTo-Json -Compress) } } | Should -Not -Throw $lastLogLine = Get-Content C:\Windows\Temp\jcadmu.log -tail 1 $lastLogLine | Should -Match "400" $lastLogLine | Should -Match "Bad Request" } } Context "When the API endpoint is unreachable" { - It "Should attempt to retry the Invoke-SystemPut call if the first call fails" { + It "Should attempt to retry the Invoke-SystemAPI call if the first call fails" { for ($i = 0; $i -lt 10; $i++) { Write-Host "Iteration $i" if ($i -eq 5) { @@ -118,12 +118,12 @@ Describe 'Invoke-SystemPut' -Tags 'InstallJC' { throw [System.Net.WebException]::new("The remote name could not be resolved") } -Verifiable } - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -systemId $systemId -body @{description = "helloWorld$($i)" } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -systemId $systemId -body @{description = "helloWorld$($i)" } } | Should -Not -Throw # remove the mock to invoke-RestMethod for subsequent calls Remove-Item Alias:\Invoke-RestMethod } else { # subsequent calls should succeed - { Invoke-SystemPut -JcApiKey $env:PESTER_APIKEY -systemId $systemId -body @{description = "helloWorld$($i)" } } | Should -Not -Throw + { Invoke-SystemAPI -JcApiKey $env:PESTER_APIKEY -systemId $systemId -body @{description = "helloWorld$($i)" } } | Should -Not -Throw } } } diff --git a/jumpcloud-ADMU/Powershell/Tests/Private/SystemContext/Invoke-SystemContextAPI.Acceptance.Tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Private/SystemContext/Invoke-SystemContextAPI.Acceptance.Tests.ps1 index 096d06c4..ccfbc87f 100644 --- a/jumpcloud-ADMU/Powershell/Tests/Private/SystemContext/Invoke-SystemContextAPI.Acceptance.Tests.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/Private/SystemContext/Invoke-SystemContextAPI.Acceptance.Tests.ps1 @@ -42,7 +42,7 @@ Describe "Invoke-SystemContextAPI Acceptance Tests" -Tag "InstallJC" { } . "$helpFunctionDir\$fileName" - $config = get-content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' + $config = Get-Content 'C:\Program Files\JumpCloud\Plugins\Contrib\jcagent.conf' $regex = 'systemKey\":\"(\w+)\"' $systemKey = [regex]::Match($config, $regex).Groups[1].Value } @@ -75,6 +75,50 @@ Describe "Invoke-SystemContextAPI Acceptance Tests" -Tag "InstallJC" { } } } + It "Should set a custom attribute value" { + $systemBefore = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + + { Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{$attributeKey = $attributeValue } } } | Should -Not -Throw + $systemAfter = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $foundAttribute = $systemAfter.attributes | Where-Object { $_.name -eq $attributeKey } + $foundAttribute.value | Should -Be $attributeValue + + } + It "Should update a custom attribute Value when it exists already" { + $systemBefore = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + + { Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{$attributeKey = $attributeValue } } } | Should -Not -Throw + $systemAfter = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $foundAttribute = $systemAfter.attributes | Where-Object { $_.name -eq $attributeKey } + $foundAttribute.value | Should -Be $attributeValue + + # Update the attribute value + $newAttributeValue = "UpdatedValue$(Get-Random)" + { Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{$attributeKey = $newAttributeValue } } } | Should -Not -Throw + $systemUpdated = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $foundUpdatedAttribute = $systemUpdated.attributes | Where-Object { $_.name -eq $attributeKey } + $foundUpdatedAttribute.value | Should -Be $newAttributeValue + } + It "Should clear a custom attribute when it's Value is set to Null" { + $systemBefore = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + + { Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{$attributeKey = $attributeValue } } } | Should -Not -Throw + $systemAfter = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $foundAttribute = $systemAfter.attributes | Where-Object { $_.name -eq $attributeKey } + $foundAttribute.value | Should -Be $attributeValue + + # Clear the attribute value by setting it to $null + { Invoke-SystemContextAPI -method "PUT" -endpoint "systems" -body @{attributes = @{$attributeKey = $null } } } | Should -Not -Throw + $systemUpdated = Invoke-SystemContextAPI -method "GET" -endpoint "systems" + $foundUpdatedAttribute = $systemUpdated.attributes | Where-Object { $_.name -eq $attributeKey } + $foundUpdatedAttribute | Should -BeNullOrEmpty + } } Context "User Association" { @@ -83,7 +127,7 @@ Describe "Invoke-SystemContextAPI Acceptance Tests" -Tag "InstallJC" { $Password = "Temp123!" $user = "ADMU_" + -join ((65..90) + (97..122) | Get-Random -Count 5 | ForEach-Object { [char]$_ }) # If User Exists, remove from the org - $users = Get-JCSdkUser + $users = Get-JcSdkUser if ("$($user)" -in $users.Username) { $existing = $users | Where-Object { $_.username -eq "$($user)" } Write-Host "Found JumpCloud User $($user): with id: $($existing.Id) removing..." diff --git a/jumpcloud-ADMU/Powershell/Tests/Public/Start-Migration.Acceptance.Tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Public/Start-Migration.Acceptance.Tests.ps1 index df6a67db..53667260 100644 --- a/jumpcloud-ADMU/Powershell/Tests/Public/Start-Migration.Acceptance.Tests.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/Public/Start-Migration.Acceptance.Tests.ps1 @@ -409,7 +409,7 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { ValidateUserShellFolder = $true SystemContextBinding = $false ReportStatus = $false - # JumpCloudUserID = $null + JumpCloudUserID = $null } # remove the log $logPath = "C:\Windows\Temp\jcadmu.log" @@ -432,20 +432,96 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { # create the user $GeneratedUser = New-JcSdkUser -Email:("$($userToMigrateTo)@jumpcloudadmu.com") -Username:("$($userToMigrateTo)") -Password:("$($user.password)") } - It "Report Status to JumpCloud Description" { + It "Report Status to JumpCloud Description when the systemDescription was previously empty" { + $userToMigrateFromSID = (Get-LocalUser -Name $userToMigrateFrom | Select-Object -ExpandProperty SID) + # set the system description to null/ empty + Set-JCSystem -SystemID $systemKey -description "" # set the $testCaseInput $testCaseInput.JumpCloudUserName = $userToMigrateTo - $testCaseInput.SelectedUserName = $userToMigrateFrom + $testCaseInput.SelectedUserName = $userToMigrateFromSID $testCaseInput.TempPassword = $tempPassword $testCaseInput.ReportStatus = $true + $testCaseInput.SystemContextBinding = $true + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id + # for this test remove the APIKey/ ORgID params + $testCaseInput.Remove('JumpCloudApiKey') + $testCaseInput.Remove('JumpCloudOrgID') + # Migrate the initialized user to the second username + { Start-Migration @testCaseInput } | Should -Not -Throw + + # get the system description + $system = Get-JcSdkSystem -Id $systemKey + # check the description + $system.Description | Should -Not -BeNullOrEmpty + # get the userObject by SID in systemDesc + $systemDescObj = $system.Description | ConvertFrom-Json + $matchedUser = $systemDescObj | Where-Object { $_.sid -eq $userToMigrateFromSID } + $matchedUser.sid | Should -Be $userToMigrateFromSID + $matchedUser.un | Should -Be $GeneratedUser.Username + $matchedUser.st | Should -Be "Completed" + + # validate the admu attributes were written: + $matchedAttribute = $system.attributes | Where-Object { $_.Name -eq 'admu' } + $matchedAttribute | Should -Not -BeNullOrEmpty + $matchedAttribute.Name | Should -Be 'admu' + $matchedAttribute.Value | Should -Be "Complete" + } + It "Report Status to JumpCloud Description when the systemDescription previously had data" { + $migrateUser = Get-LocalUser -Name $userToMigrateFrom + $userToMigrateFromSID = $migrateUser.SID.Value + + # set the system description to a test value + $testUsers = @( + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-1111111111-1111111111-1111111111-1001" + localPath = "C:\Users\User1" + un = "User1" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = $userToMigrateFromSID + localPath = "C:\Users\User2" + un = $userToMigrateTo + uid = "" + lastLogin = $null + } + ) | ConvertTo-Json + Set-JCSystem -SystemID $systemKey -description $testUsers + # set the $testCaseInput + $testCaseInput.JumpCloudUserName = $userToMigrateTo + $testCaseInput.SelectedUserName = $userToMigrateFromSID + $testCaseInput.TempPassword = $tempPassword + $testCaseInput.ReportStatus = $true + $testCaseInput.SystemContextBinding = $true + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id + # for this test remove the APIKey/ ORgID params + $testCaseInput.Remove('JumpCloudApiKey') + $testCaseInput.Remove('JumpCloudOrgID') # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw # get the system description - $systemDesc = Get-JcSdkSystem -Id $systemKey | Select-Object -ExpandProperty Description - # Should have this value: {"MigrationStatus":"Migration completed successfully","MigrationPercentage":100,"UserSID":"S-1-12-1-3466645622-1152519358-2404555438-459629385","MigrationUsername":"test1","UserID":"61e9de2fac31c01519042fe1","DeviceID":"6894eaab354d2a9865a44c74"} - $systemDesc | Should -Not -BeNullOrEmpty - Write-Host $systemDesc + $system = Get-JcSdkSystem -Id $systemKey + # check the description + $migratedUserSid = (Get-LocalUser -Name $userToMigrateTo | Select-Object -ExpandProperty SID) + $system.Description | Should -Not -BeNullOrEmpty + # get the userObject by SID in systemDesc + $systemDescObj = $system.Description | ConvertFrom-Json + $matchedUser = $systemDescObj | Where-Object { $_.sid -eq $userToMigrateFromSID } + $matchedUser.sid | Should -Be $userToMigrateFromSID + $matchedUser.un | Should -Be $GeneratedUser.Username + $matchedUser.st | Should -Be "Completed" + + # validate the admu attributes were written: + $matchedAttribute = $system.attributes | Where-Object { $_.Name -eq 'admu' } + $matchedAttribute | Should -Not -BeNullOrEmpty + $matchedAttribute.Name | Should -Be 'admu' + $matchedAttribute.Value | Should -Be "Complete" } It "Associates a JumpCloud user using 'AutoBindJCUser'" { # set the $testCaseInput @@ -524,7 +600,7 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { $testCaseInput.AutoBindJCUser = $false $testCaseInput.SystemContextBinding = $true # Add the JumpCloudUserID parameter - $testCaseInput.Add("JumpCloudUserID", $GeneratedUser.Id) + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw @@ -546,7 +622,7 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { $testCaseInput.PrimaryUser = $true $testCaseInput.SystemContextBinding = $false # Add the JumpCloudUserID parameter - $testCaseInput.Add("JumpCloudUserID", $GeneratedUser.Id) + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id { Start-Migration @testCaseInput } | Should -Throw } @@ -562,7 +638,7 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { $testCaseInput.PrimaryUser = $true $testCaseInput.SystemContextBinding = $true # Add the JumpCloudUserID parameter - $testCaseInput.Add("JumpCloudUserID", $GeneratedUser.Id) + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw @@ -590,7 +666,7 @@ Describe "Start-Migration Tests" -Tag "InstallJC" { $testCaseInput.BindAsAdmin = $true $testCaseInput.SystemContextBinding = $true # Add the JumpCloudUserID parameter - $testCaseInput.Add("JumpCloudUserID", $GeneratedUser.Id) + $testCaseInput.JumpCloudUserID = $GeneratedUser.Id # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw diff --git a/jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.ps1 b/jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.Tests.ps1 similarity index 65% rename from jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.ps1 rename to jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.Tests.ps1 index 51692ed3..bf50572b 100644 --- a/jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/Public/Start-Reversion.Acceptance.Tests.ps1 @@ -26,14 +26,14 @@ Describe "Start-Reversion Tests" -Tag "Migration Parameters" { # Test Setup BeforeEach { # sample password - $tempPassword = "Temp123!" + $tempPassword = "Temp123!Temp123!" # username to migrate $userToMigrateFrom = "ADMU_" + -join ((65..90) + (97..122) | Get-Random -Count 5 | ForEach-Object { [char]$_ }) # username to migrate to $userToMigrateTo = "ADMU_" + -join ((65..90) + (97..122) | Get-Random -Count 5 | ForEach-Object { [char]$_ }) # Initialize-TestUser - Initialize-TestUser -username $userToMigrateFrom -password $tempPassword + Initialize-TestUser -username $userToMigrateFrom -password $tempPassword -Reversion $true # Get the SID of the initialized user $userToMigrateFromSID = (Get-LocalUser -Name $userToMigrateFrom).SID.Value # define test case input @@ -79,13 +79,21 @@ Describe "Start-Reversion Tests" -Tag "Migration Parameters" { TargetProfileImagePath = "C:\Users\$userToMigrateFrom" } - $revertResult = Start-Reversion @reversionInput -force + Write-Host "Reversion Input Parameters: $($reversionInput | Out-String)" + + $revertResult = Start-Reversion @reversionInput -ErrorAction SilentlyContinue -Force Write-Host "Revert Result Object: $($revertResult | Out-String)" # Validate that the owner is the same as pre-migration $postReversionACL = Get-Acl -Path "C:\Users\$userToMigrateFrom" - $postReversionOwner = $postReversionACL.Owner - $postReversionOwner | Should -Be $preMigrationOwner + + # Force Get-Acl to return the SID explicitly (Avoiding name resolution issues) + $postReversionOwnerSID = $postReversionACL.GetOwner([System.Security.Principal.SecurityIdentifier]).Value + + Write-Host "Post-Reversion Owner SID: $postReversionOwnerSID" -ForegroundColor Blue + + # Compare the folder's owner SID to the known User SID + $postReversionOwnerSID | Should -Be $userToMigrateFromSID $revertResult | Should -Not -BeNullOrEmpty Write-Host "Reversion Result: $($revertResult | Out-String)" @@ -101,52 +109,37 @@ Describe "Start-Reversion Tests" -Tag "Migration Parameters" { $revertResult.FilesReverted.Count | Should -BeGreaterThan 0 { Get-LocalUser -Name $userToMigrateFrom } | Should -Not -Throw # Original User should exist - { Get-LocalUser -Name $userToMigrateTo } | Should -Throw # Created JC User should not exist after reversion + { Get-LocalUser -Name $userToMigrateTo -ErrorAction Stop } | Should -Throw } } Context "Reversion Failure" { It "Tests that the Reversion fails with an invalid SID" { - # Migrate the initialized user to the second username - { Start-Migration @testCaseInput } | Should -Not -Throw - # Revert the migration with an invalid SID $reversionInput = @{ UserSID = "S-1-5-21-0000000000-0000000000-0000000000-9999" # Invalid SID TargetProfileImagePath = "C:\Users\$userToMigrateFrom" } - { Start-Reversion @reversionInput } | Should -Throw "Profile registry path not found for SID: S-1-5-21-0000000000-0000000000-0000000000-9999" - } - It "Tests that the Reversion fails with an invalid SID NO Profile Path param" { - - # Migrate the initialized user to the second username - { Start-Migration @testCaseInput } | Should -Not -Throw - - # Revert the migration with an invalid SID - $reversionInput = @{ - UserSID = "S-1-5-21-0000000000-0000000000-0000000000-9999" # Invalid SID - } - - { Start-Reversion @reversionInput } | Should -Throw "Profile registry path not found for SID: S-1-5-21-0000000000-0000000000-0000000000-9999" + { Start-Reversion @reversionInput } | Should -Throw "UserSID provided could not be translated" } - - It "Tests that the Reversion fails with a missing profile path" { + It "Tests that the Reversion fails with an Valid SID and an invalid profilePath" { # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw - # Revert the migration with a missing profile path + # Revert the migration with a valid SID and invalid profileImagePath $reversionInput = @{ UserSID = $userToMigrateFromSID - TargetProfileImagePath = "C:\Users\NonExistentProfile" + TargetProfileImagePath = "C:\Users\InvalidProfilePath" } - { Start-Reversion @reversionInput } | Should -Throw "Profile directory does not exist: C:\Users\NonExistentProfile" + { Start-Reversion @reversionInput } | Should -Throw "Cannot validate argument on parameter 'TargetProfileImagePath'. Target profile path does not exist: C:\Users\InvalidProfilePath" } + # ACL Backup Missing Test Case - It "Tests that the Reversion handles missing ACL, NTUser, and UsrClass backup files gracefully" { + It "Tests that the Reversion handles missing ACL backup files" { # Migrate the initialized user to the second username { Start-Migration @testCaseInput } | Should -Not -Throw @@ -158,30 +151,65 @@ Describe "Start-Reversion Tests" -Tag "Migration Parameters" { # Remove any existing ACL backup files to simulate missing backup $aclBackupDir = "C:\Users\$userToMigrateFrom\AppData\Local\JumpCloudADMU" + # List all the backup files before deletion for debugging + Write-Host "Existing ACL Backup Files in $aclBackupDir before deletion:" -Foreground Yellow + Get-ChildItem -Path $aclBackupDir -Force -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host $_.FullName -Foreground Yellow + } if (Test-Path -Path $aclBackupDir) { Remove-Item -Path $aclBackupDir -Recurse -Force } + { Start-Reversion @reversionInput -ErrorAction Stop -Force } | Should -Throw "*No ACL backup files found*" + } - { Start-Reversion @reversionInput } | Should -Throw "No ACL backup files found in directory: $aclBackupDir for SID: $userToMigrateFromSID. Cannot proceed with revert." - - # Remove any existing NTUser.DAT and UsrClass.dat backup files to simulate missing backup + # NTUser Backup Missing Test Case + It "Tests that the Reversion handles missing NTUser backup files" { + # Migrate the initialized user to the second username + { Start-Migration @testCaseInput } | Should -Not -Throw + # Revert the migration + $reversionInput = @{ + UserSID = $userToMigrateFromSID + TargetProfileImagePath = "C:\Users\$userToMigrateFrom" + } + # Remove any existing NTUser.DAT backup files to simulate missing backup # Ntuser backups should look like NTUSER_original__Time $ntuserBackupPattern = "NTUSER_original_*" - $usrclassBackupPattern = "USRCLASS_original_*" $userProfileDir = "C:\Users\$userToMigrateFrom" # Remove NTUser_Original_*.DAT backup - Get-ChildItem -Path $userProfileDir -Filter $ntuserBackupPattern -force -ErrorAction SilentlyContinue | ForEach-Object { + Get-ChildItem -Path $userProfileDir -Filter $ntuserBackupPattern -Force -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -Path $_.FullName -Force } - { Start-Reversion @reversionInput } | Should -Throw "No NTUser.DAT backup files found in directory: $userProfileDir for SID: $userToMigrateFromSID. Cannot proceed with revert." + { Start-Reversion @reversionInput -Force -ErrorAction Stop } | Should -Throw "*No NTUser.DAT backup files found in directory*" + } - # Remove UsrClass_Original_*.dat backup - Get-ChildItem -Path $userProfileDir -Filter $usrclassBackupPattern -force -ErrorAction SilentlyContinue | ForEach-Object { - Remove-Item -Path $_.FullName -Force + # UsrClass Backup Missing Test Case + It "Tests that the Reversion handles missing UsrClass backup files" { + # Migrate the initialized user to the second username + { Start-Migration @testCaseInput } | Should -Not -Throw + + # Revert the migration + $reversionInput = @{ + UserSID = $userToMigrateFromSID + TargetProfileImagePath = "C:\Users\$userToMigrateFrom" } - { Start-Reversion @reversionInput } | Should -Throw "No UsrClass.dat backup files found in directory: $userProfileDir for SID: $userToMigrateFromSID. Cannot proceed with revert." + # --- FIX STARTS HERE --- + # Construct the correct path to UsrClass.dat + $userProfileDir = "C:\Users\$userToMigrateFrom" + $usrClassPath = Join-Path $userProfileDir "AppData\Local\Microsoft\Windows" + + $usrclassBackupPattern = "USRCLASS_original_*" + + # Verify path exists before trying to enumerate (optional safety) + if (Test-Path $usrClassPath) { + # Remove UsrClass_Original_*.dat backup from the CORRECT location + Get-ChildItem -Path $usrClassPath -Filter $usrclassBackupPattern -Force -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -Path $_.FullName -Force + } + } + # --- FIX ENDS HERE --- + { Start-Reversion @reversionInput -ErrorAction Stop -Force } | Should -Throw "*No UsrClass.dat backup files found in directory*" } } diff --git a/jumpcloud-ADMU/Powershell/Tests/Public/deviceQuery.tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Public/deviceQuery.tests.ps1 new file mode 100644 index 00000000..c104bab3 --- /dev/null +++ b/jumpcloud-ADMU/Powershell/Tests/Public/deviceQuery.tests.ps1 @@ -0,0 +1,388 @@ +Describe "ADMU Device Query Script Tests" -Tag "InstallJC" { + BeforeAll { + # get the remote invoke script path + $global:remoteInvoke = Join-Path $PSScriptRoot '..\..\..\..\jumpcloud-ADMU-Advanced-Deployment\InvokeFromJCAgent\DeviceInit\DeviceQuery.ps1' + if (-not (Test-Path $global:remoteInvoke)) { + throw "TEST SETUP FAILED: Script not found at the calculated path: $($global:remoteInvoke). Please check the relative path in the BeforeAll block." + } + $currentPath = $PSScriptRoot # Start from the current script's directory. + $TargetDirectory = "helperFunctions" + $FileName = "Import-AllFunctions.ps1" + while ($currentPath -ne $null) { + $filePath = Join-Path -Path $currentPath $TargetDirectory + if (Test-Path $filePath) { + # File found! Return the full path. + $helpFunctionDir = $filePath + break + } + + # Move one directory up. + $currentPath = Split-Path $currentPath -Parent + } + . "$helpFunctionDir\$fileName" + + # import the init user function: + . "$helpFunctionDir\Initialize-TestUser.ps1" + + # import functions from the remote invoke script + # get the function definitions from the script + $scriptContent = Get-Content -Path $global:remoteInvoke -Raw + $pattern = '\#region functionDefinitions[\s\S]*\#endregion functionDefinitions' + $functionMatches = [regex]::Matches($scriptContent, $pattern) + + # set the matches.value to a temp file and import the functions + $tempFunctionFile = Join-Path $PSScriptRoot 'deviceQueryFunctions.ps1' + $functionMatches.Value | Set-Content -Path $tempFunctionFile -Force + + # import the functions from the temp file + . $tempFunctionFile + } + + It "The contents of the device query script should be under 32,767 character limit" { + # Normalize line endings to LF to ensure consistent character count across platforms + $measured = $scriptContent | Measure-Object -Character + Write-Host "Character Count of Device Query Script: $($measured.Characters) | Limit: 32767" + $measured.Characters | Should -BeLessThan 32767 + } + Context "Get-System Tests" { + It "Should get the system data" { + Get-System -systemContextBinding $true | Should -Not -Be $null + } + + } + Context "Set-System Tests" { + BeforeAll { + # set the system description to null + Set-System -prop "Description" -Payload "" + # set the attributes to null + # get existing attributes + $systemData = Get-System -systemContextBinding $true + $existingAttributes = $systemData.attributes + if ($existingAttributes -ne $null) { + + foreach ($attr in $existingAttributes) { + Set-System -prop "Attributes" -Payload @{ "name" = $attr.name; "value" = $null } + } + } + } + It "Should set the system description" { + $testDescription = "Test Description $(Get-Random)" + Set-System -prop "Description" -Payload "$testDescription" + $systemData = Get-System -systemContextBinding $true + $retrievedDescription = $systemData.description | ConvertFrom-Json + $retrievedDescription | Should -Be $testDescription + } + It "Should set the system description to empty string" { + $testDescription = "" + Set-System -prop "Description" -Payload "$testDescription" + $systemData = Get-System -systemContextBinding $true + $retrievedDescription = $systemData.description | ConvertFrom-Json + $retrievedDescription | Should -Be $testDescription + } + It "Should set a system attribute when it does not exist" { + $existingSystem = Get-System -systemContextBinding $true + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + Set-System -prop "Attributes" -payload @{ "name" = "$attributeKey"; "value" = "$attributeValue" } + $updatedSystem = Get-System -systemContextBinding $true + $attribute = $updatedSystem.attributes | Where-Object { $_.name -eq $attributeKey } + $attribute.value | Should -Be $attributeValue + } + It "Should set a system attribute when it does exist" { + $existingSystem = Get-System -systemContextBinding $true + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + # set the attribute first + Set-System -prop "Attributes" -payload @{ "name" = "$attributeKey"; "value" = "$attributeValue" } + # update the attribute + $newAttributeValue = "UpdatedValue$(Get-Random)" + Set-System -prop "Attributes" -payload @{ "name" = "$attributeKey"; "value" = "$newAttributeValue" } + $updatedSystem = Get-System -systemContextBinding $true + $attribute = $updatedSystem.attributes | Where-Object { $_.name -eq $attributeKey } + $attribute.value | Should -Be $newAttributeValue + } + It "Should clear a system attribute if the payload string is null" { + $existingSystem = Get-System -systemContextBinding $true + $attributeKey = "TestAttribute$(Get-Random)" + $attributeValue = "TestValue$(Get-Random)" + # set the attribute first + Set-System -prop "Attributes" -payload @{ "name" = "$attributeKey"; "value" = "$attributeValue" } + # clear the attribute + Set-System -prop "Attributes" -payload @{ "name" = "$attributeKey"; "value" = $null } + $updatedSystem = Get-System -systemContextBinding $true + $attribute = $updatedSystem.attributes | Where-Object { $_.name -eq $attributeKey } + $attribute | Should -Be $null + } + } + Context "Get-ADMUUser Tests" { + It "Should return data when 'AD' users exist on a system" { + # in get-ADMUUser there's a function to call jcosqueryi.exe to get the AD users. In CI this wont work so we will mock that function to return test data + $admuUsers = Get-ADMUUser -localUsers + $admuUsers | Should -Not -Be $null + $admuUsers.Count | Should -BeGreaterThan 0 + + foreach ($user in $admuUsers) { + Write-Host "ADMU User with SID: $($user.sid) | localPath: $($user.localPath)" + } + } + It "Data from Get-ADMUUser should have the required properties" { + $admuUsers = Get-ADMUUser -localUsers + $requiredProperties = @("st", "msg", "sid", "localPath", "un", "uid", "lastLogin") + foreach ($user in $admuUsers) { + $MemberProperties = $user | Get-Member + foreach ($prop in $requiredProperties) { + $MemberProperties.Name | Should -Contain $prop + } + } + } + It "When some has been previously migrated, their status is set to 'complete'" { + $userToMigrateFrom = "ADMU_" + -join ((65..90) + (97..122) | Get-Random -Count 5 | ForEach-Object { [char]$_ }) + $tempPassword = "Temp123!Temp123!" + # Initialize-TestUser + Initialize-TestUser -username $userToMigrateFrom -password $tempPassword + # set the user's profileImagePath in registry to simulate a previous migration + $sid = (New-Object System.Security.Principal.NTAccount($userToMigrateFrom)).Translate([System.Security.Principal.SecurityIdentifier]).Value + $regPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$sid" + if (-not (Test-Path $regPath)) { + throw "Test Setup Failed: Registry path $regPath does not exist for user $userToMigrateFrom" + } + # append .ADMU to the existing profileImagePath to simulate migration + $existingProfilePath = (Get-ItemProperty -Path $regPath -Name "ProfileImagePath").ProfileImagePath + $newProfilePath = "$existingProfilePath.ADMU" + Set-ItemProperty -Path $regPath -Name "ProfileImagePath" -Value $newProfilePath + # get ADMU users + $admuUsers = Get-ADMUUser -localUsers + $migratedUser = $admuUsers | Where-Object { $_.sid -eq $sid } + $migratedUser | Should -Not -Be $null + $migratedUser.st | Should -Be "Complete" + $migratedUser.msg | Should -Be "User previously migrated" + } + } + Context "Set-SystemDesc Tests" { + BeforeEach { + # set the system description to null before each test + Set-System -prop "Description" -Payload "" + } + It "Data from Get-ADMUUser can be pushed to system description" { + $admuUsers = Get-ADMUUser -localUsers + $descResult = Set-SystemDesc -ADMUUsers $admuUsers + # retrieve system description + $systemData = Get-System -systemContextBinding $true + $retrievedDescription = $systemData.description | ConvertFrom-Json + foreach ($user in $admuUsers) { + $foundUser = $retrievedDescription | Where-Object { $_.sid -eq $user.sid } + $foundUser | Should -Not -Be $null + $foundUser.un | Should -Be $user.un + $foundUser.localPath | Should -Be $user.localPath + $foundUser.msg | Should -Be $user.msg + $foundUser.st | Should -Be $user.st + $foundUser.uid | Should -Be $user.uid + } + } + It "When a new user is added to the device, it's added to the system description" { + $admuUsers = Get-ADMUUser -localUsers + # add a new user to the list + $newUserSID = "S-1-5-21-999999999-999999999-999999999-1001" + $newUser = [PSCustomObject]@{ + sid = $newUserSID + un = "NewTestUser" + localPath = "C:/Users/NewTestUser" + msg = "Migration started" + st = "InProgress" + uid = $null + lastLogin = $null + } + $admuUsers += $newUser + # push to system description + $descResult = Set-SystemDesc -ADMUUsers $admuUsers + # retrieve system description + $systemData = Get-System -systemContextBinding $true + $retrievedDescription = $systemData.description | ConvertFrom-Json + $foundUser = $retrievedDescription | Where-Object { $_.sid -eq $newUserSID } + $foundUser | Should -Not -Be $null + $foundUser.un | Should -Be $newUser.un + $foundUser.localPath | Should -Be $newUser.localPath + $foundUser.msg | Should -Be $newUser.msg + $foundUser.st | Should -Be $newUser.st + $foundUser.uid | Should -Be $newUser.uid + } + It "Should return 'Pending' when there are only pending users" { + # Create users with only Pending state + $testUsers = @( + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-1111111111-1111111111-1111111111-1001" + localPath = "C:\Users\User1" + un = "User1" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-2222222222-2222222222-2222222222-1002" + localPath = "C:\Users\User2" + un = "User2" + uid = "" + lastLogin = $null + } + ) + $descResult = Set-SystemDesc -ADMUUsers $testUsers + $descResult.Status | Should -Be "Pending" + # Verify the attribute was set + $systemData = Get-System -systemContextBinding $true + $admuAttr = $systemData.attributes | Where-Object { $_.name -eq "admu" } + $admuAttr.value | Should -Be "Pending" + } + + It "Should return 'InProgress' when there are pending users and users with other states (not Error/Pending/Complete/Skip)" { + # Create users with Pending and custom states + $testUsers = @( + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-3333333333-3333333333-3333333333-1003" + localPath = "C:\Users\User3" + un = "User3" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'InProgress' + msg = 'Migrating' + sid = "S-1-5-21-4444444444-4444444444-4444444444-1004" + localPath = "C:\Users\User4" + un = "User4" + uid = "" + lastLogin = $null + } + ) + $descResult = Set-SystemDesc -ADMUUsers $testUsers + $descResult.Status | Should -Be "InProgress" + # Verify the attribute was set + $systemData = Get-System -systemContextBinding $true + $admuAttr = $systemData.attributes | Where-Object { $_.name -eq "admu" } + $admuAttr.value | Should -Be "InProgress" + } + + It "Should return 'Error' when there are pending users and error state users" { + # Create users with Pending and Error states + $testUsers = @( + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-5555555555-5555555555-5555555555-1005" + localPath = "C:\Users\User5" + un = "User5" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Error' + msg = 'Migration failed' + sid = "S-1-5-21-6666666666-6666666666-6666666666-1006" + localPath = "C:\Users\User6" + un = "User6" + uid = "" + lastLogin = $null + } + ) + $descResult = Set-SystemDesc -ADMUUsers $testUsers + $descResult.Status | Should -Be "Error" + # Verify the attribute was set + $systemData = Get-System -systemContextBinding $true + $admuAttr = $systemData.attributes | Where-Object { $_.name -eq "admu" } + $admuAttr.value | Should -Be "Error" + } + + It "Should return 'Pending' when there is a mix of pending, complete, and skip states" { + # Create users with Pending, Complete, and Skip states + $testUsers = @( + [PSCustomObject]@{ + st = 'Pending' + msg = 'Planned' + sid = "S-1-5-21-7777777777-7777777777-7777777777-1007" + localPath = "C:\Users\User7" + un = "User7" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Complete' + msg = 'Migration completed' + sid = "S-1-5-21-8888888888-8888888888-8888888888-1008" + localPath = "C:\Users\User8" + un = "User8" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Skip' + msg = 'Skipped' + sid = "S-1-5-21-9999999999-9999999999-9999999999-1009" + localPath = "C:\Users\User9" + un = "User9" + uid = "" + lastLogin = $null + } + ) + $descResult = Set-SystemDesc -ADMUUsers $testUsers + $descResult.Status | Should -Be "Pending" + # Verify the attribute was set + $systemData = Get-System -systemContextBinding $true + $admuAttr = $systemData.attributes | Where-Object { $_.name -eq "admu" } + $admuAttr.value | Should -Be "Pending" + } + + It "Should return 'Complete' when all users are complete or skip (no pending or error)" { + # Create users with only Complete and Skip states + $testUsers = @( + [PSCustomObject]@{ + st = 'Complete' + msg = 'Migration completed' + sid = "S-1-5-21-1010101010-1010101010-1010101010-1010" + localPath = "C:\Users\User10" + un = "User10" + uid = "" + lastLogin = $null + }, + [PSCustomObject]@{ + st = 'Skip' + msg = 'Skipped' + sid = "S-1-5-21-1111111111-2222222222-3333333333-1011" + localPath = "C:\Users\User11" + un = "User11" + uid = "" + lastLogin = $null + } + ) + $descResult = Set-SystemDesc -ADMUUsers $testUsers + $descResult.Status | Should -Be "Complete" + # Verify the attribute was set + $systemData = Get-System -systemContextBinding $true + $admuAttr = $systemData.attributes | Where-Object { $_.name -eq "admu" } + $admuAttr.value | Should -Be "Complete" + } + It "When the Set-Desc runs twice and no updates are made, the system description remains the same" { + $admuUsers = Get-ADMUUser -localUsers + $descResult1 = Set-SystemDesc -ADMUUsers $admuUsers + Start-Sleep -Seconds 2 + $descResult2 = Set-SystemDesc -ADMUUsers $admuUsers + $descResult1.Description | Should -Be $descResult2.Description + } + It "When the Set-Desc runs twice and updates are made, the system description is updated" { + $admuUsers = Get-ADMUUser -localUsers + $descResult1 = Set-SystemDesc -ADMUUsers $admuUsers + # modify one user's status + $admuUsers[0].st = "Complete" + $admuUsers[0].msg = "User previously migrated" + Start-Sleep -Seconds 2 + $descResult2 = Set-SystemDesc -ADMUUsers $admuUsers + $systemAfter = Get-System -systemContextBinding $true + $foundUser = ($systemAfter.Description | ConvertFrom-Json) | Where-Object { $_.sid -eq $admuUsers[0].sid } + $foundUser.st | Should -Be "Complete" + $foundUser.msg | Should -Be "User previously migrated" + } + } +} \ No newline at end of file diff --git a/jumpcloud-ADMU/Powershell/Tests/Public/invokeMigration.Tests.ps1 b/jumpcloud-ADMU/Powershell/Tests/Public/invokeMigration.Tests.ps1 index e60a7483..46cbb7bf 100644 --- a/jumpcloud-ADMU/Powershell/Tests/Public/invokeMigration.Tests.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/Public/invokeMigration.Tests.ps1 @@ -38,6 +38,12 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { . $tempFunctionFile } + It "The contents of the invoke migration script should be under 32,767 character limit" { + $measured = $scriptContent | Measure-Object -Character + Write-Host "Character Count of Invoke Script: $($measured.Characters) | Limit: 32767" + $measured.Characters | Should -BeLessThan 32767 + } + Context 'Confirm-MigrationParameter Function' { BeforeEach { $baseParams = @{ @@ -79,7 +85,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { $testParams.TempPassword = '' $testParams.JumpCloudAPIKey = 'TestAPIKEY' $testParams.JumpCloudOrgID = 'TestORGID' - { Confirm-MigrationParameter @testParams } | Should -Throw "Parameter Validation Failed: The 'TempPassword' parameter cannot be empty." + { Confirm-MigrationParameter @testParams } | Should -Throw "TempPassword cannot be empty." } } @@ -101,7 +107,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { $testParams.JumpCloudAPIKey = 'TestAPIKEY' $testParams.JumpCloudOrgID = 'TestORGID' - { Confirm-MigrationParameter @testParams } | Should -Throw "Parameter Validation Failed: When dataSource is 'csv', the 'csvName' parameter cannot be empty." + { Confirm-MigrationParameter @testParams } | Should -Throw "csvName required when dataSource is 'CSV'." } } Context "JumpCloud API Parameter Validation" { @@ -111,7 +117,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { JumpCloudAPIKey = 'YOURAPIKEY' # Default placeholder JumpCloudOrgID = 'OrgID' } - { Confirm-MigrationParameter @params } | Should -Throw "Parameter Validation Failed: 'JumpCloudAPIKey' must be set to a valid key when 'systemContextBinding' is false." + { Confirm-MigrationParameter @params } | Should -Throw "JumpCloudAPIKey required when systemContextBinding is false." } It "Should THROW when systemContextBinding is false and JumpCloudOrgID is the default placeholder" { @@ -119,7 +125,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { JumpCloudAPIKey = 'MyValidApiKey' JumpCloudOrgID = 'YOURORGID' # Default placeholder } - { Confirm-MigrationParameter @params } | Should -Throw "Parameter Validation Failed: 'JumpCloudOrgID' must be set to a valid ID when 'systemContextBinding' is false." + { Confirm-MigrationParameter @params } | Should -Throw "JumpCloudOrgID required when systemContextBinding is false." } It "Should THROW when systemContextBinding is false and JumpCloudAPIKey is empty" { @@ -127,7 +133,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { JumpCloudAPIKey = '' # Empty Key JumpCloudOrgID = 'MyValidOrgId' } - { Confirm-MigrationParameter @params } | Should -Throw "Parameter Validation Failed: 'JumpCloudAPIKey' must be set to a valid key when 'systemContextBinding' is false." + { Confirm-MigrationParameter @params } | Should -Throw "JumpCloudAPIKey required when systemContextBinding is false." } It "Should return TRUE when systemContextBinding is TRUE, even with default API parameters" { @@ -257,10 +263,11 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { } } } - Context "Get-MigrationUsersFromCsv Function" { + Context "Get-MgUserFromCSVFunction" { # Universal setup for all tests in this context BeforeAll { - $csvPath = Join-Path 'C:\Windows\Temp' 'jcDiscovery.csv' + $csvPath = Join-Path 'C:\Windows\Temp' 'jcdiscovery.csv' + $csvName = "jcdiscovery.csv" } AfterEach { @@ -272,7 +279,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { It "Should throw an error if the CSV file does not exist" { # Act & Assert - { Get-MigrationUsersFromCsv -csvPath "C:\Windows\Temp\notAFile.csv" -systemContextBinding $false } | Should -Throw "Validation Failed: The CSV file was not found at: 'C:\Windows\Temp\notAFile.csv'." + { Get-MgUserFromCSV -csvName "notAFile.csv" -systemContextBinding $true } | Should -Throw -ExpectedMessage "*CSV file not found:*" } It "Should throw an error if the CSV is missing a required header" { @@ -284,7 +291,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Throw "Validation Failed: The CSV is missing the required header: 'SID'." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw -ExpectedMessage "*CSV missing header: 'SID'*" } @@ -309,7 +316,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Throw "Validation Failed: Duplicate SID 'S-1-5-21-DUPLICATE-SID' found for LocalComputerName '$computerName'." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw "Duplicate SID found: 'S-1-5-21-DUPLICATE-SID'." } It "Should NOT throw an error if a SID is duplicated for the local device and only one row has a JumpCloudUserName" { # Arrange: The same SID appears twice for 'TEST-PC-1'. @@ -322,11 +329,11 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Not -Throw - $usersToMigrate = Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Not -Throw + $usersToMigrate = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true $usersToMigrate | Should -Not -BeNullOrEmpty - ($usersToMigrate | Select-Object -First 1).SelectedUserName | Should -Be "S-1-5-21-DUPLICATE-SID" - ($usersToMigrate | Select-Object -First 1).LocalProfilePath | Should -Be "C:\Users\j.doe" + ($usersToMigrate | Select-Object -First 1).SelectedUsername | Should -Be "S-1-5-21-DUPLICATE-SID" + ($usersToMigrate | Select-Object -First 1).LocalPath | Should -Be "C:\Users\j.doe" ($usersToMigrate | Select-Object -First 1).JumpCloudUserName | Should -Be "jane.doe" ($usersToMigrate | Select-Object -First 1).JumpCloudUserID | Should -Be "jcuser123" } @@ -341,11 +348,11 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Not -Throw - $usersToMigrate = Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Not -Throw + $usersToMigrate = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true $usersToMigrate | Should -Not -BeNullOrEmpty - ($usersToMigrate | Select-Object -First 1).SelectedUserName | Should -Be "S-1-5-21-DIFFERENT-SID" - ($usersToMigrate | Select-Object -First 1).LocalProfilePath | Should -Be "C:\Users\j.doe" + ($usersToMigrate | Select-Object -First 1).SelectedUsername | Should -Be "S-1-5-21-DIFFERENT-SID" + ($usersToMigrate | Select-Object -First 1).LocalPath | Should -Be "C:\Users\j.doe" ($usersToMigrate | Select-Object -First 1).JumpCloudUserName | Should -Be "jane.doe" ($usersToMigrate | Select-Object -First 1).JumpCloudUserID | Should -Be "jcuser123" } @@ -358,7 +365,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Throw "Validation Failed: Missing required data for field 'SID'." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw -ExpectedMessage "*Field 'SID' empty*" } It "Should throw an error if 'LocalPath' field is empty" { @@ -370,7 +377,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Throw "Validation Failed: Missing required data for field 'LocalPath'." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw -ExpectedMessage "*Field 'LocalPath' empty*" } It "Should only return rows where 'JumpCloudUserName' field is not empty" { @@ -383,8 +390,8 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - # Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false | Should -Not -BeNullOrEmpty - $result = Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false + # Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true | Should -Not -BeNullOrEmpty + $result = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true $result | Where-Object { -not [string]::IsNullOrWhiteSpace($_.JumpCloudUserName) } | Should -Not -BeNullOrEmpty $result[0].SelectedUserName | Should -Be "S-1-5-21-ABC" $result[0].JumpCloudUserName | Should -Be "bobby.jones" @@ -400,7 +407,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $true } | Should -Throw "*'JumpCloudUserID' cannot be empty when systemContextBinding is enabled. Halting script." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw -ExpectedMessage "*JumpCloudUserID required for systemContextBinding.*" } # --- Test Cases for Filtering Logic --- @@ -413,7 +420,7 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { "@ Set-Content -Path $csvPath -Value $csvContent -Force # Act & Assert - { Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false } | Should -Throw "Validation Failed: No users were found in the CSV matching this computer's name ('$computerName') and serial number ('$serialNumber')." + { Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true } | Should -Throw -ExpectedMessage "*No users found in CSV matching this computer.*" } It "Should return a filtered list of user objects for the current computer" { @@ -426,14 +433,14 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { "@ Set-Content -Path $csvPath -Value $csvContent -Force # Act - $result = Get-MigrationUsersFromCsv -csvPath $csvPath -systemContextBinding $false + $result = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $true # Assert $result | Should -Not -BeNullOrEmpty $result.Count | Should -Be 2 - $result[0].SelectedUserName | Should -Be "S-1-5-21-USER1" + $result[0].SelectedUsername | Should -Be "S-1-5-21-USER1" $result[0].JumpCloudUserName | Should -Be "user.one.jc" - $result[1].SelectedUserName | Should -Be "S-1-5-21-USER3" + $result[1].SelectedUsername | Should -Be "S-1-5-21-USER3" $result[1].JumpCloudUserID | Should -Be "jcuser3" } @@ -457,7 +464,8 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "Migration Parameters" { It "Should throw an error if the download fails" { # Mock Invoke-WebRequest to throw an error Mock Invoke-WebRequest { throw "Simulated download failure" } - { Get-LatestADMUGUIExe -destinationPath "C:\Windows\Temp" -GitHubToken $env:GITHUB_TOKEN } | Should -Throw "Operation failed after 3 attempts. Last error: Simulated download failure" + $MaxRetries = 3 + { Get-LatestADMUGUIExe -destinationPath "C:\Windows\Temp" -GitHubToken $env:GITHUB_TOKEN -maxRetries $MaxRetries -retryDelaySeconds 1 } | Should -Throw -ExpectedMessage "Failed after $MaxRetries attempts*" } AfterAll { # Clean up the test directory @@ -603,17 +611,18 @@ $userSid1,C:\Users\$userToMigrateFrom1,$env:COMPUTERNAME,$userToMigrateFrom1,$us guiJcadmuPath = $destinationPath } $systemContextBinding = $false + $csvName = "jcdiscovery.csv" } # Migration with Valid data It "Should migrate the users to JumpCloud and not throw an error" { # set the users to migrate - $UsersToMigrate = Get-MigrationUsersFromCsv -CsvPath $csvPath -systemContextBinding $systemContextBinding + $UsersToMigrate = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $systemContextBinding # Execute the migration batch processing { Invoke-UserMigrationBatch -UsersToMigrate $UsersToMigrate -MigrationConfig $migrationParams } | Should -Not -Throw } It "Should migrate the users and return the expected results" { # set the users to migrate - $UsersToMigrate = Get-MigrationUsersFromCsv -CsvPath $csvPath -systemContextBinding $systemContextBinding + $UsersToMigrate = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $systemContextBinding # Execute the migration batch processing $results = Invoke-UserMigrationBatch -UsersToMigrate $UsersToMigrate -MigrationConfig $migrationParams $results.TotalUsers | Should -Be 2 @@ -622,7 +631,7 @@ $userSid1,C:\Users\$userToMigrateFrom1,$env:COMPUTERNAME,$userToMigrateFrom1,$us } It "Should migrate multiple users even if one fails" { # set the users to migrate - $UsersToMigrate = Get-MigrationUsersFromCsv -CsvPath $csvPath -systemContextBinding $systemContextBinding + $UsersToMigrate = Get-MgUserFromCSV -csvName $csvName -systemContextBinding $systemContextBinding # Force an error by setting one of the JumpCloudUserName to an invalid user # to throw the test init the user to migrate to Initialize-TestUser -username $userToMigrateTo1 -password $tempPassword @@ -674,6 +683,247 @@ Describe "ADMU Bulk Migration Script CI Tests" -Tag "InstallJC" { # import the functions from the temp file . $tempFunctionFile } + Context "Get-MgUserFromDesc Function" { + # Mock Get-SystemDescription to avoid actual API calls + BeforeEach { + # Store the original Get-SystemDescription function if it exists + if (Test-Path function:Get-SystemDescription) { + $originalFunc = Get-Item function:Get-SystemDescription + } + } + + AfterEach { + # Restore original function if it existed + if ($null -ne $originalFunc) { + $functionDefinition = Get-Content function:$originalFunc + Invoke-Expression "function Get-SystemDescription { $functionDefinition }" + } + } + + Context "Error Handling" { + It "Should THROW when Get-SystemDescription fails with error" { + # Arrange + function Get-SystemDescription { + throw "API connection failed" + } + + # Act & Assert + { Get-MgUserFromDesc -systemContextBinding $true } | Should -Throw -ExpectedMessage "*Failed to retrieve system description:*" + } + + It "Should THROW when systemDescription JSON is invalid" { + # Arrange + function Get-SystemDescription { + return "{ invalid json }" + } + + # Act & Assert + { Get-MgUserFromDesc -systemContextBinding $true } | Should -Throw -ExpectedMessage "*Invalid JSON:*" + } + } + + Context "Empty and Null Handling" { + It "Should return NULL when system description is empty string" { + # Arrange + function Get-SystemDescription { + return "" + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result | Should -Be $null + } + + It "Should return NULL when system description is NULL" { + # Arrange + function Get-SystemDescription { + return $null + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result | Should -Be $null + } + + It "Should return NULL when all users are filtered out" { + # Arrange - JSON with users but all have non-Pending status + function Get-SystemDescription { + return '[{"sid":"S-1-5-21-TEST","un":"user1","st":"Completed"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result | Should -Be $null + } + } + + Context "User Filtering Logic" { + It "Should filter out users with empty SID" { + # Arrange + Mock Get-SystemDescription { + return '[{"sid":"","un":"user1","st":"Pending","uid":"jcuid1"},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user2" + } + + It "Should filter out users with empty username (un)" { + # Arrange + Mock Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"","st":"Pending","uid":"jcuid1"},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user2" + } + + It "Should skip users with status 'Skip'" { + # Arrange + Mock Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"user1","st":"Skip","uid":"jcuid1"},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user2" + } + + It "Should only include users with status 'Pending'" { + # Arrange + Mock Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"user1","st":"Completed","uid":"jcuid1"},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"},{"sid":"S-1-5-21-DEF","un":"user3","st":"Failed","uid":"jcuid3"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user2" + $result[0].SelectedUsername | Should -Be "S-1-5-21-XYZ" + } + } + + Context "systemContextBinding Validation" { + It "Should THROW when systemContextBinding is true and user missing uid" { + # Arrange + Mock Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"user1","st":"Pending","uid":""},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"}]' + } + + # Act & Assert + { Get-MgUserFromDesc -systemContextBinding $true } | Should -Throw "User 'user1' missing 'uid'." + } + + } + + Context "JSON Parsing - Single vs Array" { + It "Should convert single PSCustomObject to array" { + # Arrange - Simulate JSON that returns single object + function Get-SystemDescription { + return '{"sid":"S-1-5-21-ABC","un":"user1","st":"Pending","uid":"jcuid1"}' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result -is [System.Collections.ArrayList] | Should -Be $true + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user1" + } + + It "Should handle JSON array correctly" { + # Arrange + function Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"user1","st":"Pending","uid":"jcuid1"},{"sid":"S-1-5-21-XYZ","un":"user2","st":"Pending","uid":"jcuid2"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 2 + $result[0].JumpCloudUserName | Should -Be "user1" + $result[1].JumpCloudUserName | Should -Be "user2" + } + } + + Context "Output Structure" { + It "Should return objects with correct properties" { + # Arrange + function Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"jane.doe","localPath":"C:\\Users\\jane.doe","st":"Pending","uid":"jcuser123"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result[0].SelectedUsername | Should -Be "S-1-5-21-ABC" + $result[0].JumpCloudUserName | Should -Be "jane.doe" + $result[0].LocalPath | Should -Be "C:\Users\jane.doe" + $result[0].JumpCloudUserID | Should -Be "jcuser123" + $result[0].PSObject.Properties.Name.Count | Should -Be 4 + } + + It "Should return ArrayList type" { + # Arrange + function Get-SystemDescription { + return '[{"sid":"S-1-5-21-ABC","un":"user1","st":"Pending","uid":"jcuid1"}]' + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.GetType().Name | Should -Be "ArrayList" + } + } + + Context "Mixed Scenarios" { + It "Should filter correctly with mixed valid and invalid users" { + # Arrange + function Get-SystemDescription { + $json = @' +[ + {"sid":"","un":"user1","st":"Pending","uid":"jcuid1"}, + {"sid":"S-1-5-21-ABC","un":"user2","st":"Skip","uid":"jcuid2"}, + {"sid":"S-1-5-21-XYZ","un":"","st":"Pending","uid":"jcuid3"}, + {"sid":"S-1-5-21-DEF","un":"user3","st":"Completed","uid":"jcuid4"}, + {"sid":"S-1-5-21-GHI","un":"user4","st":"Pending","uid":"jcuid5"} +] +'@ + return $json + } + + # Act + $result = Get-MgUserFromDesc -systemContextBinding $true + + # Assert + $result.Count | Should -Be 1 + $result[0].JumpCloudUserName | Should -Be "user4" + } + } + } Context "JumpCloud Agent Required Migrations" { # Validate the JumpCloud Agent is installed BeforeAll { diff --git a/jumpcloud-ADMU/Powershell/Tests/helperFunctions/Initialize-TestUser.ps1 b/jumpcloud-ADMU/Powershell/Tests/helperFunctions/Initialize-TestUser.ps1 index b07d1874..76a2dd9e 100644 --- a/jumpcloud-ADMU/Powershell/Tests/helperFunctions/Initialize-TestUser.ps1 +++ b/jumpcloud-ADMU/Powershell/Tests/helperFunctions/Initialize-TestUser.ps1 @@ -8,7 +8,10 @@ Function Initialize-TestUser { $UserName, [Parameter()] [System.String] - $Password + $Password, + [Parameter()] + [System.Boolean] + $Reversion ) Process { Write-Host "Building Profile for $($UserName)" @@ -16,7 +19,13 @@ Function Initialize-TestUser { Remove-LocalUserProfile $($UserName) } $newUserPassword = ConvertTo-SecureString -String "$($Password)" -AsPlainText -Force - New-localUser -Name "$($UserName)" -password $newUserPassword -ErrorVariable userExitCode -Description "Created By JumpCloud ADMU" + if ($Reversion) { + # Added this for the reversion test since Start-Reversion removes users with JC description even though it is not needed for the tests + New-localUser -Name "$($UserName)" -password $newUserPassword -ErrorVariable userExitCode + } else { + New-localUser -Name "$($UserName)" -password $newUserPassword -ErrorVariable userExitCode -Description "Created By JumpCloud ADMU" + } + if ($userExitCode) { Write-ToLog -Message:("$userExitCode") Write-ToLog -Message:("The user: $($UserName) could not be created, exiting") diff --git a/jumpcloud-ADMU/en-US/JumpCloud.ADMU-help.xml b/jumpcloud-ADMU/en-US/JumpCloud.ADMU-help.xml index f74b9135..04364d5f 100644 --- a/jumpcloud-ADMU/en-US/JumpCloud.ADMU-help.xml +++ b/jumpcloud-ADMU/en-US/JumpCloud.ADMU-help.xml @@ -526,4 +526,308 @@ + + + Start-Reversion + Start + Reversion + + Reverts a user migration by restoring original registry files for a specified Windows SID. + + + + This function reverts a user migration by: 1. Looking up the account SID in the Windows registry ProfileList 2. Identifying the .ADMU profile path 3. Restoring original NTUSER.DAT and UsrClass.dat files from backups 4. Preserving migrated files with _migrated suffix for rollback purposes + + + + Start-Reversion + + UserSID + + The Windows Security Identifier (SID) of the user account to revert. + + System.String + + System.String + + + None + + + TargetProfileImagePath + + The actual profile path to revert. If not specified, will use the path from the registry. This path will be validated to ensure it exists and is associated with the UserSID. + + System.String + + System.String + + + None + + + form + + {{ Fill form Description }} + + System.Boolean + + System.Boolean + + + False + + + UserName + + {{ Fill UserName Description }} + + System.String + + System.String + + + None + + + ProfileSize + + {{ Fill ProfileSize Description }} + + System.String + + System.String + + + None + + + LocalPath + + {{ Fill LocalPath Description }} + + System.String + + System.String + + + None + + + DryRun + + Shows what actions would be performed without actually executing them. + + + System.Management.Automation.SwitchParameter + + + False + + + Force + + Bypasses confirmation prompts and forces the revert operation. + + + System.Management.Automation.SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + System.Management.Automation.SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + False + + + + + + UserSID + + The Windows Security Identifier (SID) of the user account to revert. + + System.String + + System.String + + + None + + + TargetProfileImagePath + + The actual profile path to revert. If not specified, will use the path from the registry. This path will be validated to ensure it exists and is associated with the UserSID. + + System.String + + System.String + + + None + + + form + + {{ Fill form Description }} + + System.Boolean + + System.Boolean + + + False + + + UserName + + {{ Fill UserName Description }} + + System.String + + System.String + + + None + + + ProfileSize + + {{ Fill ProfileSize Description }} + + System.String + + System.String + + + None + + + LocalPath + + {{ Fill LocalPath Description }} + + System.String + + System.String + + + None + + + DryRun + + Shows what actions would be performed without actually executing them. + + System.Management.Automation.SwitchParameter + + System.Management.Automation.SwitchParameter + + + False + + + Force + + Bypasses confirmation prompts and forces the revert operation. + + System.Management.Automation.SwitchParameter + + System.Management.Automation.SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + System.Management.Automation.SwitchParameter + + System.Management.Automation.SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + System.Management.Automation.SwitchParameter + + System.Management.Automation.SwitchParameter + + + False + + + + + + + [PSCustomObject] Returns revert operation results with success status and details. + + + + + + + + + + + + + + -------------------------- EXAMPLE 1 -------------------------- + Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" +Reverts the migration for the specified user SID using the registry profile path. + + + + + + -------------------------- EXAMPLE 2 -------------------------- + Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" -TargetProfileImagePath "C:\Users\john.doe" +Reverts the migration using a specific target profile path instead of the registry value. + + + + + + -------------------------- EXAMPLE 3 -------------------------- + Start-Reversion -UserSID "S-1-5-21-123456789-1234567890-123456789-1001" -DryRun +Shows what would be reverted without making actual changes. + + + + + + + + Online Version: + https://github.com/TheJumpCloud/jumpcloud-ADMU/wiki/Start-Reversion + + + \ No newline at end of file