$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $projectRoot $api = 'http://localhost:58080' $tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token' $householdId = '10000000-0000-0000-0000-000000000211' $deviceId = '10000000-0000-0000-0000-000000000511' $deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum') $tempFile = Join-Path ([IO.Path]::GetTempPath()) ('firmware-smoke-' + [Guid]::NewGuid().ToString('N') + '.bin') $downloadFile = $tempFile + '.download' $keyIds = @() $objectRefs = @() function Get-AccessToken([string]$UserName) { return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{ grant_type = 'password' client_id = 'voice-platform-web' username = $UserName password = 'LocalOnly123!' scope = 'openid' }).access_token } function Get-Sha256Hex([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Invoke-DeviceRequest([string]$Method, [string]$Path, [string]$Body = '') { $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body) $digest = Get-Sha256Hex $bodyBytes $timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') $nonce = [Guid]::NewGuid().ToString('N') $canonical = "$Method`n$Path`n$timestamp`n$nonce`n$digest" $hmac = New-Object Security.Cryptography.HMACSHA256 $hmac.Key = $deviceSecret try { $signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_') } finally { $hmac.Dispose() } $headers = @{ 'X-Device-Id' = $deviceId 'X-Device-Timestamp' = $timestamp 'X-Device-Nonce' = $nonce 'X-Device-Content-SHA256' = $digest 'X-Device-Signature' = $signature } $arguments = @{ UseBasicParsing = $true Method = $Method Uri = $api + $Path Headers = $headers } if ($Body) { $arguments.ContentType = 'application/json' $arguments.Body = $Body } return Invoke-WebRequest @arguments } function Assert-HttpStatus([scriptblock]$Action, [int]$ExpectedStatus) { try { $response = & $Action if ($response.StatusCode -ne $ExpectedStatus) { throw "Expected HTTP $ExpectedStatus but received $($response.StatusCode)" } } catch { $actualStatus = $_.Exception.Response.StatusCode.value__ if ($actualStatus -ne $ExpectedStatus) { throw } } } function Register-SigningKey([string]$KeyId, [string]$PublicKey, [hashtable]$Headers) { $body = @{ key_id = $KeyId; public_key_base64 = $PublicKey } | ConvertTo-Json -Compress Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $Headers -ContentType 'application/json' -Body $body | Out-Null $script:keyIds += $KeyId } function Prepare-Release([object]$Signed, [string]$Version, [string]$KeyId, [hashtable]$Headers, [string]$Signature) { $body = @{ model = 'simulated-terminal' hardware_revision = 'sim-rev-a' version = $Version expected_size = [int64]$Signed.size expected_digest = $Signed.sha256 signature = $Signature signing_key_id = $KeyId minimum_security_version = '1.0.0' release_notes = 'synthetic smoke firmware' } | ConvertTo-Json -Compress $response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $Headers -ContentType 'application/json' -Body $body $prepared = $response.Content | ConvertFrom-Json if (-not $prepared.data.upload_url) { throw 'Firmware upload session was not created' } $preflight = Invoke-WebRequest -UseBasicParsing -Method Options -Uri $prepared.data.upload_url -Headers @{ Origin = 'http://localhost:3100' 'Access-Control-Request-Method' = 'PUT' 'Access-Control-Request-Headers' = 'content-type' } if (-not $preflight.Headers['Access-Control-Allow-Origin']) { throw 'Object storage did not allow the web origin for direct firmware upload' } Invoke-WebRequest -UseBasicParsing -Method Put -Uri $prepared.data.upload_url -InFile $tempFile -ContentType 'application/octet-stream' | Out-Null return $prepared.data } function Finalize-Release([string]$ReleaseId, [hashtable]$Headers) { $response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$ReleaseId/finalize" -Headers $Headers return ($response.Content | ConvertFrom-Json).data } function Assert-AssignmentProgress([string]$RolloutId, [hashtable]$Headers, [string]$ExpectedStatus, [int]$ExpectedProgress) { $response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/firmware-rollouts/$RolloutId" -Headers $Headers $detail = ($response.Content | ConvertFrom-Json).data $assignment = $detail.assignments | Where-Object { $_.device_id -eq $deviceId } | Select-Object -First 1 if (-not $assignment) { throw "Rollout $RolloutId did not expose the target assignment" } if ($assignment.status -ne $ExpectedStatus -or [int]$assignment.progress_percent -ne $ExpectedProgress) { throw "Rollout assignment was $($assignment.status)/$($assignment.progress_percent), expected $ExpectedStatus/$ExpectedProgress" } } try { [IO.File]::WriteAllBytes($tempFile, [Text.Encoding]::UTF8.GetBytes('synthetic-firmware-' + [Guid]::NewGuid().ToString('N'))) $ownerToken = Get-AccessToken 'owner' $editorToken = Get-AccessToken 'editor' $ownerHeaders = @{ Authorization = "Bearer $ownerToken" } $editorHeaders = @{ Authorization = "Bearer $editorToken" } $newVersion = '9.9.' + (Get-Random -Minimum 1000 -Maximum 9999) $newSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $newVersion $tempFile | ConvertFrom-Json $newKeyId = 'smoke-new-' + [Guid]::NewGuid().ToString('N') $newKeyBody = @{ key_id = $newKeyId; public_key_base64 = $newSigned.public_key_base64 } | ConvertTo-Json -Compress Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $editorHeaders -ContentType 'application/json' -Body $newKeyBody } 403 Register-SigningKey $newKeyId $newSigned.public_key_base64 $ownerHeaders $badVersion = $newVersion + '-bad-signature' $badRelease = Prepare-Release $newSigned $badVersion $newKeyId $ownerHeaders $newSigned.signature_base64 Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$($badRelease.id)/finalize" -Headers $ownerHeaders } 409 $newRelease = Prepare-Release $newSigned $newVersion $newKeyId $ownerHeaders $newSigned.signature_base64 $newReady = Finalize-Release $newRelease.id $ownerHeaders if ($newReady.status -ne 'READY') { throw 'Valid signed firmware did not become READY' } $releaseList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $ownerHeaders).Content | ConvertFrom-Json if (-not ($releaseList.data.items | Where-Object { $_.id -eq $newRelease.id -and $_.status -eq 'READY' })) { throw 'Firmware release list did not expose the verified release' } $oldVersion = '9.8.' + (Get-Random -Minimum 1000 -Maximum 9999) $oldSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $oldVersion $tempFile | ConvertFrom-Json $oldKeyId = 'smoke-old-' + [Guid]::NewGuid().ToString('N') Register-SigningKey $oldKeyId $oldSigned.public_key_base64 $ownerHeaders $oldRelease = Prepare-Release $oldSigned $oldVersion $oldKeyId $ownerHeaders $oldSigned.signature_base64 $oldReady = Finalize-Release $oldRelease.id $ownerHeaders if ($oldReady.status -ne 'READY') { throw 'Rollback firmware did not become READY' } $rolloutBody = @{ release_id = $newRelease.id name = 'synthetic canary' target_percentage = 100 device_ids = @($deviceId) } | ConvertTo-Json -Compress $rolloutResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders -ContentType 'application/json' -Body $rolloutBody $rollout = ($rolloutResponse.Content | ConvertFrom-Json).data $rolloutList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders).Content | ConvertFrom-Json if (-not ($rolloutList.data.items | Where-Object { $_.id -eq $rollout.id -and $_.status -eq 'RUNNING' })) { throw 'Firmware rollout list did not expose the running rollout' } $pausedResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/pause" -Headers $ownerHeaders $paused = ($pausedResponse.Content | ConvertFrom-Json).data if ($paused.status -ne 'PAUSED') { throw 'Firmware rollout did not pause' } $cancelledState = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT dc.status FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$($rollout.id)'::uuid" | Out-String).Trim() if ($cancelledState -ne 'CANCELLED') { throw "Queued firmware command was not cancelled: $cancelledState" } Assert-HttpStatus { Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($newRelease.id)/download" } 403 $rollbackBody = @{ rollback_release_id = $oldRelease.id } | ConvertTo-Json -Compress $rollbackResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/rollback" -Headers $ownerHeaders -ContentType 'application/json' -Body $rollbackBody $rollback = ($rollbackResponse.Content | ConvertFrom-Json).data $pulledResponse = Invoke-DeviceRequest 'GET' '/device/v2/commands?limit=20' $pulled = $pulledResponse.Content | ConvertFrom-Json $command = $pulled.data.items | Where-Object { $_.type -eq 'firmware_update' -and $_.payload.release_id -eq $oldRelease.id } if (-not $command) { throw 'Device did not receive the rollback firmware command' } $downloadResponse = Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($oldRelease.id)/download" $download = $downloadResponse.Content | ConvertFrom-Json Invoke-WebRequest -UseBasicParsing -Uri $download.data.download_url -OutFile $downloadFile $actualDigest = (Get-FileHash -LiteralPath $downloadFile -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualDigest -ne $oldSigned.sha256) { throw 'Downloaded rollback firmware digest does not match' } $downloadAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'downloading'; progress_percent = 25 } } | ConvertTo-Json -Compress Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $downloadAck | Out-Null Assert-AssignmentProgress $rollback.id $ownerHeaders 'DOWNLOADING' 25 $installAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'installing'; progress_percent = 80 } } | ConvertTo-Json -Compress Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $installAck | Out-Null Assert-AssignmentProgress $rollback.id $ownerHeaders 'INSTALLING' 80 $ackBody = @{ status = 'SUCCEEDED'; result_summary = @{ installed_version = $oldVersion } } | ConvertTo-Json -Compress Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $ackBody | Out-Null Assert-AssignmentProgress $rollback.id $ownerHeaders 'CONFIRMED' 100 Start-Sleep -Milliseconds 300 $state = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT ro.status||'|'||fa.status FROM firmware_rollout ro JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.public_id='$($rollback.id)'::uuid" | Out-String).Trim() if ($state -ne 'COMPLETED|CONFIRMED') { throw "Rollback rollout did not converge: $state" } Write-Host "Firmware signature, rejection, pause, and rollback smoke passed: $($rollout.id) -> $($rollback.id)" } finally { if ($keyIds.Count -gt 0) { $keyList = ($keyIds | ForEach-Object { "'$_'" }) -join ',' $refs = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT object_ref FROM firmware_release WHERE signing_key_id IN ($keyList)" | Out-String $objectRefs = $refs -split "`r?`n" | Where-Object { $_.Trim() } $sql = @" BEGIN; CREATE TEMP TABLE smoke_releases AS SELECT id,public_id FROM firmware_release WHERE signing_key_id IN($keyList); CREATE TEMP TABLE smoke_rollouts AS SELECT id,public_id FROM firmware_rollout WHERE release_id IN(SELECT id FROM smoke_releases); CREATE TEMP TABLE smoke_commands AS SELECT dc.id,dc.public_id FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id WHERE fa.rollout_id IN(SELECT id FROM smoke_rollouts); DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM smoke_releases UNION SELECT public_id FROM smoke_rollouts UNION SELECT public_id FROM smoke_commands); DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM smoke_commands); DELETE FROM firmware_assignment WHERE rollout_id IN(SELECT id FROM smoke_rollouts); DELETE FROM firmware_rollout WHERE id IN(SELECT id FROM smoke_rollouts); DELETE FROM device_command WHERE id IN(SELECT id FROM smoke_commands); DELETE FROM firmware_release WHERE id IN(SELECT id FROM smoke_releases); DELETE FROM firmware_signing_key WHERE key_id IN($keyList); COMMIT; "@ docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null } if ($objectRefs.Count -gt 0) { docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password | Out-Null foreach ($objectRef in $objectRefs) { docker compose exec -T minio mc rm --force "smoke/private-exports/$($objectRef.Trim())" | Out-Null } } if (Test-Path -LiteralPath $downloadFile) { Remove-Item -LiteralPath $downloadFile -Force } if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force } }