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 } }