$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){(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 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 'owner';$editor=Token 'editor';$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].platform_role-ne'ops_admin'-or$editorMe.accounts[0].platform_role-ne'ops_approver'){throw 'Seeded operations roles 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} }