275 lines
14 KiB
PowerShell
275 lines
14 KiB
PowerShell
$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
|
|
}
|
|
}
|