Files
story/scripts/smoke-media-upload.ps1

258 lines
20 KiB
PowerShell

param([switch]$KeepFixture)
$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'
$tenantId = '10000000-0000-0000-0000-000000000001'
$householdId = '10000000-0000-0000-0000-000000000201'
$subjectId = '10000000-0000-0000-0000-000000000401'
$subjectPublicId = '10000000-0000-0000-0000-000000000411'
$deviceInternalId = '10000000-0000-0000-0000-000000000501'
$deviceId = '10000000-0000-0000-0000-000000000511'
$ownerId = '10000000-0000-0000-0000-000000000101'
$ownerPublicId = '10000000-0000-0000-0000-000000000111'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversationId = [Guid]::NewGuid().ToString()
$conversationInternalId = [Guid]::NewGuid().ToString()
$consentId = [Guid]::NewGuid().ToString()
$snapshotId = [Guid]::NewGuid().ToString()
$contextId = [Guid]::NewGuid().ToString()
$nearFile = Join-Path ([IO.Path]::GetTempPath()) ('media-near-' + [Guid]::NewGuid().ToString('N') + '.bin')
$farFile = Join-Path ([IO.Path]::GetTempPath()) ('media-far-' + [Guid]::NewGuid().ToString('N') + '.bin')
$objectRefs = @()
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() }
finally { $sha.Dispose() }
}
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 New-SyntheticWav([byte]$Marker) {
$sampleRate = 8000
$channels = 1
$bitsPerSample = 16
$sampleCount = 800
$dataSize = $sampleCount * $channels * ($bitsPerSample / 8)
$stream = [IO.MemoryStream]::new()
$writer = [IO.BinaryWriter]::new($stream)
try {
$writer.Write([Text.Encoding]::ASCII.GetBytes('RIFF'))
$writer.Write([uint32](36 + $dataSize))
$writer.Write([Text.Encoding]::ASCII.GetBytes('WAVE'))
$writer.Write([Text.Encoding]::ASCII.GetBytes('fmt '))
$writer.Write([uint32]16)
$writer.Write([uint16]1)
$writer.Write([uint16]$channels)
$writer.Write([uint32]$sampleRate)
$writer.Write([uint32]($sampleRate * $channels * ($bitsPerSample / 8)))
$writer.Write([uint16]($channels * ($bitsPerSample / 8)))
$writer.Write([uint16]$bitsPerSample)
$writer.Write([Text.Encoding]::ASCII.GetBytes('data'))
$writer.Write([uint32]$dataSize)
$samples = [byte[]]::new($dataSize)
$samples[0] = $Marker
$writer.Write($samples)
$writer.Flush()
return $stream.ToArray()
}
finally { $writer.Dispose(); $stream.Dispose() }
}
function Invoke-DeviceRequest([string]$Method, [string]$Path, [string]$Body = '', [hashtable]$ExtraHeaders = @{}) {
$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 = [Security.Cryptography.HMACSHA256]::new($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
}
foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value }
$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 Wait-Batch([string]$BatchId, [string]$ExpectedStatus) {
for ($attempt = 0; $attempt -lt 40; $attempt++) {
$response = Invoke-DeviceRequest 'GET' "/device/v2/upload-batches/$BatchId"
$batch = ($response.Content | ConvertFrom-Json).data
if ($batch.status -eq $ExpectedStatus) { return $batch }
Start-Sleep -Milliseconds 250
}
throw "Media batch $BatchId did not reach $ExpectedStatus"
}
try {
$nearBytes = New-SyntheticWav 1
$farBytes = New-SyntheticWav 2
[IO.File]::WriteAllBytes($nearFile, $nearBytes)
[IO.File]::WriteAllBytes($farFile, $farBytes)
$nearDigest = Get-Sha256Hex $nearBytes
$farDigest = Get-Sha256Hex $farBytes
$fixtureSql = @"
BEGIN;
INSERT INTO consent_record(id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref,granted_at)
VALUES('$consentId'::uuid,gen_random_uuid(),'$tenantId'::uuid,'$subjectId'::uuid,'raw_audio_storage','{"scope":"synthetic_media_smoke"}'::jsonb,'local-v1',repeat('e',64),'$ownerId'::uuid,'local-synthetic://media-smoke',now());
INSERT INTO consent_snapshot(id,public_id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
VALUES('$snapshotId'::uuid,gen_random_uuid(),'$tenantId'::uuid,'$subjectId'::uuid,999,'{"voice_chat":true,"transcript_storage":true,"raw_audio_storage":true}'::jsonb,jsonb_build_array('$consentId'));
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
VALUES('$contextId'::uuid,gen_random_uuid(),'$tenantId'::uuid,'$subjectId'::uuid,1,'$snapshotId'::uuid,'[]'::jsonb,'media-smoke-v1','s2s_hybrid',repeat('f',64),'synthetic media smoke');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at)
VALUES('$conversationInternalId'::uuid,'$conversationId'::uuid,'$tenantId'::uuid,'$householdId'::uuid,'$subjectId'::uuid,'$deviceInternalId'::uuid,'$snapshotId'::uuid,'$contextId'::uuid,'media-smoke-$conversationId','s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '1 hour',now()-interval '2 minutes',now()-interval '1 minute');
INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status,lease_expires_at,last_checked_at)
VALUES('$conversationInternalId'::uuid,'mock','media-smoke-task-$conversationId','media-smoke-room-$conversationId','device-media-smoke','agent-media-smoke','COMPLETED',now()-interval '1 minute',now());
COMMIT;
"@
$fixtureSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
$segments = @(
@{ track = 'near'; sequence_no = 0; codec = 'wav_pcm_s16le'; content_type = 'audio/wav'; duration_ms = 100; expected_size = $nearBytes.Length; expected_digest = $nearDigest },
@{ track = 'far'; sequence_no = 0; codec = 'wav_pcm_s16le'; content_type = 'audio/wav'; duration_ms = 100; expected_size = $farBytes.Length; expected_digest = $farDigest }
)
$body = @{ segments = $segments } | ConvertTo-Json -Compress -Depth 5
$idempotencyKey = 'media-smoke-' + [Guid]::NewGuid().ToString('N')
$headers = @{ 'Idempotency-Key' = $idempotencyKey }
$preparedResponse = Invoke-DeviceRequest 'POST' "/device/v2/conversations/$conversationId/upload-batches" $body $headers
$prepared = ($preparedResponse.Content | ConvertFrom-Json).data
if ($prepared.status -ne 'UPLOADING' -or $prepared.segments.Count -ne 2) { throw 'Media upload batch was not prepared' }
$replayed = ((Invoke-DeviceRequest 'POST' "/device/v2/conversations/$conversationId/upload-batches" $body $headers).Content | ConvertFrom-Json).data
if ($replayed.id -ne $prepared.id) { throw 'Idempotent media manifest replay created another batch' }
$conflictSegments = @($segments[0].Clone(), $segments[1].Clone())
$conflictSegments[0].duration_ms = 5001
$conflictBody = @{ segments = $conflictSegments } | ConvertTo-Json -Compress -Depth 5
Assert-HttpStatus { Invoke-DeviceRequest 'POST' "/device/v2/conversations/$conversationId/upload-batches" $conflictBody $headers } 409
foreach ($segment in $prepared.segments) {
if (-not $segment.upload_url) { throw "Missing upload URL for $($segment.track)" }
$source = if ($segment.track -eq 'near') { $nearFile } else { $farFile }
Invoke-WebRequest -UseBasicParsing -Method Put -Uri $segment.upload_url -InFile $source -ContentType $segment.content_type | Out-Null
}
Invoke-DeviceRequest 'POST' "/device/v2/upload-batches/$($prepared.id)/complete" | Out-Null
$completed = Wait-Batch $prepared.id 'COMPLETED'
if ($completed.verified_count -ne 2 -or ($completed.segments | Where-Object { $_.status -ne 'VERIFIED' -or -not $_.local_delete_after })) {
throw 'Verified media batch did not expose delayed local deletion evidence'
}
$ownerHeaders = @{ Authorization = 'Bearer ' + (Get-AccessToken 'owner') }
$editorHeaders = @{ Authorization = 'Bearer ' + (Get-AccessToken 'editor') }
$nearSegment = $completed.segments | Where-Object { $_.track -eq 'near' } | Select-Object -First 1
$farSegment = $completed.segments | Where-Object { $_.track -eq 'far' } | Select-Object -First 1
Assert-HttpStatus { Invoke-DeviceRequest 'POST' "/device/v2/media-segments/$($nearSegment.id)/local-delete-ack" (@{ status = 'DELETED' } | ConvertTo-Json -Compress) } 409
$failedDelete = ((Invoke-DeviceRequest 'POST' "/device/v2/media-segments/$($nearSegment.id)/local-delete-ack" (@{ status = 'FAILED'; error_code = 'LOCAL_FILE_BUSY' } | ConvertTo-Json -Compress)).Content | ConvertFrom-Json).data
if ($failedDelete.local_delete_status -ne 'FAILED' -or $failedDelete.local_delete_error_code -ne 'LOCAL_FILE_BUSY') { throw 'Device local deletion failure receipt was not persisted' }
"UPDATE media_segment SET local_delete_after=now()-interval '1 second' WHERE public_id IN ('$($nearSegment.id)'::uuid,'$($farSegment.id)'::uuid);" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
$deletedReceipt = ((Invoke-DeviceRequest 'POST' "/device/v2/media-segments/$($nearSegment.id)/local-delete-ack" (@{ status = 'DELETED' } | ConvertTo-Json -Compress)).Content | ConvertFrom-Json).data
$replayedReceipt = ((Invoke-DeviceRequest 'POST' "/device/v2/media-segments/$($nearSegment.id)/local-delete-ack" (@{ status = 'DELETED' } | ConvertTo-Json -Compress)).Content | ConvertFrom-Json).data
if ($deletedReceipt.local_delete_status -ne 'DELETED' -or -not $deletedReceipt.local_delete_reported_at -or $replayedReceipt.local_delete_reported_at -ne $deletedReceipt.local_delete_reported_at) { throw 'Device local deletion receipt was not successful and idempotent' }
$farDeletedReceipt = ((Invoke-DeviceRequest 'POST' "/device/v2/media-segments/$($farSegment.id)/local-delete-ack" (@{ status = 'DELETED' } | ConvertTo-Json -Compress)).Content | ConvertFrom-Json).data
if ($farDeletedReceipt.local_delete_status -ne 'DELETED') { throw 'Second track local deletion receipt was not persisted' }
$accessBody = @{ action = 'PLAY'; purpose = 'synthetic playback verification' } | ConvertTo-Json -Compress
Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/media-segments/$($nearSegment.id)/access" -Headers $ownerHeaders -ContentType 'application/json' -Body $accessBody } 403
$grantBody = @{
grantee_user_id = $ownerPublicId
permission = 'PLAY'
purpose = 'synthetic media smoke'
expires_at = [DateTime]::UtcNow.AddHours(1).ToString('o')
} | ConvertTo-Json -Compress
Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/subjects/$subjectPublicId/recording-access-grants" -Headers $editorHeaders -ContentType 'application/json' -Body $grantBody } 403
Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/subjects/$subjectPublicId/recording-access-grants" -Headers $editorHeaders } 403
$playGrant = ((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/subjects/$subjectPublicId/recording-access-grants" -Headers $ownerHeaders -ContentType 'application/json' -Body $grantBody).Content | ConvertFrom-Json).data
$grantList = ((Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/subjects/$subjectPublicId/recording-access-grants" -Headers $ownerHeaders).Content | ConvertFrom-Json).data.items
if (-not ($grantList | Where-Object { $_.id -eq $playGrant.id })) { throw 'Owner grant list did not return the newly created explicit grant' }
$mediaView = ((Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/conversations/$conversationId/media" -Headers $ownerHeaders).Content | ConvertFrom-Json).data
if (-not $mediaView.can_play -or $mediaView.can_download -or $mediaView.segments.Count -ne 2) { throw 'Recording media view did not apply independent play/download grants' }
$playAccess = ((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/media-segments/$($nearSegment.id)/access" -Headers $ownerHeaders -ContentType 'application/json' -Body $accessBody).Content | ConvertFrom-Json).data
$playedBytes = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri $playAccess.access_url).Content
if ($playedBytes.Length -ne $nearBytes.Length) { throw 'Short-lived playback URL did not return the expected object' }
$downloadBody = @{ action = 'DOWNLOAD'; purpose = 'synthetic download verification' } | ConvertTo-Json -Compress
Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/media-segments/$($nearSegment.id)/access" -Headers $ownerHeaders -ContentType 'application/json' -Body $downloadBody } 403
$downloadGrantBody = @{
grantee_user_id = $ownerPublicId
permission = 'DOWNLOAD'
purpose = 'synthetic media smoke'
expires_at = [DateTime]::UtcNow.AddHours(1).ToString('o')
} | ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/subjects/$subjectPublicId/recording-access-grants" -Headers $ownerHeaders -ContentType 'application/json' -Body $downloadGrantBody | Out-Null
$downloadAccess = ((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/media-segments/$($nearSegment.id)/access" -Headers $ownerHeaders -ContentType 'application/json' -Body $downloadBody).Content | ConvertFrom-Json).data
if (-not $downloadAccess.access_url) { throw 'Independent download grant did not issue a short-lived URL' }
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/recording-access-grants/$($playGrant.id)/revoke" -Headers $ownerHeaders | Out-Null
Assert-HttpStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/media-segments/$($nearSegment.id)/access" -Headers $ownerHeaders -ContentType 'application/json' -Body $accessBody } 403
$badBody = @{ segments = @(@{ track = 'near'; sequence_no = 1; codec = 'wav_pcm_s16le'; content_type = 'audio/wav'; duration_ms = 100; expected_size = $nearBytes.Length; expected_digest = (('0' * 64) -join '') }) } | ConvertTo-Json -Compress -Depth 5
$badHeaders = @{ 'Idempotency-Key' = ('media-smoke-bad-' + [Guid]::NewGuid().ToString('N')) }
$badPrepared = ((Invoke-DeviceRequest 'POST' "/device/v2/conversations/$conversationId/upload-batches" $badBody $badHeaders).Content | ConvertFrom-Json).data
Invoke-WebRequest -UseBasicParsing -Method Put -Uri $badPrepared.segments[0].upload_url -InFile $nearFile -ContentType 'audio/wav' | Out-Null
Invoke-DeviceRequest 'POST' "/device/v2/upload-batches/$($badPrepared.id)/complete" | Out-Null
$failed = Wait-Batch $badPrepared.id 'FAILED'
if ($failed.rejected_count -ne 1 -or $failed.segments[0].last_error_code -ne 'DIGEST_MISMATCH') { throw 'Wrong digest was not rejected deterministically' }
$accessCounts = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT count(*) FILTER(WHERE result='SUCCESS'),count(*) FILTER(WHERE result='DENIED') FROM media_access_log WHERE conversation_id='$conversationInternalId'::uuid" | Out-String).Trim()
if ($accessCounts -ne '2|3') { throw "Recording access audit counts were unexpected: $accessCounts" }
Write-Host ('Media upload/access/delete-receipt smoke passed: ' + $prepared.id)
}
finally {
if (-not $KeepFixture) {
$refs = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT ms.object_ref FROM media_segment ms WHERE ms.conversation_id='$conversationInternalId'::uuid" | Out-String
$objectRefs = $refs -split "`r?`n" | Where-Object { $_.Trim() }
$cleanupSql = @"
BEGIN;
DELETE FROM web_event WHERE resource_public_id IN (SELECT public_id FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid) OR resource_public_id=(SELECT public_id FROM conversation WHERE id='$conversationInternalId'::uuid);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM media_segment WHERE conversation_id='$conversationInternalId'::uuid);
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT public_id FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid);
DELETE FROM media_access_log WHERE conversation_id='$conversationInternalId'::uuid;
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM recording_access_grant WHERE subject_id='$subjectId'::uuid AND purpose='synthetic media smoke');
DELETE FROM recording_access_grant WHERE subject_id='$subjectId'::uuid AND purpose='synthetic media smoke';
DELETE FROM upload_batch_segment WHERE upload_batch_id IN (SELECT id FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid);
DELETE FROM media_segment WHERE conversation_id='$conversationInternalId'::uuid;
DELETE FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid;
DELETE FROM rtc_task WHERE conversation_id='$conversationInternalId'::uuid;
DELETE FROM conversation WHERE id='$conversationInternalId'::uuid;
DELETE FROM context_snapshot WHERE id='$contextId'::uuid;
DELETE FROM consent_snapshot WHERE id='$snapshotId'::uuid;
DELETE FROM consent_record WHERE id='$consentId'::uuid;
COMMIT;
"@
$cleanupSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | 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 }
}
} else {
Write-Host ('Visual fixture retained for conversation: ' + $conversationId)
}
if (Test-Path -LiteralPath $nearFile) { Remove-Item -LiteralPath $nearFile -Force }
if (Test-Path -LiteralPath $farFile) { Remove-Item -LiteralPath $farFile -Force }
}