diff --git a/.gitignore b/.gitignore index c3c8f3c..cd257ab 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .vscode/* __pycache__/ *.py[cod] +/tests/* +!/tests/test_update_manifests.py diff --git a/scripts/auto-update.ps1 b/scripts/auto-update.ps1 index 6fef663..2661289 100644 --- a/scripts/auto-update.ps1 +++ b/scripts/auto-update.ps1 @@ -1,9 +1,9 @@ -#requires -Version 5.1 +#requires -Version 5.1 <# .SYNOPSIS Aktualisiert Scoop und alle benutzerspezifischen Pakete; elexis* bleibt gesperrt. .DESCRIPTION -Fuehrt ausschliesslich den Update-Lauf aus. Die Aufgabe wird von update-bucket.ps1 +Führt ausschliesslich den Update-Lauf aus. Die Aufgabe wird von update-bucket.ps1 eingerichtet und startet dieses Skript verborgen. Der letzte Lauf steht in auto-update.log neben diesem Skript. Globale Pakete werden nicht aktualisiert. #> @@ -25,7 +25,7 @@ $exitCode = 0 try { Start-Transcript -LiteralPath (Join-Path $PSScriptRoot 'auto-update.log') -Force | Out-Null $transcriptStarted = $true - # Ein absoluter Scoop-Pfad und die shims im PATH funktionieren auch ohne Profil. + # Ein absoluter Scoop-Pfad und die Shims im PATH funktionieren auch ohne Profil. $env:PATH = (Join-Path $ScoopRoot 'shims') + [IO.Path]::PathSeparator + $env:PATH $installed = @(& $scoopCommand list) if ($LASTEXITCODE -ne 0) { throw 'Installierte Scoop-Pakete konnten nicht ermittelt werden.' } @@ -33,7 +33,7 @@ try { if ($package.Name -notlike 'elexis*' -or $package.Info -match 'Global install|Install failed') { continue } & $scoopCommand hold $package.Name if ($LASTEXITCODE -ne 0) { - throw "Updatesperre fuer '$($package.Name)' fehlgeschlagen. Kein Update ausgefuehrt." + throw "Updatesperre für '$($package.Name)' fehlgeschlagen. Kein Update ausgeführt." } } & $scoopCommand update diff --git a/scripts/setup-auto-update.ps1 b/scripts/setup-auto-update.ps1 index 3fd6961..771bca3 100644 --- a/scripts/setup-auto-update.ps1 +++ b/scripts/setup-auto-update.ps1 @@ -1,13 +1,14 @@ -#requires -Version 5.1 +#requires -Version 5.1 <# .SYNOPSIS -Fragt nach und richtet optional die taeglichen Scoop-Auto-Updates ein. +Fragt nach und richtet optional die täglichen Scoop-Auto-Updates ein. .DESCRIPTION -Eigenstaendig ausfuehrbar, ohne Bucket-Migration. Bei y wird auto-update.ps1 -heruntergeladen und eine Aufgabe fuer taeglich 03:00 Uhr erstellt. +Eigenständig ausführbar, ohne Bucket-Migration. Bei y wird auto-update.ps1 +heruntergeladen und eine Aufgabe für täglich 03:00 Uhr erstellt. Die Aufgabe startet das Updateskript verborgen und holt verpasste Termine nach, -sobald die Benutzersitzung und eine Netzwerkverbindung verfuegbar sind. -Bei n wird nichts eingerichtet; bestehende Aufgaben bleiben unveraendert. +sobald die Benutzersitzung und eine Netzwerkverbindung verfügbar sind. +Bestehende Aufgaben können nach Rückfrage repariert oder samt Updateskript +entfernt werden. Werden beide Fragen verneint, bleibt alles unverändert. .EXAMPLE .\scripts\setup-auto-update.ps1 #> @@ -20,9 +21,48 @@ $ErrorActionPreference = 'Stop' $ScoopRoot = [IO.Path]::GetFullPath($ScoopRoot) $repository = 'https://gitea.medelexis.ch/public/scoop' -do { - $autoUpdateAnswer = (Read-Host 'Scoop Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant() -} while ($autoUpdateAnswer -notin @('y', 'n')) +$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$taskName = 'Medelexis AG - Scoop AutoUpdate' +$legacyTaskName = "Medelexis-Scoop-AutoUpdate-$userSid" +$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis' +$existingTasks = @( + foreach ($name in @($taskName, $legacyTaskName)) { + Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + } +) +$autoUpdateRemoved = $false +if ($existingTasks.Count -gt 0) { + do { + $autoUpdateAnswer = (Read-Host 'Bestehende Scoop-Auto-Update-Aufgabe reparieren (ersetzen)? [y/n]').Trim().ToLowerInvariant() + } while ($autoUpdateAnswer -notin @('y', 'n')) + if ($autoUpdateAnswer -eq 'n') { + do { + $removeAnswer = (Read-Host 'Scoop-Auto-Update entfernen? [y/n]').Trim().ToLowerInvariant() + } while ($removeAnswer -notin @('y', 'n')) + if ($removeAnswer -eq 'y') { + foreach ($existingTask in $existingTasks) { + if ($existingTask.State -eq 'Running') { + Stop-ScheduledTask -TaskName $existingTask.TaskName + } + Unregister-ScheduledTask -TaskName $existingTask.TaskName -Confirm:$false + } + # Nur die zugehörigen Dateien entfernen, niemals den Ordner rekursiv. + foreach ($fileName in @('auto-update.ps1', 'auto-update-hidden.exe', 'auto-update.log')) { + $filePath = Join-Path $autoUpdateDirectory $fileName + if (Test-Path -LiteralPath $filePath -PathType Leaf) { + Remove-Item -LiteralPath $filePath -Force + } + } + $autoUpdateRemoved = $true + Write-Host 'Scoop-Auto-Update entfernt (Aufgabe und zugehörige Dateien).' + } + } +} else { + do { + $autoUpdateAnswer = (Read-Host 'Scoop-Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant() + } while ($autoUpdateAnswer -notin @('y', 'n')) +} if ($autoUpdateAnswer -eq 'y') { $scoopCommand = Join-Path $ScoopRoot 'shims\scoop.ps1' @@ -41,16 +81,67 @@ if ($autoUpdateAnswer -eq 'y') { $downloadedScript = [Management.Automation.Language.Parser]::ParseFile($downloadPath, [ref] $parseTokens, [ref] $parseErrors) if ($parseErrors.Count -gt 0 -or -not $downloadedScript.ParamBlock -or 'ScoopRoot' -notin @($downloadedScript.ParamBlock.Parameters.Name.VariablePath.UserPath)) { - throw 'Das heruntergeladene Auto-Update-Skript ist kein gueltiges Updateskript.' + throw 'Das heruntergeladene Auto-Update-Skript ist kein gültiges Updateskript.' } Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force # Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet. - $userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $taskName = "Medelexis-Scoop-AutoUpdate-$userSid" - $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - $arguments = '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -ScoopRoot "{1}"' -f $autoUpdateScript, $ScoopRoot - $action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments + # Bei einer Reparatur zuerst eine laufende Aufgabe anhalten, + # damit ihr Starter ersetzt werden kann. + foreach ($existingTask in $existingTasks) { + if ($existingTask.State -eq 'Running') { + Stop-ScheduledTask -TaskName $existingTask.TaskName + } + } + # Ein GUI-Programm startet PowerShell ohne Konsole, auch wenn Windows + # Terminal als Standardterminal eingerichtet ist. Es wartet auf das Ende + # des Updates und reicht den Exitcode weiter (Fehlerstatus / IgnoreNew). + $launcherPath = Join-Path $autoUpdateDirectory 'auto-update-hidden.exe' + $launcherSource = @' +using System; +using System.Diagnostics; +public static class ScoopAutoUpdateLauncher { + [STAThread] + public static int Main(string[] args) { + if (args.Length != 2) return 2; + try { + var start = new ProcessStartInfo(); + start.FileName = System.IO.Path.Combine(Environment.GetEnvironmentVariable("SystemRoot"), + @"System32\WindowsPowerShell\v1.0\powershell.exe"); + start.Arguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" + + args[0] + "\" -ScoopRoot \"" + args[1] + "\""; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.WindowStyle = ProcessWindowStyle.Hidden; + using (var child = Process.Start(start)) { + child.WaitForExit(); + return child.ExitCode; + } + } catch { return 1; } + } +} +'@ + $compiledLauncher = Join-Path $autoUpdateDirectory ('auto-update-{0}.exe' -f [guid]::NewGuid()) + $launcherSourcePath = [IO.Path]::ChangeExtension($compiledLauncher, '.cs') + # Der .NET-Framework-Compiler erzeugt eine Windows-Anwendung auch unter + # PowerShell 7, wo Add-Type -OutputType WindowsApplication nicht existiert. + $compiler = @( + (Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:SystemRoot 'Microsoft.NET\Framework\v4.0.30319\csc.exe') + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $compiler) { throw 'Der Windows-.NET-Framework-Compiler wurde nicht gefunden.' } + try { + [IO.File]::WriteAllText($launcherSourcePath, $launcherSource, [Text.Encoding]::UTF8) + $compilerOutput = & $compiler /nologo /target:winexe "/out:$compiledLauncher" $launcherSourcePath 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $compiledLauncher -PathType Leaf)) { + throw "Der fensterlose Starter konnte nicht erstellt werden: $($compilerOutput | Out-String)" + } + Move-Item -LiteralPath $compiledLauncher -Destination $launcherPath -Force + } finally { + foreach ($temporaryFile in @($compiledLauncher, $launcherSourcePath)) { + if (Test-Path -LiteralPath $temporaryFile) { Remove-Item -LiteralPath $temporaryFile } + } + } + $action = New-ScheduledTaskAction -Execute $launcherPath -Argument ('"{0}" "{1}"' -f $autoUpdateScript, $ScoopRoot.TrimEnd('\')) $trigger = New-ScheduledTaskTrigger -Daily -At '03:00' $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable ` @@ -58,12 +149,15 @@ if ($autoUpdateAnswer -eq 'y') { -ExecutionTimeLimit (New-TimeSpan -Hours 4) Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force ` - -Description 'Scoop taeglich um 03:00 aktualisieren; verpasste Termine nachholen; elexis* auf hold setzen.' | Out-Null - Write-Host "Scoop Auto-Update eingeschaltet: $taskName (taeglich 03:00, verpasste Termine nachholen)." - Write-Host "Ausfuehrung verborgen unter $userId, sobald der Benutzer angemeldet ist; kein Aufwecken des PCs." + -Description 'Tägliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)' | Out-Null + # Die frühere Aufgabe dieses Benutzers entfernen, damit nur ein Update läuft. + if (Get-ScheduledTask -TaskName $legacyTaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $legacyTaskName -Confirm:$false + } + Write-Host "Scoop-Auto-Update eingeschaltet: $taskName" } finally { if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath } } -} else { - Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.' +} elseif (-not $autoUpdateRemoved) { + Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.' } diff --git a/scripts/update-bucket.ps1 b/scripts/update-bucket.ps1 index fbd7216..fc453fe 100644 --- a/scripts/update-bucket.ps1 +++ b/scripts/update-bucket.ps1 @@ -9,7 +9,7 @@ Ein passender bestehender Bucket wird umbenannt. Danach werden elexis und streic Andere Buckets wie main und extras bleiben bestehen. Alte Paketnamen mit Unterstrich werden auf passende Manifestnamen mit Bindestrich umgestellt. ee-iso/ee_iso und ee-cli werden deinstalliert. MyElexis CLI wird neu installiert. -Mit -Apply wird abschliessend gefragt, ob taegliche Auto-Updates um 03:00 Uhr +Mit -Apply wird abschliessend gefragt, ob tägliche Auto-Updates um 03:00 Uhr eingerichtet werden sollen. Das separate Skript wird aus dem Repository geladen. .EXAMPLE .\scripts\update-bucket.ps1 @@ -269,9 +269,48 @@ Write-Host "Fertig: $($changes.Count) install.json-Dateien auf medelexis umgeste Write-Host "Paketnamen umgestellt: $($renames.Count)." Write-Host 'Von elexis/streichtech/medelexis bleibt nur medelexis bestehen. Prüfung: scoop status --local' -do { - $autoUpdateAnswer = (Read-Host 'Scoop Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant() -} while ($autoUpdateAnswer -notin @('y', 'n')) +$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$taskName = 'Medelexis AG - Scoop AutoUpdate' +$legacyTaskName = "Medelexis-Scoop-AutoUpdate-$userSid" +$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis' +$existingTasks = @( + foreach ($name in @($taskName, $legacyTaskName)) { + Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + } +) +$autoUpdateRemoved = $false +if ($existingTasks.Count -gt 0) { + do { + $autoUpdateAnswer = (Read-Host 'Bestehende Scoop-Auto-Update-Aufgabe reparieren (ersetzen)? [y/n]').Trim().ToLowerInvariant() + } while ($autoUpdateAnswer -notin @('y', 'n')) + if ($autoUpdateAnswer -eq 'n') { + do { + $removeAnswer = (Read-Host 'Scoop-Auto-Update entfernen? [y/n]').Trim().ToLowerInvariant() + } while ($removeAnswer -notin @('y', 'n')) + if ($removeAnswer -eq 'y') { + foreach ($existingTask in $existingTasks) { + if ($existingTask.State -eq 'Running') { + Stop-ScheduledTask -TaskName $existingTask.TaskName + } + Unregister-ScheduledTask -TaskName $existingTask.TaskName -Confirm:$false + } + # Nur die zugehörigen Dateien entfernen, niemals den Ordner rekursiv. + foreach ($fileName in @('auto-update.ps1', 'auto-update-hidden.exe', 'auto-update.log')) { + $filePath = Join-Path $autoUpdateDirectory $fileName + if (Test-Path -LiteralPath $filePath -PathType Leaf) { + Remove-Item -LiteralPath $filePath -Force + } + } + $autoUpdateRemoved = $true + Write-Host 'Scoop-Auto-Update entfernt (Aufgabe und zugehörige Dateien).' + } + } +} else { + do { + $autoUpdateAnswer = (Read-Host 'Scoop-Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant() + } while ($autoUpdateAnswer -notin @('y', 'n')) +} if ($autoUpdateAnswer -eq 'y') { $autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis' @@ -286,16 +325,67 @@ if ($autoUpdateAnswer -eq 'y') { $downloadedScript = [Management.Automation.Language.Parser]::ParseFile($downloadPath, [ref] $parseTokens, [ref] $parseErrors) if ($parseErrors.Count -gt 0 -or -not $downloadedScript.ParamBlock -or 'ScoopRoot' -notin @($downloadedScript.ParamBlock.Parameters.Name.VariablePath.UserPath)) { - throw 'Das heruntergeladene Auto-Update-Skript ist kein gueltiges Updateskript.' + throw 'Das heruntergeladene Auto-Update-Skript ist kein gültiges Updateskript.' } Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force # Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet. - $userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $taskName = "Medelexis-Scoop-AutoUpdate-$userSid" - $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - $arguments = '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -ScoopRoot "{1}"' -f $autoUpdateScript, $ScoopRoot - $action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments + # Bei einer Reparatur zuerst eine laufende Aufgabe anhalten, + # damit ihr Starter ersetzt werden kann. + foreach ($existingTask in $existingTasks) { + if ($existingTask.State -eq 'Running') { + Stop-ScheduledTask -TaskName $existingTask.TaskName + } + } + # Ein GUI-Programm startet PowerShell ohne Konsole, auch wenn Windows + # Terminal als Standardterminal eingerichtet ist. Es wartet auf das Ende + # des Updates und reicht den Exitcode weiter (Fehlerstatus / IgnoreNew). + $launcherPath = Join-Path $autoUpdateDirectory 'auto-update-hidden.exe' + $launcherSource = @' +using System; +using System.Diagnostics; +public static class ScoopAutoUpdateLauncher { + [STAThread] + public static int Main(string[] args) { + if (args.Length != 2) return 2; + try { + var start = new ProcessStartInfo(); + start.FileName = System.IO.Path.Combine(Environment.GetEnvironmentVariable("SystemRoot"), + @"System32\WindowsPowerShell\v1.0\powershell.exe"); + start.Arguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" + + args[0] + "\" -ScoopRoot \"" + args[1] + "\""; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.WindowStyle = ProcessWindowStyle.Hidden; + using (var child = Process.Start(start)) { + child.WaitForExit(); + return child.ExitCode; + } + } catch { return 1; } + } +} +'@ + $compiledLauncher = Join-Path $autoUpdateDirectory ('auto-update-{0}.exe' -f [guid]::NewGuid()) + $launcherSourcePath = [IO.Path]::ChangeExtension($compiledLauncher, '.cs') + # Der .NET-Framework-Compiler erzeugt eine Windows-Anwendung auch unter + # PowerShell 7, wo Add-Type -OutputType WindowsApplication nicht existiert. + $compiler = @( + (Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:SystemRoot 'Microsoft.NET\Framework\v4.0.30319\csc.exe') + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $compiler) { throw 'Der Windows-.NET-Framework-Compiler wurde nicht gefunden.' } + try { + [IO.File]::WriteAllText($launcherSourcePath, $launcherSource, [Text.Encoding]::UTF8) + $compilerOutput = & $compiler /nologo /target:winexe "/out:$compiledLauncher" $launcherSourcePath 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $compiledLauncher -PathType Leaf)) { + throw "Der fensterlose Starter konnte nicht erstellt werden: $($compilerOutput | Out-String)" + } + Move-Item -LiteralPath $compiledLauncher -Destination $launcherPath -Force + } finally { + foreach ($temporaryFile in @($compiledLauncher, $launcherSourcePath)) { + if (Test-Path -LiteralPath $temporaryFile) { Remove-Item -LiteralPath $temporaryFile } + } + } + $action = New-ScheduledTaskAction -Execute $launcherPath -Argument ('"{0}" "{1}"' -f $autoUpdateScript, $ScoopRoot.TrimEnd('\')) $trigger = New-ScheduledTaskTrigger -Daily -At '03:00' $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable ` @@ -303,12 +393,15 @@ if ($autoUpdateAnswer -eq 'y') { -ExecutionTimeLimit (New-TimeSpan -Hours 4) Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force ` - -Description 'Scoop taeglich um 03:00 aktualisieren; verpasste Termine nachholen; elexis* auf hold setzen.' | Out-Null - Write-Host "Scoop Auto-Update eingeschaltet: $taskName (taeglich 03:00, verpasste Termine nachholen)." - Write-Host "Ausfuehrung verborgen unter $userId, sobald der Benutzer angemeldet ist; kein Aufwecken des PCs." + -Description 'Tägliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)' | Out-Null + # Die frühere Aufgabe dieses Benutzers entfernen, damit nur ein Update läuft. + if (Get-ScheduledTask -TaskName $legacyTaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $legacyTaskName -Confirm:$false + } + Write-Host "Scoop-Auto-Update eingeschaltet: $taskName" } finally { if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath } } -} else { - Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.' +} elseif (-not $autoUpdateRemoved) { + Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.' } diff --git a/tests/test_auto_update.ps1 b/tests/test_auto_update.ps1 index 07dd61d..f5bde88 100644 --- a/tests/test_auto_update.ps1 +++ b/tests/test_auto_update.ps1 @@ -1,4 +1,4 @@ -#requires -Version 5.1 +#requires -Version 5.1 # Isolated behavioural tests: no real downloads, Scoop updates or scheduled tasks. $ErrorActionPreference = 'Stop' $projectRoot = Split-Path $PSScriptRoot -Parent @@ -11,6 +11,14 @@ $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 } @@ -25,13 +33,32 @@ function New-ScheduledTaskSettingsSet { 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) { @@ -46,7 +73,7 @@ throw 'The downloaded runner must not execute during setup.' try { # Spaces in the installation path exercise the scheduled command's quoting. - $scoopRoot = Join-Path $fixtureRoot 'scoop with spaces' + $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' @@ -85,66 +112,136 @@ exit 0 $commands = @(Get-Content $commandLog) Assert-True ($commands[-1] -eq $failure) "Must abort immediately after $failure." } - # Exercise the actual opt-in/download block without running the bucket migration. + # 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.' - $optInBlock = [scriptblock]::Create((($bucketAst.EndBlock.Statements | Select-Object -Last 2 | ForEach-Object { $_.Extent.Text }) -join "`n")) + $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:AutoUpdateTestAnswers.Enqueue('n') - & $optInBlock + $global:AutoUpdateTestPrompts = [Collections.Generic.List[string]]::new() + $global:AutoUpdateTestTasks = @{} + $global:AutoUpdateTestDownloads = 0 $downloadDirectory = Join-Path $scoopRoot 'scripts\medelexis' - Assert-True (-not (Test-Path $downloadDirectory)) 'Declining must not download or set up anything.' - $global:AutoUpdateTestAnswers.Enqueue('invalid') - $global:AutoUpdateTestAnswers.Enqueue('Y') - & $optInBlock $downloadedPath = Join-Path $downloadDirectory 'auto-update.ps1' - Assert-True (Test-Path $downloadedPath) 'Expected downloaded runner.' - $task = $global:AutoUpdateTestTask - Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00') 'Expected daily 03:00 schedule.' - Assert-True ($task.Settings.StartWhenAvailable -and $task.Settings.RunOnlyIfNetworkAvailable) 'Expected catch-up and network settings.' - Assert-True ($task.Settings.MultipleInstances -eq 'IgnoreNew') 'Expected overlap protection.' - Assert-True ($task.Principal.LogonType -eq 'Interactive' -and $task.Principal.RunLevel -eq 'Limited') 'Expected normal user session.' - Assert-True ($task.Action.Argument.Contains('-WindowStyle Hidden') -and $task.Action.Argument.Contains('-NonInteractive')) 'Expected background execution.' - Assert-True ($task.Action.Argument.Contains('-File "' + $downloadedPath + '"') -and $task.Action.Argument.Contains('-ScoopRoot "' + $scoopRoot + '"')) 'Expected quoted paths pointing to the downloaded runner.' - Assert-True (-not (Test-Path $commandLog)) 'Setup must not execute Scoop.' - $taskName = $task.TaskName + $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') - & $optInBlock - Assert-True ($global:AutoUpdateTestTask.TaskName -eq $taskName -and $global:AutoUpdateTestTask.Force) 'Repeated setup must replace the same task.' - $originalDownload = [IO.File]::ReadAllText($downloadedPath) - Remove-Variable AutoUpdateTestTask -Scope Global - $global:AutoUpdateTestInvalidDownload = $true - $global:AutoUpdateTestAnswers.Enqueue('y') - $rejected = $false - try { & $optInBlock } catch { $rejected = $true } - Assert-True $rejected 'Invalid download must be rejected.' - Assert-True ([IO.File]::ReadAllText($downloadedPath) -ceq $originalDownload) 'Invalid download must preserve the existing script.' - Assert-True ($null -eq (Get-Variable AutoUpdateTestTask -Scope Global -ErrorAction SilentlyContinue)) 'Invalid download must not register a task.' - Assert-True (@(Get-ChildItem $downloadDirectory -Filter '*.download').Count -eq 0) 'Temporary downloads must be removed.' - # The additional standalone installer must offer the same setup without migration. - $standaloneSetup = Join-Path $projectRoot 'scripts\setup-auto-update.ps1' - $global:AutoUpdateTestAnswers.Enqueue('n') - & $standaloneSetup -ScoopRoot $scoopRoot - Assert-True ($null -eq (Get-Variable AutoUpdateTestTask -Scope Global -ErrorAction SilentlyContinue)) 'Standalone n must not register a task.' - $global:AutoUpdateTestInvalidDownload = $false - $global:AutoUpdateTestAnswers.Enqueue('y') - & $standaloneSetup -ScoopRoot $scoopRoot - $task = $global:AutoUpdateTestTask - Assert-True ($task.TaskName -eq $taskName -and $task.Force) 'Standalone setup must use the same task.' - Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00' -and $task.Settings.StartWhenAvailable) 'Standalone setup must schedule daily 03:00 with catch-up.' - Assert-True ($task.Action.Argument.Contains('-WindowStyle Hidden') -and $task.Action.Argument.Contains('-File "' + $downloadedPath + '"')) 'Standalone task must invoke the downloaded runner hidden.' - Assert-True (-not (Test-Path $commandLog)) 'Standalone setup must not run Scoop or migrations.' - Write-Host 'PASS: schedule, repeat setup, hold filtering, command order, logging, failures, opt-in and download setup.' + & (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 -Scope Global -ErrorAction SilentlyContinue + 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