$ErrorActionPreference='Stop' $PSNativeCommandUseErrorActionPreference=$true $root=Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $root $api='http://localhost:58080' $callbacks='http://localhost:58081' $tokenUrl='http://localhost:58180/realms/voice-local/protocol/openid-connect/token' $tenantPublicId='10000000-0000-0000-0000-000000000010' $deviceId='10000000-0000-0000-0000-000000000511' $deviceSecret=[Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum') $providerSecret=[Text.Encoding]::UTF8.GetBytes('local-provider-callback-secret-32-bytes-minimum') $priceBookId=$null;$conversationId=$null;$quotaIds=@();$priceKey='billing-price-'+[Guid]::NewGuid().ToString('N') function Token([string]$name){(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{grant_type='password';client_id='voice-platform-web';username=$name;password='LocalOnly123!';scope='openid'}).access_token} function Expected([scriptblock]$call,[int]$status){try{&$call|Out-Null;throw "Expected HTTP $status"}catch{$actual=[int]$_.Exception.Response.StatusCode;if($actual-ne$status){throw}}} function Scalar([string]$sql){(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c $sql|Out-String).Trim()} function SignedDevicePost([string]$path,[string]$body,[string]$key){$bytes=[Text.Encoding]::UTF8.GetBytes($body);$digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant();$timestamp=[DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ');$nonce=[Guid]::NewGuid().ToString('N');$canonical="POST`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()};Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($api+$path) -ContentType 'application/json' -Body $body -Headers @{'X-Device-Id'=$deviceId;'X-Device-Timestamp'=$timestamp;'X-Device-Nonce'=$nonce;'X-Device-Content-SHA256'=$digest;'X-Device-Signature'=$signature;'Idempotency-Key'=$key}} function ProviderPost([string]$body){$bytes=[Text.Encoding]::UTF8.GetBytes($body);$digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant();$timestamp=[DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ');$canonical="$timestamp`n$digest";$hmac=[Security.Cryptography.HMACSHA256]::new($providerSecret);try{$signature=[Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+','-').Replace('/','_')}finally{$hmac.Dispose()};Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$callbacks/callbacks/v1/vertc/voice-chat" -ContentType 'application/json' -Body $body -Headers @{'X-Provider-Timestamp'=$timestamp;'X-Provider-Signature'=$signature}} try{ $owner=Token 'owner';$editor=Token 'editor';$outsider=Token 'outsider' Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/price-books" -Headers @{Authorization="Bearer $outsider";'X-Tenant-Id'=$tenantPublicId}} 403 $rates=@( @{usage_category='RTC';metric_code='connected_minute';unit='minute';unit_price_micros=10000}, @{usage_category='END_TO_END_VOICE';metric_code='agent_minute';unit='minute';unit_price_micros=20000}, @{usage_category='ASR';metric_code='audio_minute';unit='minute';unit_price_micros=30000}, @{usage_category='LLM';metric_code='token';unit='token';unit_price_micros=2}, @{usage_category='TTS';metric_code='character';unit='character';unit_price_micros=1}, @{usage_category='STORAGE';metric_code='gigabyte_day';unit='gb_day';unit_price_micros=1000}, @{usage_category='POST_PROCESSING';metric_code='job';unit='job';unit_price_micros=50000} ) $priceBody=@{provider='mock';currency='CNY';source_ref=('contract://smoke/'+[Guid]::NewGuid().ToString('N'));change_summary='端到端费用治理冒烟价格';effective_from=[DateTime]::UtcNow.AddMinutes(-1).ToString('o');rates=$rates}|ConvertTo-Json -Compress -Depth 6 $created=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/price-books" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$priceKey} -ContentType 'application/json' -Body $priceBody $priceBook=($created.Content|ConvertFrom-Json).data;$priceBookId=[string]$priceBook.id if($priceBook.status-ne'DRAFT'-or$priceBook.rates.Count-ne7){throw 'Price book draft or immutable rate set was not created'} $replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/price-books" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$priceKey} -ContentType 'application/json' -Body $priceBody if($replay.StatusCode-ne200-or[string]$replay.Headers.'Idempotency-Replayed'-ne'true'-or($replay.Content|ConvertFrom-Json).data.id-ne$priceBookId){throw 'Price book idempotency replay failed'} $publishBody=@{reason='报价来源与七类计量单价复核通过'}|ConvertTo-Json -Compress Expected {Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/price-books/$priceBookId/publish" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'} -ContentType 'application/json' -Body $publishBody} 409 $published=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/price-books/$priceBookId/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'} -ContentType 'application/json' -Body $publishBody).Content|ConvertFrom-Json).data if($published.status-ne'PUBLISHED'-or$published.row_version-ne2){throw 'Dual-controlled price publication failed'} $quotaBody=@{scope_type='TENANT';billing_timezone='Asia/Shanghai';currency='CNY';daily_limit_micros=100000000;monthly_limit_micros=1000000000;max_concurrent_calls=10;max_call_duration_sec=3600;in_progress_action='COMPLETE_CURRENT_TURN';change_summary='冒烟初始高额度'}|ConvertTo-Json -Compress $quota=((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/quota-policies" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('quota-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $quotaBody).Content|ConvertFrom-Json).data $quotaIds+=[string]$quota.id if($quota.status-ne'ACTIVE'-or$quota.billing_timezone-ne'Asia/Shanghai'){throw 'Quota policy did not activate with billing boundary'} $createBody=@{device_time=[DateTime]::UtcNow.ToString('o');monotonic_ms=223344;capabilities=@{rtc=$true;audio_tracks=@('near','far');upload_resume=$true};network=@{type='wifi'};requested_goal='open_conversation'}|ConvertTo-Json -Compress -Depth 8 $conversation=((SignedDevicePost '/device/v2/conversations' $createBody ('billing-conversation-'+[Guid]::NewGuid().ToString('N'))).Content|ConvertFrom-Json).data $conversationId=[string]$conversation.conversation_id $budget=Scalar "SELECT billing_timezone||'|'||currency||'|'||daily_limit_micros||'|'||monthly_limit_micros||'|'||max_call_duration_sec||'|'||in_progress_action FROM conversation_usage_budget cub JOIN conversation c ON c.id=cub.conversation_id WHERE c.public_id='$conversationId'::uuid" if($budget-ne'Asia/Shanghai|CNY|100000000|1000000000|3600|COMPLETE_CURRENT_TURN'){throw "Frozen quota budget mismatch: $budget"} $taskRef=Scalar "SELECT rt.provider_task_ref FROM rtc_task rt JOIN conversation c ON c.id=rt.conversation_id WHERE c.public_id='$conversationId'::uuid" if(-not$taskRef){throw 'Conversation did not allocate provider task reference'} $eventId='usage-smoke-'+[Guid]::NewGuid().ToString('N') $occurred=[DateTime]::UtcNow.ToString('o') $usage=@( @{usage_category='RTC';metric_code='connected_minute';quantity='2';unit='minute'}, @{usage_category='END_TO_END_VOICE';metric_code='agent_minute';quantity='1.5';unit='minute'}, @{usage_category='ASR';metric_code='audio_minute';quantity='1';unit='minute'}, @{usage_category='LLM';metric_code='token';quantity='100';unit='token'}, @{usage_category='TTS';metric_code='character';quantity='250';unit='character'}, @{usage_category='STORAGE';metric_code='gigabyte_day';quantity='2';unit='gb_day'}, @{usage_category='POST_PROCESSING';metric_code='job';quantity='1';unit='job'} ) $usageBody=@{provider='mock';event_id=$eventId;task_ref=$taskRef;conversation_id=$conversationId;event_type='voice_chat.usage';status='FINAL';occurred_at=$occurred;usage=$usage}|ConvertTo-Json -Compress -Depth 6 $accepted=(ProviderPost $usageBody).Content|ConvertFrom-Json if($accepted.data.usage_accepted-ne7-or$accepted.data.duplicate){throw 'Provider usage event was not accepted exactly once'} $duplicate=(ProviderPost $usageBody).Content|ConvertFrom-Json if(-not$duplicate.data.duplicate){throw 'Exact provider usage replay was not identified as duplicate'} $conflict=$usageBody|ConvertFrom-Json;$conflict.usage[0].quantity='3';$conflictBody=$conflict|ConvertTo-Json -Compress -Depth 6 Expected {ProviderPost $conflictBody} 409 $costEvidence=Scalar "SELECT count(*)||'|'||sum(ccl.cost_micros)||'|'||count(DISTINCT ccl.price_book_id)||'|'||count(DISTINCT ccl.currency) FROM conversation_cost_line ccl JOIN conversation c ON c.id=ccl.conversation_id WHERE c.public_id='$conversationId'::uuid" if($costEvidence-ne'7|132450|1|1'){throw "Frozen cost or duplicate-fee evidence mismatch: $costEvidence"} $from=[DateTime]::UtcNow.AddHours(-1).ToString('o');$to=[DateTime]::UtcNow.AddMinutes(1).ToString('o') $summaryResponse=Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/cost-summary?from=$([uri]::EscapeDataString($from))&to=$([uri]::EscapeDataString($to))" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId} $summary=($summaryResponse.Content|ConvertFrom-Json).data if($summary.cost_micros-ne132450-or$summary.currency-ne'CNY'-or$summary.lines.Count-ne7){throw 'Operations cost summary does not match frozen cost lines'} if($summaryResponse.Content-match'task_ref|provider_task'){throw 'Cost summary exposed supplier internal task reference'} $familyUsageResponse=Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/conversations/$conversationId/usage" -Headers @{Authorization="Bearer $owner"} $familyUsage=($familyUsageResponse.Content|ConvertFrom-Json).data if($familyUsage.conversation_id-ne$conversationId-or$familyUsage.cost_micros-ne132450-or$familyUsage.lines.Count-ne7){throw 'Family conversation usage summary does not match frozen cost lines'} if($familyUsageResponse.Content-match'task_ref|provider_task|price_book|price_rate|event_id'){throw 'Family usage summary exposed supplier or internal billing references'} Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/conversations/$conversationId/usage" -Headers @{Authorization="Bearer $outsider"}} 403 $hardQuotaBody=@{scope_type='TENANT';billing_timezone='Asia/Shanghai';currency='CNY';daily_limit_micros=100000;monthly_limit_micros=100000;max_concurrent_calls=10;max_call_duration_sec=600;in_progress_action='STOP_IMMEDIATELY';change_summary='冒烟硬额度拦截'}|ConvertTo-Json -Compress $hardQuota=((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/quota-policies" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('quota-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $hardQuotaBody).Content|ConvertFrom-Json).data $quotaIds+=[string]$hardQuota.id try{SignedDevicePost '/device/v2/conversations' $createBody ('billing-hard-limit-'+[Guid]::NewGuid().ToString('N'))|Out-Null;throw 'Expected HTTP 429'}catch{$actual=[int]$_.Exception.Response.StatusCode;if($actual-ne429){throw};$payload=$_.ErrorDetails.Message|ConvertFrom-Json;if($payload.error.code-ne'USAGE_QUOTA_EXCEEDED'){throw "Unexpected quota error code: $($payload.error.code)"}} Write-Host "Billing smoke passed: tenant isolation, dual-controlled versioned prices, seven-category exact-once usage, frozen CNY 0.132450 cost, safe summary, quota snapshot and stable hard-limit rejection: $conversationId" }finally{ if($conversationId-match'^[0-9a-fA-F-]{36}$'){ $cleanupConversation=@" BEGIN; CREATE TEMP TABLE billing_conv AS SELECT id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversationId'::uuid; DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM billing_conv); DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM billing_conv); DELETE FROM provider_event WHERE conversation_id IN(SELECT id FROM billing_conv); DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM billing_conv); DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM billing_conv); DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM billing_conv); DELETE FROM conversation WHERE id IN(SELECT id FROM billing_conv); DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM billing_conv); DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM billing_conv); COMMIT; "@ $cleanupConversation|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null } if($quotaIds.Count){$ids=($quotaIds|ForEach-Object{"'$_'::uuid"})-join',';$cleanupQuota="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM usage_quota_policy WHERE public_id IN($ids)); DELETE FROM idempotency_record WHERE scope='POST /api/v1/ops/quota-policies' AND resource_id IN(SELECT id FROM usage_quota_policy WHERE public_id IN($ids)); DELETE FROM usage_quota_policy WHERE public_id IN($ids); COMMIT;";$cleanupQuota|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null} if($priceBookId-match'^[0-9a-fA-F-]{36}$'){$cleanupPrice="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM billing_price_book WHERE public_id='$priceBookId'::uuid); DELETE FROM idempotency_record WHERE scope='POST /api/v1/ops/price-books' AND resource_id IN(SELECT id FROM billing_price_book WHERE public_id='$priceBookId'::uuid); DELETE FROM billing_price_book WHERE public_id='$priceBookId'::uuid; COMMIT;";$cleanupPrice|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null} }