#requires -Version 5.1 # Isolated behavioural tests: no real downloads, Scoop updates or scheduled tasks. $ErrorActionPreference = 'Stop' $projectRoot = Split-Path $PSScriptRoot -Parent $fixtureRoot = Join-Path $PSScriptRoot ('.auto-update-test-' + [guid]::NewGuid()) $oldScoop = $env:SCOOP $oldPath = $env:PATH $oldFixture = $env:SCOOP_AUTO_UPDATE_TEST_ROOT $oldFailure = $env:SCOOP_AUTO_UPDATE_TEST_FAILURE function Assert-True($Condition, [string] $Message) { if (-not $Condition) { throw $Message } } function Assert-HiddenTask($Task, [string] $Runner, [string] $ScoopRoot) { Assert-True ($Task.TaskName -eq 'Medelexis AG - Scoop AutoUpdate') 'Unexpected task name.' $description = 'T' + [char]0x00e4 + 'gliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)' Assert-True ($Task.Description -ceq $description) 'Unexpected task description.' $launcher = Join-Path (Split-Path $Runner -Parent) 'auto-update-hidden.exe' Assert-True ($Task.Action.Execute -eq $launcher -and (Test-Path -LiteralPath $launcher)) 'Task must use the GUI launcher.' Assert-True ($Task.Action.Argument -eq ('"{0}" "{1}"' -f $Runner, $ScoopRoot)) 'Expected quoted runner and Scoop root.' } # These stand-ins capture scheduling settings without changing Windows. function New-ScheduledTaskAction { param($Execute, $Argument) [pscustomobject]$PSBoundParameters } function New-ScheduledTaskTrigger { param([switch]$Daily, $At) [pscustomobject]$PSBoundParameters } function New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) [pscustomobject]$PSBoundParameters } function New-ScheduledTaskSettingsSet { param([switch]$StartWhenAvailable, [switch]$RunOnlyIfNetworkAvailable, [switch]$AllowStartIfOnBatteries, [switch]$DontStopIfGoingOnBatteries, $MultipleInstances, $ExecutionTimeLimit) [pscustomobject]$PSBoundParameters } function Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, [switch]$Force, $Description) $global:AutoUpdateTestTask = [pscustomobject]$PSBoundParameters $global:AutoUpdateTestTasks[$TaskName] = $global:AutoUpdateTestTask } function Get-ScheduledTask { param($TaskName, $ErrorAction) if ($global:AutoUpdateTestTasks.ContainsKey($TaskName)) { $global:AutoUpdateTestTasks[$TaskName] } } function Unregister-ScheduledTask { param($TaskName, [switch]$Confirm) $expected = 'Medelexis-Scoop-AutoUpdate-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value Assert-True ($TaskName -in @($expected, 'Medelexis AG - Scoop AutoUpdate') -and -not $Confirm) 'Only remove the matching auto-update task.' $global:AutoUpdateTestRemovedTask = $TaskName $global:AutoUpdateTestTasks.Remove($TaskName) } function Stop-ScheduledTask { param($TaskName) $global:AutoUpdateTestStoppedTask = $TaskName $global:AutoUpdateTestTasks[$TaskName].State = 'Ready' } function Read-Host { param($Prompt) $global:AutoUpdateTestPrompts.Add($Prompt) $global:AutoUpdateTestAnswers.Dequeue() } function Invoke-WebRequest { param($Uri, $OutFile, [switch]$UseBasicParsing) $global:AutoUpdateTestDownloads++ Assert-True ($Uri -in @('https://example.test/scoop/raw/branch/master/scripts/auto-update.ps1', 'https://gitea.medelexis.ch/public/scoop/raw/branch/master/scripts/auto-update.ps1')) 'Unexpected download URL.' if ($global:AutoUpdateTestInvalidDownload) { 'This is not the setup script.' | Set-Content -LiteralPath $OutFile } else { @' param([string] $ScoopRoot) throw 'The downloaded runner must not execute during setup.' '@ | Set-Content -LiteralPath $OutFile } } try { # Spaces in the installation path exercise the scheduled command's quoting. $scoopRoot = Join-Path $fixtureRoot ('scoop with spaces ' + [char]0x00e4) $shims = Join-Path $scoopRoot 'shims' New-Item -ItemType Directory -Path $shims -Force | Out-Null $scriptPath = Join-Path $fixtureRoot 'auto-update.ps1' Copy-Item -LiteralPath (Join-Path $projectRoot 'scripts\auto-update.ps1') -Destination $scriptPath $env:SCOOP_AUTO_UPDATE_TEST_ROOT = $fixtureRoot $commandLog = Join-Path $fixtureRoot 'commands.log' @' param([Parameter(ValueFromRemainingArguments = $true)][string[]] $ScoopArguments) Add-Content -LiteralPath (Join-Path $env:SCOOP_AUTO_UPDATE_TEST_ROOT 'commands.log') -Value ($ScoopArguments -join ' ') if ($env:SCOOP_AUTO_UPDATE_TEST_FAILURE -eq ($ScoopArguments -join ' ')) { exit 1 } if ($ScoopArguments[0] -eq 'list') { [pscustomobject]@{ Name = 'elexis'; Info = '' } [pscustomobject]@{ Name = 'Elexis-test'; Info = 'Held package' } [pscustomobject]@{ Name = 'myelexis-cli'; Info = '' } [pscustomobject]@{ Name = 'git'; Info = '' } [pscustomobject]@{ Name = 'elexis-global'; Info = 'Global install' } [pscustomobject]@{ Name = 'elexis-broken'; Info = 'Install failed' } } exit 0 '@ | Set-Content -LiteralPath (Join-Path $shims 'scoop.ps1') -Encoding UTF8 & $scriptPath -ScoopRoot $scoopRoot Assert-True ($LASTEXITCODE -eq 0) 'Expected successful update.' Assert-True (((Get-Content $commandLog) -join '|') -eq 'list|hold elexis|hold Elexis-test|update|update *') 'Expected only local elexis* held before updates.' Assert-True (Test-Path (Join-Path $fixtureRoot 'auto-update.log')) 'Expected update log.' foreach ($failure in @('list', 'hold elexis', 'update', 'update *')) { Remove-Item -LiteralPath $commandLog $env:SCOOP_AUTO_UPDATE_TEST_FAILURE = $failure # Expected errors are logged; do not let test ErrorActionPreference terminate the caller. $ErrorActionPreference = 'Continue' & $scriptPath -ScoopRoot $scoopRoot 2>$null $result = $LASTEXITCODE $ErrorActionPreference = 'Stop' Assert-True ($result -eq 1) "Expected failure status for $failure." $commands = @(Get-Content $commandLog) Assert-True ($commands[-1] -eq $failure) "Must abort immediately after $failure." } # Test all entry points without migrating buckets or changing real tasks. $tokens = $null $errors = $null $bucketAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $projectRoot 'scripts\update-bucket.ps1'), [ref]$tokens, [ref]$errors) Assert-True ($errors.Count -eq 0) 'Invalid bucket script syntax.' $setupStatements = @($bucketAst.EndBlock.Statements) $setupStart = 0 while ($setupStart -lt $setupStatements.Count -and -not $setupStatements[$setupStart].Extent.Text.StartsWith('$userId =')) { $setupStart++ } Assert-True ($setupStart -lt $setupStatements.Count) 'Setup block missing in bucket migration.' $optInBlock = [scriptblock]::Create((($setupStatements[$setupStart..($setupStatements.Count - 1)] | ForEach-Object { $_.Extent.Text }) -join "`n")) $repository = 'https://example.test/scoop' Remove-Item -LiteralPath $commandLog $global:AutoUpdateTestAnswers = [Collections.Generic.Queue[string]]::new() $global:AutoUpdateTestPrompts = [Collections.Generic.List[string]]::new() $global:AutoUpdateTestTasks = @{} $global:AutoUpdateTestDownloads = 0 $downloadDirectory = Join-Path $scoopRoot 'scripts\medelexis' $downloadedPath = Join-Path $downloadDirectory 'auto-update.ps1' $launcherPath = Join-Path $downloadDirectory 'auto-update-hidden.exe' $taskName = 'Medelexis AG - Scoop AutoUpdate' $legacyName = 'Medelexis-Scoop-AutoUpdate-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $setupEntries = @($optInBlock, (Join-Path $projectRoot 'scripts\setup-auto-update.ps1')) foreach ($entry in $setupEntries) { $global:AutoUpdateTestPrompts.Clear() $global:AutoUpdateTestAnswers.Enqueue('n') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestPrompts.Count -eq 1 -and $global:AutoUpdateTestPrompts[0] -eq 'Scoop-Auto-Update einschalten? [y/n]') 'Missing task must offer normal setup.' Assert-True ($global:AutoUpdateTestTasks.Count -eq 0) 'Declining initial setup must not register a task.' $global:AutoUpdateTestInvalidDownload = $false $global:AutoUpdateTestAnswers.Enqueue('invalid') $global:AutoUpdateTestAnswers.Enqueue('Y') & $entry -ScoopRoot $scoopRoot $task = $global:AutoUpdateTestTasks[$taskName] Assert-HiddenTask $task $downloadedPath $scoopRoot Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00' -and $task.Settings.StartWhenAvailable) 'Expected daily 03:00 with catch-up.' Assert-True ($task.Settings.RunOnlyIfNetworkAvailable -and $task.Settings.MultipleInstances -eq 'IgnoreNew') 'Expected network and overlap settings.' Assert-True ($task.Principal.LogonType -eq 'Interactive' -and $task.Principal.RunLevel -eq 'Limited') 'Expected normal user session.' Assert-True (-not (Test-Path $commandLog)) 'Setup must not execute Scoop or migrations.' # No/no must leave task, runner and launcher byte-for-byte unchanged. $downloads = $global:AutoUpdateTestDownloads $runnerBefore = (Get-FileHash -LiteralPath $downloadedPath).Hash $launcherBefore = (Get-FileHash -LiteralPath $launcherPath).Hash $global:AutoUpdateTestPrompts.Clear() $global:AutoUpdateTestAnswers.Enqueue('n') $global:AutoUpdateTestAnswers.Enqueue('n') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestPrompts.Count -eq 2 -and $global:AutoUpdateTestPrompts[0] -like '*reparieren*' -and $global:AutoUpdateTestPrompts[1] -like '*entfernen*') 'Expected repair then removal questions.' Assert-True ($global:AutoUpdateTestTasks[$taskName] -eq $task -and $global:AutoUpdateTestDownloads -eq $downloads) 'No/no must preserve the task and avoid downloading.' Assert-True ((Get-FileHash -LiteralPath $downloadedPath).Hash -eq $runnerBefore -and (Get-FileHash -LiteralPath $launcherPath).Hash -eq $launcherBefore) 'No/no must preserve files.' # Repair downloads and replaces the existing task, without duplicating it. $global:AutoUpdateTestPrompts.Clear() $global:AutoUpdateTestAnswers.Enqueue('y') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestPrompts.Count -eq 1 -and $global:AutoUpdateTestPrompts[0] -like '*reparieren*') 'Repair must ask only the repair question.' Assert-True ($global:AutoUpdateTestDownloads -eq $downloads + 1 -and $global:AutoUpdateTestTasks.Count -eq 1) 'Repair must download and replace the existing task.' Assert-HiddenTask $global:AutoUpdateTestTasks[$taskName] $downloadedPath $scoopRoot # Failed repair must retain the prior runner and task. $task = $global:AutoUpdateTestTasks[$taskName] $global:AutoUpdateTestInvalidDownload = $true $global:AutoUpdateTestAnswers.Enqueue('y') $rejected = $false try { & $entry -ScoopRoot $scoopRoot } catch { $rejected = $true } Assert-True $rejected 'Invalid download must be rejected.' Assert-True ($global:AutoUpdateTestTasks[$taskName] -eq $task -and (Get-FileHash -LiteralPath $downloadedPath).Hash -eq $runnerBefore) 'Failed repair must preserve the existing runner and task.' Assert-True (@(Get-ChildItem $downloadDirectory -Filter '*.download').Count -eq 0) 'Temporary downloads must be removed.' $global:AutoUpdateTestInvalidDownload = $false # Also recognize the earlier task name, and remove only managed files. $global:AutoUpdateTestTasks.Clear() $global:AutoUpdateTestTasks[$legacyName] = [pscustomobject]@{ TaskName = $legacyName; State = 'Running' } Set-Content -LiteralPath (Join-Path $downloadDirectory 'auto-update.log') -Value 'old log' Set-Content -LiteralPath (Join-Path $downloadDirectory 'unrelated.txt') -Value 'keep' $downloads = $global:AutoUpdateTestDownloads $global:AutoUpdateTestAnswers.Enqueue('n') $global:AutoUpdateTestAnswers.Enqueue('y') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestTasks.Count -eq 0 -and $global:AutoUpdateTestStoppedTask -eq $legacyName) 'Remove must stop and unregister the existing legacy task.' Assert-True (-not (Test-Path $downloadedPath) -and -not (Test-Path $launcherPath) -and -not (Test-Path (Join-Path $downloadDirectory 'auto-update.log'))) 'Remove must delete the runner, launcher and log.' Assert-True ((Test-Path (Join-Path $downloadDirectory 'unrelated.txt')) -and $global:AutoUpdateTestDownloads -eq $downloads) 'Remove must preserve unrelated files and avoid downloading.' # Repair migrates the legacy name to the requested task name. $global:AutoUpdateTestTasks[$legacyName] = [pscustomobject]@{ TaskName = $legacyName; State = 'Ready' } $global:AutoUpdateTestAnswers.Enqueue('y') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestTasks.Count -eq 1 -and $global:AutoUpdateTestTasks.ContainsKey($taskName)) 'Repair must migrate the legacy task.' # Removal of the current name must offer the same behaviour. $global:AutoUpdateTestAnswers.Enqueue('n') $global:AutoUpdateTestAnswers.Enqueue('y') & $entry -ScoopRoot $scoopRoot Assert-True ($global:AutoUpdateTestTasks.Count -eq 0 -and -not (Test-Path $downloadedPath)) 'Remove must also handle the current task name.' } # Prepare one local launcher for the real hidden-process smoke test below. $global:AutoUpdateTestAnswers.Enqueue('y') & (Join-Path $projectRoot 'scripts\setup-auto-update.ps1') -ScoopRoot $scoopRoot $task = $global:AutoUpdateTestTasks[$taskName] # Run only a harmless local fixture through the actual GUI launcher. No Scoop # or scheduler calls: verify quoting, hidden child window and exit propagation. @' param([string] $ScoopRoot) Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class AutoUpdateWindowTest { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr handle); } "@ $visible = [AutoUpdateWindowTest]::IsWindowVisible([AutoUpdateWindowTest]::GetConsoleWindow()) Set-Content -LiteralPath (Join-Path $ScoopRoot 'hidden-test.txt') -Value ([string]$visible) exit 23 '@ | Set-Content -LiteralPath $downloadedPath -Encoding UTF8 $process = Start-Process -FilePath $task.Action.Execute -ArgumentList $task.Action.Argument -WindowStyle Hidden -PassThru if (-not $process.WaitForExit(20000)) { $process.Kill() throw 'Hidden launcher did not finish within 20 seconds.' } Assert-True ($process.ExitCode -eq 23) 'Launcher must forward the runner exit code.' Assert-True ((Get-Content -LiteralPath (Join-Path $scoopRoot 'hidden-test.txt')) -eq 'False') 'Runner must have no visible console window.' Write-Host 'PASS: install, repair, no/no, removal, legacy tasks, hidden launcher, schedule, hold filtering and failures.' } finally { $env:SCOOP = $oldScoop $env:PATH = $oldPath $env:SCOOP_AUTO_UPDATE_TEST_ROOT = $oldFixture $env:SCOOP_AUTO_UPDATE_TEST_FAILURE = $oldFailure Remove-Variable AutoUpdateTestTask, AutoUpdateTestAnswers, AutoUpdateTestInvalidDownload, AutoUpdateTestRemovedTask, AutoUpdateTestTasks, AutoUpdateTestDownloads, AutoUpdateTestPrompts, AutoUpdateTestStoppedTask -Scope Global -ErrorAction SilentlyContinue # Delete only this test's unique directory, after checking its absolute parent. if ((Test-Path -LiteralPath $fixtureRoot) -and [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($fixtureRoot)) -eq [IO.Path]::GetFullPath($PSScriptRoot) -and [IO.Path]::GetFileName($fixtureRoot).StartsWith('.auto-update-test-')) { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force } }