113 lines
19 KiB
PowerShell
113 lines
19 KiB
PowerShell
$ErrorActionPreference='Stop'
|
|
$PSNativeCommandUseErrorActionPreference=$true
|
|
$root=Split-Path -Parent $PSScriptRoot
|
|
Set-Location -LiteralPath $root
|
|
$api='http://localhost:58080'
|
|
$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')
|
|
$configIds=@();$resourceIds=@();$conversationId=$null;$groupId=$null
|
|
$key1='ops-config-'+[Guid]::NewGuid().ToString('N');$key2='ops-config-'+[Guid]::NewGuid().ToString('N');$rollbackKey='ops-rollback-'+[Guid]::NewGuid().ToString('N')
|
|
|
|
function Token([string]$name){$password=if($name-like'operator*'){'OpsLocalOnly123!'}else{'LocalOnly123!'};(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{grant_type='password';client_id='voice-platform-web';username=$name;password=$password;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 SignedPost([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 RolloutBody([string]$reason,[string]$scope,[string]$scopeId=''){@{reason=$reason;rollout=@{scope_type=$scope;rollout_percent=100;max_concurrent_calls=100}}|ForEach-Object{if($scopeId){$_.rollout.scope_id=$scopeId};$_|ConvertTo-Json -Compress -Depth 6}}
|
|
function AddEvaluation([string]$configId,[string]$suite,[string]$token){$body=@{suite_version=$suite;result='PASSED';metrics=@{success_rate=1;sample_count=12};report_ref=("report://local/"+$suite)}|ConvertTo-Json -Compress -Depth 6;(Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions/$configId/evaluations" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('ops-eval-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $body).Content|ConvertFrom-Json}
|
|
function AddResource([string]$type,[string]$name,[string]$display,[string]$token){$body=@{resource_type=$type;reference_name=$name;display_name=$display;verified_at=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Compress;(Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/resource-references" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('ops-resource-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $body).Content|ConvertFrom-Json}
|
|
|
|
try{
|
|
$owner=Token 'operator';$editor=Token 'operator-approver';$outsider=Token 'outsider'
|
|
$ownerMe=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" -Headers @{Authorization="Bearer $owner"}).Content|ConvertFrom-Json).data
|
|
$editorMe=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" -Headers @{Authorization="Bearer $editor"}).Content|ConvertFrom-Json).data
|
|
if($ownerMe.accounts[0].account_kind-ne'operator'-or$ownerMe.accounts[0].platform_role-ne'ops_admin'-or$editorMe.accounts[0].account_kind-ne'operator'-or$editorMe.accounts[0].platform_role-ne'ops_approver'){throw 'Dedicated operations accounts were not exposed by /me'}
|
|
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $outsider";'X-Tenant-Id'='20000000-0000-0000-0000-000000000010'}} 403
|
|
|
|
$groupBody=@{name=('本地灰度设备组-'+[Guid]::NewGuid().ToString('N').Substring(0,8));selector=@{source='smoke-test'};device_ids=@($deviceId)}|ConvertTo-Json -Compress -Depth 5
|
|
$groupResponse=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/device-groups" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('ops-group-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $groupBody
|
|
$groupId=[string](($groupResponse.Content|ConvertFrom-Json).data.id)
|
|
if(-not $groupId){throw 'Device rollout group was not created'}
|
|
|
|
$invalid=@{config_key='default-agent-routing';display_name='默认语音路由';config_kind='ROUTING';payload=@{default_engine_mode='s2s_hybrid';routes=@();api_secret='must-not-pass'};secret_refs=@{};change_summary='非法明文密钥验证'}|ConvertTo-Json -Compress -Depth 8
|
|
Expected {Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('invalid-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $invalid} 400
|
|
|
|
$v1Body=@{config_key='default-agent-routing';display_name='默认语音路由';config_kind='ROUTING';payload=@{default_engine_mode='s2s_hybrid';routes=@(@{goal='open_conversation';engine_mode='s2s_hybrid'})};secret_refs=@{};change_summary='建立默认混合语音路由'}|ConvertTo-Json -Compress -Depth 8
|
|
$v1Response=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$key1} -ContentType 'application/json' -Body $v1Body
|
|
$v1=($v1Response.Content|ConvertFrom-Json).data;$configIds+=[string]$v1.id
|
|
$replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$key1} -ContentType 'application/json' -Body $v1Body
|
|
if($replay.StatusCode-ne200-or[string]$replay.Headers.'Idempotency-Replayed'-ne'true'-or(($replay.Content|ConvertFrom-Json).data.id-ne$v1.id)){throw 'Config draft idempotency replay failed'}
|
|
$v1Submitted=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v1.id)/submit" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'}).Content|ConvertFrom-Json).data
|
|
$tenantPublish=RolloutBody '审批通过第一版' 'TENANT'
|
|
Expected {Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v1.id)/publish" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body (RolloutBody '禁止自己审批' 'TENANT')} 409
|
|
Expected {Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v1.id)/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body $tenantPublish} 409
|
|
AddEvaluation ([string]$v1.id) 'routing-regression-v1' $owner|Out-Null
|
|
$evidence=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/config-versions/$($v1.id)/evaluations" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId}).Content|ConvertFrom-Json).data.items
|
|
if(@($evidence).Count-ne1-or$evidence[0].result-ne'PASSED'){throw 'Offline evaluation evidence was not queryable'}
|
|
$v1Published=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v1.id)/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body $tenantPublish).Content|ConvertFrom-Json).data
|
|
if($v1Published.status-ne'PUBLISHED'-or$v1Published.validation_result.valid-ne$true){throw 'First config was not published with validation evidence'}
|
|
|
|
$v2Body=@{config_key='default-agent-routing';display_name='默认语音路由';config_kind='ROUTING';payload=@{default_engine_mode='cascade';routes=@(@{goal='open_conversation';engine_mode='cascade'})};secret_refs=@{};change_summary='切换到级联链路进行小范围验证'}|ConvertTo-Json -Compress -Depth 8
|
|
$v2=((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$key2} -ContentType 'application/json' -Body $v2Body).Content|ConvertFrom-Json).data;$configIds+=[string]$v2.id
|
|
Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v2.id)/submit" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'}|Out-Null
|
|
AddEvaluation ([string]$v2.id) 'routing-regression-v2' $owner|Out-Null
|
|
$groupPublish=RolloutBody '审批通过第二版并投放设备组' 'DEVICE_GROUP' $groupId
|
|
$v2Published=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($v2.id)/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body $groupPublish).Content|ConvertFrom-Json).data
|
|
$history=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/config-versions?config_key=default-agent-routing" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId}).Content|ConvertFrom-Json).data.items
|
|
$published=@($history|Where-Object {$_.status -eq 'PUBLISHED'});$first=@($history|Where-Object {$_.id -eq $v1.id})
|
|
if($published.Count-ne2-or$first.Count-ne1-or$first[0].status-ne'PUBLISHED'){throw "Independent tenant and device-group rollouts were not retained: $($history|ForEach-Object{"$($_.version_no):$($_.status)"})"}
|
|
|
|
$rollbackBody=RolloutBody '级联验证异常,回滚设备组到混合语音路由' 'DEVICE_GROUP' $groupId
|
|
$rollbackResponse=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions/$($v1.id)/rollback" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$rollbackKey} -ContentType 'application/json' -Body $rollbackBody
|
|
$rollback=($rollbackResponse.Content|ConvertFrom-Json).data;$configIds+=[string]$rollback.id
|
|
$rollbackReplay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions/$($v1.id)/rollback" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$rollbackKey} -ContentType 'application/json' -Body $rollbackBody
|
|
if($rollback.version_no-ne3-or$rollback.payload.default_engine_mode-ne's2s_hybrid'-or($rollbackReplay.Content|ConvertFrom-Json).data.id-ne$rollback.id-or[string]$rollbackReplay.Headers.'Idempotency-Replayed'-ne'true'){throw 'Immutable rollback version or replay failed'}
|
|
$afterRollback=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/config-versions?config_key=default-agent-routing" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId}).Content|ConvertFrom-Json).data.items
|
|
$retiredV2=@($afterRollback|Where-Object {$_.id-eq$v2.id});if($retiredV2.Count-ne1-or$retiredV2[0].status-ne'RETIRED'-or$rollback.supersedes_id-ne$v2.id){throw 'Device-group rollback did not retire and link the superseded rollout version'}
|
|
|
|
$resourceSuffix=[Guid]::NewGuid().ToString('N');$credentialRef="kms://smoke/credential/$resourceSuffix";$modelRef="model://smoke/approved/$resourceSuffix"
|
|
$modelBody=@{config_key=("smoke-model-"+$resourceSuffix.Substring(0,10));display_name='模型资源门禁验证';config_kind='MODEL';payload=@{provider='local-smoke';model_id=$modelRef};secret_refs=@{credential_ref=$credentialRef};change_summary='验证模型与凭据资源类型及时效门禁'}|ConvertTo-Json -Compress -Depth 8
|
|
$model=((Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/config-versions" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=('ops-model-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $modelBody).Content|ConvertFrom-Json).data;$configIds+=[string]$model.id
|
|
Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($model.id)/submit" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'}|Out-Null
|
|
AddEvaluation ([string]$model.id) 'model-resource-gate-v1' $owner|Out-Null
|
|
$modelPublish=RolloutBody '模型资源门禁验证通过' 'TENANT'
|
|
Expected {Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($model.id)/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body $modelPublish} 409
|
|
$credential=(AddResource 'PROVIDER_CREDENTIAL' $credentialRef '本地凭据引用' $owner).data;$resourceIds+=[string]$credential.id
|
|
$modelResource=(AddResource 'MODEL_RESOURCE' $modelRef '本地已核验模型' $owner).data;$resourceIds+=[string]$modelResource.id
|
|
$modelPublished=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/config-versions/$($model.id)/publish" -Headers @{Authorization="Bearer $editor";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v2"'} -ContentType 'application/json' -Body $modelPublish).Content|ConvertFrom-Json).data
|
|
if($modelPublished.status-ne'PUBLISHED'){throw 'Verified credential and model references did not release the publish gate'}
|
|
$activeRollouts=@(((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/config-rollouts" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId}).Content|ConvertFrom-Json).data.items|Where-Object {$_.status-eq'ACTIVE'-and$_.config_version_id-in$configIds})
|
|
if($activeRollouts.Count-ne3){throw "Active tenant, device-group and model rollout ledger mismatch: $($activeRollouts.Count)"}
|
|
$revoked=((Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/resource-references/$($modelResource.id)/revoke" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'='"v1"'} -ContentType 'application/json' -Body (@{reason='验证资源撤销生命周期'}|ConvertTo-Json -Compress)).Content|ConvertFrom-Json).data
|
|
if($revoked.status-ne'REVOKED'-or$revoked.row_version-ne2){throw 'Resource reference revocation did not preserve optimistic concurrency'}
|
|
|
|
$createBody=@{device_time=[DateTime]::UtcNow.ToString('o');monotonic_ms=98765;capabilities=@{rtc=$true;audio_tracks=@('near','far');upload_resume=$true};network=@{type='wifi'};requested_goal='open_conversation'}|ConvertTo-Json -Compress -Depth 8
|
|
$created=(SignedPost '/device/v2/conversations' $createBody ('ops-runtime-'+[Guid]::NewGuid().ToString('N'))).Content|ConvertFrom-Json
|
|
$conversationId=[string]$created.data.conversation_id
|
|
$runtime=(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT c.engine_mode||'|'||xs.prompt_bundle_version FROM conversation c JOIN context_snapshot xs ON xs.id=c.context_snapshot_id WHERE c.public_id='$conversationId'::uuid"|Out-String).Trim()
|
|
if($runtime-ne("s2s_hybrid|cfg:"+$rollback.id)){throw "New conversation did not freeze rolled-back config: $runtime"}
|
|
$auditCount=[int]((docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT count(*) FROM audit_event WHERE action LIKE 'ops.config.%' AND resource_id IN(SELECT id FROM runtime_config_version WHERE public_id IN('$($configIds[0])'::uuid,'$($configIds[1])'::uuid,'$($configIds[2])'::uuid,'$($configIds[3])'::uuid))"|Out-String).Trim())
|
|
if($auditCount-ne10){throw "Unexpected operations config-version audit count: $auditCount"}
|
|
Write-Host "Operations config smoke passed: role isolation, evaluation/resource gates, rollout ledger and revocation, device-group precedence, immutable rollback and new-call version freeze: $($rollback.id)"
|
|
}finally{
|
|
if($conversationId-match'^[0-9a-fA-F-]{36}$'){
|
|
$cleanupConversation=@"
|
|
BEGIN;
|
|
CREATE TEMP TABLE ops_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 ops_conv);
|
|
DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM ops_conv);
|
|
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM ops_conv);
|
|
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM ops_conv);
|
|
DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM ops_conv);
|
|
DELETE FROM conversation WHERE id IN(SELECT id FROM ops_conv);
|
|
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM ops_conv);
|
|
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM ops_conv);
|
|
COMMIT;
|
|
"@
|
|
$cleanupConversation|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null
|
|
}
|
|
if($configIds.Count){$ids=($configIds|ForEach-Object{"'$_'::uuid"})-join',';$cleanup="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM ops_config_evaluation WHERE config_version_id IN(SELECT id FROM runtime_config_version WHERE public_id IN($ids))); DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM runtime_config_version WHERE public_id IN($ids)); DELETE FROM idempotency_record WHERE scope IN('POST /api/v1/ops/config-versions','POST /api/v1/ops/config-versions/rollback','POST /api/v1/ops/config-versions/evaluations') AND (resource_id IN(SELECT id FROM runtime_config_version WHERE public_id IN($ids)) OR resource_id IN(SELECT id FROM ops_config_evaluation WHERE config_version_id IN(SELECT id FROM runtime_config_version WHERE public_id IN($ids)))); DELETE FROM runtime_config_rollout WHERE config_version_id IN(SELECT id FROM runtime_config_version WHERE public_id IN($ids)); UPDATE runtime_config_version SET supersedes_id=NULL,rollback_of_id=NULL WHERE public_id IN($ids); DELETE FROM runtime_config_version WHERE public_id IN($ids); COMMIT;";$cleanup|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
|
|
if($resourceIds.Count){$ids=($resourceIds|ForEach-Object{"'$_'::uuid"})-join',';$cleanupResources="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM ops_resource_reference WHERE public_id IN($ids)); DELETE FROM idempotency_record WHERE scope='POST /api/v1/ops/resource-references' AND resource_id IN(SELECT id FROM ops_resource_reference WHERE public_id IN($ids)); DELETE FROM ops_resource_reference WHERE public_id IN($ids); COMMIT;";$cleanupResources|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
|
|
if($groupId-match'^[0-9a-fA-F-]{36}$'){$cleanupGroup="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM ops_device_group WHERE public_id='$groupId'::uuid); DELETE FROM idempotency_record WHERE scope='POST /api/v1/ops/device-groups' AND resource_id IN(SELECT id FROM ops_device_group WHERE public_id='$groupId'::uuid); DELETE FROM ops_device_group WHERE public_id='$groupId'::uuid; COMMIT;";$cleanupGroup|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
|
|
}
|