diff --git a/.github/workflows/build-windows-a11y-ami.yml b/.github/workflows/build-windows-a11y-ami.yml index 68da3cd..a39edc7 100644 --- a/.github/workflows/build-windows-a11y-ami.yml +++ b/.github/workflows/build-windows-a11y-ami.yml @@ -75,21 +75,7 @@ jobs: - name: Install Windows Updates (repeat until converged) run: | - for i in $(seq 1 5); do - OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-updates.ps1 3600) - echo "${OUTPUT}" - if echo "${OUTPUT}" | grep -q "No updates found."; then - echo "No further updates." - break - fi - if echo "${OUTPUT}" | grep -q "REBOOT_REQUIRED=true"; then - echo "Rebooting instance for updates (pass ${i})..." - aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" - sleep 30 - aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" - fi - done - echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV" + bash scripts/windows-a11y/run-windows-updates.sh "${{ steps.launch.outputs.instance_id }}" - name: Install/update Chrome, Firefox, NVDA id: software @@ -148,13 +134,9 @@ jobs: id: create-image run: | AMI_NAME="windows-a11y-${{ github.event.inputs.ami_name }}" - IMAGE_ID=$(aws ec2 create-image \ - --instance-id "${{ steps.launch.outputs.instance_id }}" \ - --name "${AMI_NAME}" \ - --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ - --query 'ImageId' --output text) - aws ec2 wait image-available --image-ids "${IMAGE_ID}" - echo "ami_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT" + bash scripts/windows-a11y/create-ami.sh \ + "${{ steps.launch.outputs.instance_id }}" \ + "${AMI_NAME}" - name: Tag AMI and snapshots run: | diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index d7e1a25..1e57022 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -28,7 +28,7 @@ reliable than using the localized base image. - Source: `Custom` → enter your office/VPN CIDR block (e.g. `203.0.113.0/24`) — do **not** use `0.0.0.0/0`. - Description: `Office VPN RDP access` 6. **Outbound rules**: leave the default (all traffic allowed) — the instance needs outbound HTTPS for - Windows Update, Chocolatey, and the SSM agent. + Windows Update, Google, Mozilla, NV Access, and the SSM agent. 7. **Tags**: `Name` = `windows-a11y-rdp`. 8. Click **Create security group**. Copy the resulting **Security group ID** (e.g. `sg-0123456789abcdef0`). 9. Record this value — it becomes the `SECURITY_GROUP_ID` GitHub variable in step 5. diff --git a/scripts/windows-a11y/create-ami.sh b/scripts/windows-a11y/create-ami.sh new file mode 100644 index 0000000..46ecc90 --- /dev/null +++ b/scripts/windows-a11y/create-ami.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTANCE_ID="${1:?Usage: create-ami.sh }" +AMI_NAME="${2:?Usage: create-ami.sh }" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must point to the GitHub Actions output file}" + +MAX_ATTEMPTS="${AMI_MAX_ATTEMPTS:-120}" +POLL_INTERVAL_SECONDS="${AMI_POLL_INTERVAL_SECONDS:-15}" +LOG_PREFIX="[create-ami]" + +IMAGE_ID=$(aws ec2 create-image \ + --instance-id "${INSTANCE_ID}" \ + --name "${AMI_NAME}" \ + --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ + --query 'ImageId' \ + --output text) + +echo "${LOG_PREFIX} Created AMI ${IMAGE_ID}." +echo "ami_id=${IMAGE_ID}" >> "${GITHUB_OUTPUT}" + +for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + set +e + IMAGE_JSON=$(aws ec2 describe-images --image-ids "${IMAGE_ID}" --output json 2>&1) + DESCRIBE_STATUS=$? + set -e + + if (( DESCRIBE_STATUS != 0 )); then + if grep -q 'InvalidAMIID.NotFound' <<< "${IMAGE_JSON}"; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} is not visible yet (attempt ${attempt} of ${MAX_ATTEMPTS})." + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + continue + fi + + echo "${IMAGE_JSON}" >&2 + exit "${DESCRIBE_STATUS}" + fi + + STATE=$(jq -r '.Images[0].State // "missing"' <<< "${IMAGE_JSON}") + STATE_REASON=$(jq -r '.Images[0].StateReason.Message // empty' <<< "${IMAGE_JSON}") + + if [[ -n "${STATE_REASON}" ]]; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS}); reason: ${STATE_REASON}." + else + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS})." + fi + + case "${STATE}" in + available) + exit 0 + ;; + failed) + echo "${LOG_PREFIX} AMI ${IMAGE_ID} creation failed." >&2 + exit 1 + ;; + pending) + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + ;; + *) + echo "${LOG_PREFIX} Unexpected AMI state '${STATE}' for ${IMAGE_ID}." >&2 + exit 1 + ;; + esac +done + +echo "${LOG_PREFIX} Timed out waiting for AMI ${IMAGE_ID} after ${MAX_ATTEMPTS} checks." >&2 +exit 1 diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index fe62be5..ed18f90 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -1,19 +1,110 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' $logPrefix = '[install-software]' +$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/' -if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Output "$logPrefix Installing Chocolatey..." - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - $env:Path = "$env:Path;C:\ProgramData\chocolatey\bin" +function Get-FirefoxInstallerUri { + return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' } -$chocoPath = (Get-Command choco -ErrorAction Stop).Source -$successfulExitCodes = @(0, 2, 1641, 3010) +function Get-NvdaStableInstallerUri { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Uri]$BaseUri = $nvdaStableBaseUri + ) + + $hrefPattern = 'href=["''](?nvda_(?\d{4}\.\d+(?:\.\d+)?)\.exe)["'']' + $matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase')) + if ($matches.Count -ne 1) { + throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)." + } + return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) +} + +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} + +function Invoke-WebRequestWithRetry { + param( + [Parameter(Mandatory = $true)][Uri]$Uri, + [string]$OutFile, + [string]$Operation = 'download', + [ValidateRange(1, 3)][int]$MaxAttempts = 3 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $parameters = @{ Uri = $Uri; UseBasicParsing = $true } + if ($OutFile) { $parameters.OutFile = $OutFile } + return Invoke-WebRequest @parameters + } catch { + if ($attempt -eq $MaxAttempts) { + throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" + } + $delaySeconds = 15 * $attempt + Write-Warning "$logPrefix $Operation attempt $attempt of $MaxAttempts failed; retrying in $delaySeconds seconds." + Start-Sleep -Seconds $delaySeconds + } + } +} + +function Assert-AuthenticodePublisher { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProductName, + [Parameter(Mandatory = $true)][string]$PublisherPattern + ) + $signature = Get-AuthenticodeSignature -FilePath $Path + $publisher = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + '' + } + if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) { + throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)" + } +} + +function Assert-InstallerExitCode { + param([string]$ProductName, [int]$ExitCode) + if ($ExitCode -ne 0) { + throw "$logPrefix $ProductName installation failed with exit code $ExitCode." + } +} + +function Install-Firefox { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath ` + -Operation 'Firefox installer download' + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} + +function Install-Nvda { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri -Operation 'NVDA stable listing download' + $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content + $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) + Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath -Operation 'NVDA installer download' + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} function Install-GoogleChrome { $installerUri = 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi' @@ -28,7 +119,7 @@ function Install-GoogleChrome { # The Chrome Enterprise URL always points at the current stable MSI, so a # static checksum would become stale. Verify Google's code-signing identity - # instead of bypassing integrity checks with Chocolatey's --ignore-checksums. + # rather than bypassing integrity checks. $signature = Get-AuthenticodeSignature -FilePath $installerPath $publisher = $signature.SignerCertificate.Subject if ($signature.Status -ne 'Valid' -or $publisher -notmatch '(^|, )O=Google LLC(,|$)') { @@ -56,36 +147,6 @@ function Install-GoogleChrome { Remove-Item -Path $installerPath -Force } -function Install-OrUpgradePackage { - param( - [Parameter(Mandatory = $true)] - [string]$Package, - - [int]$MaxAttempts = 3 - ) - - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - Write-Output "$logPrefix Installing/updating $Package (attempt $attempt of $MaxAttempts)..." - - # Do not discard Chocolatey's output: it contains the installer-specific - # error that is needed to diagnose failures from the SSM command log. - & $chocoPath upgrade $Package -y --no-progress --execution-timeout=1200 --ignore-detected-reboot - $exitCode = $LASTEXITCODE - - if ($exitCode -in $successfulExitCodes) { - return - } - - if ($attempt -lt $MaxAttempts) { - $retryDelay = 15 * $attempt - Write-Warning "$logPrefix choco upgrade $Package failed with exit code $exitCode; retrying in $retryDelay seconds." - Start-Sleep -Seconds $retryDelay - } - } - - throw "$logPrefix choco upgrade $Package failed after $MaxAttempts attempts (last exit code: $exitCode)" -} - function Find-GoogleChromeExecutable { $candidates = @( 'C:\Program Files\Google\Chrome\Application\chrome.exe' @@ -124,32 +185,46 @@ function Wait-GoogleChromeExecutable { return $null } -$packages = @('firefox', 'nvda') -foreach ($package in $packages) { - Install-OrUpgradePackage -Package $package -} - -Install-GoogleChrome - -Write-Output "$logPrefix Installed package versions:" -$chromeExecutable = Wait-GoogleChromeExecutable -if (-not $chromeExecutable) { - $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' - $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine - throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" +function Write-InstalledSoftwareVersions { + param( + [Parameter(Mandatory = $true)] + [Collections.IDictionary]$SoftwareExecutables + ) + foreach ($software in $SoftwareExecutables.GetEnumerator()) { + if (-not (Test-Path $software.Value)) { + throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + } + $versionInfo = (Get-Item $software.Value).VersionInfo + $version = if ($versionInfo.ProductVersion) { + $versionInfo.ProductVersion + } else { + $versionInfo.FileVersion + } + Write-Output "VERSION_$($software.Key)=$version" + } } -$softwareExecutables = [ordered]@{ - GOOGLECHROME = $chromeExecutable - FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' - NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' -} -foreach ($software in $softwareExecutables.GetEnumerator()) { - if (-not (Test-Path $software.Value)) { - throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" +function Invoke-InstallSoftware { + Install-Firefox + Install-Nvda + Install-GoogleChrome + + Write-Output "$logPrefix Installed package versions:" + $chromeExecutable = Wait-GoogleChromeExecutable + if (-not $chromeExecutable) { + $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' + $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 ` + -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" + } + $softwareExecutables = [ordered]@{ + GOOGLECHROME = $chromeExecutable + FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' + NVDA = Find-NvdaExecutable } + Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables +} - $versionInfo = (Get-Item $software.Value).VersionInfo - $version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion } - Write-Output "VERSION_$($software.Key)=$version" +if (-not $SkipExecution) { + Invoke-InstallSoftware } diff --git a/scripts/windows-a11y/install-updates.ps1 b/scripts/windows-a11y/install-updates.ps1 index 417ac3b..af1c728 100644 --- a/scripts/windows-a11y/install-updates.ps1 +++ b/scripts/windows-a11y/install-updates.ps1 @@ -4,6 +4,41 @@ param() $ErrorActionPreference = 'Stop' $logPrefix = '[install-updates]' +function ConvertTo-HResultHex { + param( + [Parameter(Mandatory)] + [long]$HResult + ) + + $unsignedValue = $HResult -band 0xffffffffL + return '0x{0:X8}' -f $unsignedValue +} + +function Write-InstallationDiagnostics { + param( + [Parameter(Mandatory)] + $InstallResult, + + [Parameter(Mandatory)] + $Updates, + + [string]$Prefix = '[install-updates]' + ) + + $aggregateHResult = ConvertTo-HResultHex -HResult $InstallResult.HResult + Write-Output "$Prefix Install result code: $($InstallResult.ResultCode); HRESULT: $aggregateHResult" + + for ($index = 0; $index -lt $Updates.Count; $index++) { + $update = $Updates[$index] + $updateResult = $InstallResult.GetUpdateResult($index) + $updateHResult = ConvertTo-HResultHex -HResult $updateResult.HResult + Write-Output ( + "$Prefix Update result: $($update.Title); code: $($updateResult.ResultCode); " + + "HRESULT: $updateHResult; reboot required: $($updateResult.RebootRequired)" + ) + } +} + Write-Output "$logPrefix Searching for updates..." $updateSession = New-Object -ComObject Microsoft.Update.Session $updateSearcher = $updateSession.CreateUpdateSearcher() @@ -40,7 +75,7 @@ $installer = $updateSession.CreateUpdateInstaller() $installer.Updates = $updatesToInstall $installResult = $installer.Install() -Write-Output "$logPrefix Install result code: $($installResult.ResultCode)" +Write-InstallationDiagnostics -InstallResult $installResult -Updates $updatesToInstall Write-Output "$logPrefix Reboot required: $($installResult.RebootRequired)" if ($installResult.ResultCode -ne 2 -and $installResult.ResultCode -ne 3) { diff --git a/scripts/windows-a11y/run-windows-updates.sh b/scripts/windows-a11y/run-windows-updates.sh new file mode 100644 index 0000000..d785d25 --- /dev/null +++ b/scripts/windows-a11y/run-windows-updates.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -uo pipefail + +INSTANCE_ID="${1:?Usage: run-windows-updates.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SSM_RUN_SCRIPT="${SSM_RUN_SCRIPT:-${SCRIPT_DIR}/ssm-run.sh}" +: "${GITHUB_ENV:?GITHUB_ENV must point to the GitHub Actions environment file}" + +for pass in $(seq 1 5); do + set +e + OUTPUT=$(bash "${SSM_RUN_SCRIPT}" "${INSTANCE_ID}" "${SCRIPT_DIR}/install-updates.ps1" 3600) + SSM_STATUS=$? + set -e + + printf '%s\n' "${OUTPUT}" + if (( SSM_STATUS != 0 )); then + exit "${SSM_STATUS}" + fi + + if grep -q "No updates found." <<< "${OUTPUT}"; then + echo "No further updates." + break + fi + + if grep -q "REBOOT_REQUIRED=true" <<< "${OUTPUT}"; then + echo "Rebooting instance for updates (pass ${pass})..." + aws ec2 reboot-instances --instance-ids "${INSTANCE_ID}" + sleep 30 + aws ec2 wait instance-status-ok --instance-ids "${INSTANCE_ID}" + fi +done + +echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "${GITHUB_ENV}" diff --git a/scripts/windows-a11y/tests/create-ami.Tests.ps1 b/scripts/windows-a11y/tests/create-ami.Tests.ps1 new file mode 100644 index 0000000..91072b8 --- /dev/null +++ b/scripts/windows-a11y/tests/create-ami.Tests.ps1 @@ -0,0 +1,106 @@ +Describe 'AMI creation polling' { + BeforeEach { + $script:runnerPath = Join-Path $PSScriptRoot '..\create-ami.sh' + $script:binDirectory = Join-Path $TestDrive 'bin' + $script:stateFile = Join-Path $TestDrive 'describe-count' + $script:githubOutput = Join-Path $TestDrive 'github-output' + $script:originalPath = $env:PATH + + New-Item -ItemType Directory -Path $script:binDirectory -Force | Out-Null + Remove-Item -LiteralPath $script:stateFile -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $script:githubOutput -Value '' -NoNewline -Force + + $awsStub = Join-Path $script:binDirectory 'aws' + Set-Content -LiteralPath $awsStub -Value @' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1 $2" == "ec2 create-image" ]]; then + echo "ami-test123" + exit 0 +fi + +if [[ "$1 $2" == "ec2 describe-images" ]]; then + count=0 + if [[ -f "${AWS_STUB_STATE_FILE}" ]]; then + count=$(<"${AWS_STUB_STATE_FILE}") + fi + count=$((count + 1)) + printf '%s' "${count}" > "${AWS_STUB_STATE_FILE}" + + if [[ "${AWS_STUB_MODE}" == "not-found-then-available" && "${count}" == "1" ]]; then + echo "An error occurred (InvalidAMIID.NotFound) when calling DescribeImages" >&2 + exit 255 + elif [[ "${AWS_STUB_MODE}" == "failed" ]]; then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"failed","StateReason":{"Message":"snapshot failed"}}]}' + elif (( count >= AWS_STUB_AVAILABLE_AFTER )); then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"available"}]}' + else + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"pending"}]}' + fi + exit 0 +fi + +echo "unexpected aws invocation: $*" >&2 +exit 64 +'@ + & chmod +x $awsStub + + $env:PATH = "$script:binDirectory$([IO.Path]::PathSeparator)$script:originalPath" + $env:GITHUB_OUTPUT = $script:githubOutput + $env:AWS_STUB_STATE_FILE = $script:stateFile + $env:AMI_POLL_INTERVAL_SECONDS = '0' + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + AfterEach { + $env:PATH = $script:originalPath + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_STATE_FILE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_MODE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_AVAILABLE_AFTER -ErrorAction SilentlyContinue + Remove-Item Env:AMI_POLL_INTERVAL_SECONDS -ErrorAction SilentlyContinue + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + It 'continues polling beyond forty checks until the AMI is available' { + $env:AWS_STUB_MODE = 'available' + $env:AWS_STUB_AVAILABLE_AFTER = '41' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] Created AMI ami-test123.' + $output | Should -Contain '[create-ami] AMI ami-test123 state: pending (attempt 40 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 41 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '41' + (Get-Content -LiteralPath $script:githubOutput -Raw).Trim() | + Should -BeExactly 'ami_id=ami-test123' + } + + It 'retries when a newly created AMI is not visible yet' { + $env:AWS_STUB_MODE = 'not-found-then-available' + $env:AWS_STUB_AVAILABLE_AFTER = '2' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] AMI ami-test123 is not visible yet (attempt 1 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 2 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '2' + } + + It 'fails immediately and reports StateReason when the AMI enters failed state' { + $env:AWS_STUB_MODE = 'failed' + $env:AWS_STUB_AVAILABLE_AFTER = '999' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + $output | Should -Contain '[create-ami] AMI ami-test123 state: failed (attempt 1 of 120); reason: snapshot failed.' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '1' + } +} diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 new file mode 100644 index 0000000..9226f53 --- /dev/null +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -0,0 +1,227 @@ +BeforeAll { + if (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + function global:Get-AuthenticodeSignature { + param([string]$FilePath) + throw "Get-AuthenticodeSignature must be mocked on non-Windows hosts: $FilePath" + } + } + . (Join-Path $PSScriptRoot '..\install-software.ps1') -SkipExecution +} + +Describe 'official stable installer resolution' { + It 'returns the Mozilla latest stable zh-TW win64 endpoint' { + (Get-FirefoxInstallerUri).AbsoluteUri | Should -BeExactly ` + 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + + It 'accepts one numeric NVDA stable patch release' { + $content = 'nvda_2026.1.1.exe' + $uri = Get-NvdaStableInstallerUri -Content $content + $uri.AbsoluteUri | Should -BeExactly ` + 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' + } + + It 'rejects NVDA beta and release-candidate installers' { + $content = @' +beta +rc +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*exactly one numeric stable NVDA installer*' + } + + It 'rejects an ambiguous stable listing' { + $content = @' +first +second +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*found 2*' + } +} + +Describe 'publisher and exit-code validation' { + It 'accepts a valid expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=Mozilla Corporation, O=Mozilla Corporation, C=US' + } + } + } + { Assert-AuthenticodePublisher -Path 'firefox.exe' -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' } | + Should -Not -Throw + } + + It 'rejects an unexpected publisher even when the signature is valid' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Unexpected, O=Unexpected, C=US' } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects an invalid signature from the expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'HashMismatch' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=NV Access Limited, O=NV Access Limited, C=AU' + } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects a nonzero executable installer exit code' { + { Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode 1 } | + Should -Throw '*exit code 1*' + } +} + +Describe 'bounded official download retries' { + It 'rejects download retry counts above three' { + Mock Invoke-WebRequest + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -MaxAttempts 4 } | + Should -Throw '*MaxAttempts*' + } + + It 'retries twice and succeeds on the third attempt' { + $script:attempt = 0 + Mock Invoke-WebRequest { + $script:attempt++ + if ($script:attempt -lt 3) { throw 'temporary failure' } + } + Mock Start-Sleep + + Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + } + + It 'fails after three requests with contextual retry warnings' { + Mock Invoke-WebRequest { throw 'temporary failure' } + Mock Start-Sleep + Mock Write-Warning + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -Operation 'Firefox installer download' } | + Should -Throw '*failed after 3 attempts*' + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + Should -Invoke Start-Sleep -Times 2 -Exactly + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 1 of 3*15*' + } + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 2 of 3*30*' + } + } +} + +Describe 'NVDA executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } +} + +Describe 'official product installers' { + BeforeEach { + $env:TEMP = $TestDrive + Mock New-Item + Mock Remove-Item + Mock Invoke-WebRequestWithRetry + Mock Assert-AuthenticodePublisher + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Assert-InstallerExitCode + } + + It 'downloads and silently installs Firefox zh-TW win64' { + Install-Firefox + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' -and + $Operation -eq 'Firefox installer download' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'Firefox' -and + $PublisherPattern -eq '(^|, )O=Mozilla Corporation(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '/S' -and $Wait -and $PassThru + } + } + + It 'resolves and silently installs the official stable NVDA build' { + Mock Invoke-WebRequestWithRetry { + [pscustomobject]@{ Content = 'download' } + } -ParameterFilter { -not $OutFile } + + Install-Nvda + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile -and + $Operation -eq 'NVDA stable listing download' + } + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' -and + $Operation -eq 'NVDA installer download' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'NVDA' -and + $PublisherPattern -eq '(^|, )O=NV Access Limited(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '--install-silent' -and $Wait -and $PassThru + } + } +} + +Describe 'workflow version output contract' { + It 'emits the three existing VERSION keys' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + VersionInfo = [pscustomobject]@{ + ProductVersion = '1.2.3' + FileVersion = '1.2.3.0' + } + } + } + $executables = [ordered]@{ + GOOGLECHROME = 'C:\Google\chrome.exe' + FIREFOX = 'C:\Mozilla Firefox\firefox.exe' + NVDA = 'C:\NVDA\nvda.exe' + } + + $output = @(Write-InstalledSoftwareVersions -SoftwareExecutables $executables) + + $output | Should -Contain 'VERSION_GOOGLECHROME=1.2.3' + $output | Should -Contain 'VERSION_FIREFOX=1.2.3' + $output | Should -Contain 'VERSION_NVDA=1.2.3' + @($output | Where-Object { $_ -like 'VERSION_*=*' }).Count | Should -Be 3 + } +} diff --git a/scripts/windows-a11y/tests/install-updates.Tests.ps1 b/scripts/windows-a11y/tests/install-updates.Tests.ps1 new file mode 100644 index 0000000..95f2b73 --- /dev/null +++ b/scripts/windows-a11y/tests/install-updates.Tests.ps1 @@ -0,0 +1,55 @@ +Describe 'Windows Update installation diagnostics' { + BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..\install-updates.ps1' + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, + [ref]$tokens, + [ref]$errors + ) + $functionAsts = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('ConvertTo-HResultHex', 'Write-InstallationDiagnostics') + }, $true) + + @($functionAsts).Count | Should -Be 2 + foreach ($functionAst in $functionAsts) { + Invoke-Expression $functionAst.Extent.Text + } + } + + It 'reports aggregate and per-update result codes and HRESULT values' { + $firstResult = [pscustomobject]@{ + ResultCode = 2 + HResult = 0 + RebootRequired = $false + } + $secondResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + } + $installResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + UpdateResults = @($firstResult, $secondResult) + } + $installResult | Add-Member -MemberType ScriptMethod -Name GetUpdateResult -Value { + param($index) + $this.UpdateResults[$index] + } + $updates = @( + [pscustomobject]@{ Title = 'Successful update' }, + [pscustomobject]@{ Title = 'Failed update' } + ) + + $output = @(Write-InstallationDiagnostics -InstallResult $installResult -Updates $updates) + + $output | Should -Contain '[install-updates] Install result code: 4; HRESULT: 0x80240017' + $output | Should -Contain '[install-updates] Update result: Successful update; code: 2; HRESULT: 0x00000000; reboot required: False' + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017; reboot required: True' + } +} diff --git a/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 new file mode 100644 index 0000000..f4864bd --- /dev/null +++ b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 @@ -0,0 +1,25 @@ +Describe 'Windows Update workflow runner' { + BeforeEach { + $script:runnerPath = Join-Path $PSScriptRoot '..\run-windows-updates.sh' + $script:ssmStub = Join-Path $TestDrive 'ssm-stub.sh' + $script:githubEnv = Join-Path $TestDrive 'github-env' + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +echo "[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017" +exit 7 +'@ + New-Item -ItemType File -Path $script:githubEnv | Out-Null + } + + It 'prints captured SSM stdout and preserves a failed exit status' { + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 7 + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017' + (Get-Content -LiteralPath $script:githubEnv -Raw) | Should -BeNullOrEmpty + } +} diff --git a/scripts/windows-a11y/tests/verify-environment.Tests.ps1 b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 new file mode 100644 index 0000000..4cf8a87 --- /dev/null +++ b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 @@ -0,0 +1,19 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\verify-environment.ps1') -SkipExecution +} + +Describe 'NVDA verification executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } +} diff --git a/scripts/windows-a11y/verify-environment.ps1 b/scripts/windows-a11y/verify-environment.ps1 index 28e2f91..7ae6ec9 100644 --- a/scripts/windows-a11y/verify-environment.ps1 +++ b/scripts/windows-a11y/verify-environment.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' @@ -25,32 +25,48 @@ function Find-GoogleChromeExecutable { return $candidates | Select-Object -Unique | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 } -$results = [ordered]@{} +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} -$results.ChromePath = Find-GoogleChromeExecutable -$results.ChromeInstalled = [bool]$results.ChromePath -$results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' -$results.NvdaInstalled = Test-Path 'C:\Program Files (x86)\NVDA\nvda.exe' +function Invoke-EnvironmentVerification { + $results = [ordered]@{} -$rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections -$results.RdpEnabled = ($rdpValue -eq 0) + $results.ChromePath = Find-GoogleChromeExecutable + $results.ChromeInstalled = [bool]$results.ChromePath + $results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' + $nvdaPath = Find-NvdaExecutable + $results.NvdaInstalled = [bool]$nvdaPath -$results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) -$results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) -$results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) -$results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name -$results.DisplayLanguage = Get-SystemPreferredUILanguage -$results.SystemLocale = (Get-WinSystemLocale).Name -$results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) -$results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') + $rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections + $results.RdpEnabled = ($rdpValue -eq 0) -$checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') -$allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) -$results.AllChecksPassed = $allPassed + $results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) + $results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) + $results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) + $results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name + $results.DisplayLanguage = Get-SystemPreferredUILanguage + $results.SystemLocale = (Get-WinSystemLocale).Name + $results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) + $results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') -$json = $results | ConvertTo-Json -Compress -Write-Output "VERIFY_RESULT_JSON=$json" + $checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') + $allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) + $results.AllChecksPassed = $allPassed + + $json = $results | ConvertTo-Json -Compress + Write-Output "VERIFY_RESULT_JSON=$json" + + if (-not $allPassed) { + throw "Environment verification failed: $json" + } +} -if (-not $allPassed) { - throw "Environment verification failed: $json" +if (-not $SkipExecution) { + Invoke-EnvironmentVerification }