Files
story/scripts/smoke-billing.ps1
T

116 lines
14 KiB
PowerShell
Raw Normal View History

2026-08-26 06:41:07 +08:00
$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}
}