chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
[CmdletBinding()]
param(
[ValidateRange(1, 60)]
[int]$LogWindowMinutes = 5
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
function Assert-HttpStatus {
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$Uri
)
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 5
} catch {
throw "$Name is unavailable at $Uri. $($_.Exception.Message)"
}
if ($response.StatusCode -ne 200) {
throw "$Name returned HTTP $($response.StatusCode) at $Uri."
}
Write-Host "[PASS] $Name returned HTTP 200."
}
function Assert-DependencyMetrics {
param(
[Parameter(Mandatory)]
[string]$Service,
[Parameter(Mandatory)]
[string]$Uri
)
$content = (Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 5).Content
$dependencyLines = @($content -split "`n" | Where-Object {
$_ -match '^voice_platform_dependency_up\{'
})
if ($dependencyLines.Count -eq 0) {
throw "$Service did not expose dependency health metrics."
}
$down = @($dependencyLines | Where-Object { $_ -match '\}\s+0(?:\.0+)?\s*$' })
if ($down.Count -gt 0) {
throw "$Service has unavailable dependencies:`n$($down -join "`n")"
}
Write-Host "[PASS] $Service dependencies are available ($($dependencyLines.Count) checked)."
}
$requiredServices = @(
'api',
'callbacks',
'worker',
'web',
'mock-provider',
'mock-alert-receiver',
'postgres',
'redis',
'minio',
'keycloak'
)
$runningServices = @(docker compose ps --services --status running)
$missingServices = @($requiredServices | Where-Object { $_ -notin $runningServices })
if ($missingServices.Count -gt 0) {
throw "Required services are not running: $($missingServices -join ', ')."
}
Write-Host "[PASS] All required local services are running."
Assert-HttpStatus -Name 'API readiness' -Uri 'http://127.0.0.1:58080/health/ready'
Assert-HttpStatus -Name 'Callbacks readiness' -Uri 'http://127.0.0.1:58081/health/ready'
Assert-HttpStatus -Name 'Worker readiness' -Uri 'http://127.0.0.1:58082/health/ready'
Assert-HttpStatus -Name 'Web readiness' -Uri 'http://127.0.0.1:3100/health/ready'
Assert-HttpStatus -Name 'Mock provider liveness' -Uri 'http://127.0.0.1:59200/health/live'
Assert-HttpStatus -Name 'Mock alert receiver liveness' -Uri 'http://127.0.0.1:59210/health/live'
Assert-DependencyMetrics -Service 'API' -Uri 'http://127.0.0.1:58080/metrics'
Assert-DependencyMetrics -Service 'Worker' -Uri 'http://127.0.0.1:58082/metrics'
$outboxCounts = docker compose exec -T postgres psql -U voice -d voice_platform -tAc `
"SELECT count(*) FILTER (WHERE status IN ('pending', 'processing')), count(*) FILTER (WHERE status = 'dead') FROM outbox_event;"
$outboxCounts = $outboxCounts.Trim()
if ($outboxCounts -ne '0|0') {
throw "Outbox has open/dead work: $outboxCounts."
}
Write-Host '[PASS] Outbox has no pending, processing, or dead events.'
$recentLogs = docker compose logs --since "${LogWindowMinutes}m" api callbacks worker mock-provider mock-alert-receiver 2>&1
$errorLogs = @($recentLogs | Select-String -Pattern 'panic|fatal|level[=:]error|"level":"error"' -CaseSensitive:$false)
if ($errorLogs.Count -gt 0) {
throw "Recent service logs contain errors:`n$($errorLogs -join "`n")"
}
Write-Host "[PASS] No fatal or error-level logs in the last $LogWindowMinutes minute(s)."
Write-Host 'Lightweight functional readiness check passed.'
+28
View File
@@ -0,0 +1,28 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$unformatted = gofmt -l cmd internal
if ($unformatted) {
throw "The following Go files are not formatted:`n$($unformatted -join "`n")"
}
go vet ./...
# Database-backed packages share the seeded local tenant. Run packages
# sequentially whenever integration variables are present so temporary SaaS
# fixtures cannot become visible to identity assertions in another package.
go test -p 1 '-coverprofile=coverage.out' ./...
Push-Location web
try {
npm ci
npm run generate:api
npm run typecheck
npm run build
} finally {
Pop-Location
}
docker compose config --quiet
Write-Host 'All local checks passed.'
+28
View File
@@ -0,0 +1,28 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
docker compose config --quiet
docker compose up --build -d
Write-Host '等待 API 就绪...'
for ($attempt = 1; $attempt -le 60; $attempt++) {
try {
$response = Invoke-RestMethod -Uri 'http://localhost:58080/health/ready' -TimeoutSec 2
if ($response.data.status -eq 'ready') {
Write-Host '本地环境已就绪:'
Write-Host ' Web http://localhost:3100'
Write-Host ' API http://localhost:58080'
Write-Host ' Callbacks http://localhost:58081'
Write-Host ' Worker http://localhost:58082'
Write-Host ' MinIO http://localhost:59001'
Write-Host ' Keycloak http://localhost:58180'
exit 0
}
} catch {
Start-Sleep -Seconds 2
}
}
docker compose ps
throw 'API在约定时间内未就绪,请执行 docker compose logs api postgres redis migrate 查看原因。'
+121
View File
@@ -0,0 +1,121 @@
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[string]$Output = (Join-Path $PSScriptRoot '..\artifacts\requirements-source-inventory.md')
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$sourcePath = (Resolve-Path -LiteralPath $Source).Path
$outputPath = [System.IO.Path]::GetFullPath($Output)
$outputDirectory = Split-Path -Parent $outputPath
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory | Out-Null
}
$stylesByID = @{}
$archive = [System.IO.Compression.ZipFile]::OpenRead($sourcePath)
try {
$entry = $archive.GetEntry('word/document.xml')
if ($null -eq $entry) {
throw 'DOCX中缺少word/document.xml'
}
$stream = $entry.Open()
try {
$reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8, $true)
try {
[xml]$document = $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
finally {
$stream.Dispose()
}
$stylesEntry = $archive.GetEntry('word/styles.xml')
if ($null -ne $stylesEntry) {
$stylesStream = $stylesEntry.Open()
try {
$stylesReader = [System.IO.StreamReader]::new($stylesStream, [System.Text.Encoding]::UTF8, $true)
try {
[xml]$stylesDocument = $stylesReader.ReadToEnd()
}
finally {
$stylesReader.Dispose()
}
}
finally {
$stylesStream.Dispose()
}
$stylesNamespace = [System.Xml.XmlNamespaceManager]::new($stylesDocument.NameTable)
$stylesNamespace.AddNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
foreach ($styleNode in $stylesDocument.SelectNodes('/w:styles/w:style', $stylesNamespace)) {
$idAttribute = $styleNode.Attributes.GetNamedItem('styleId', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
$nameNode = $styleNode.SelectSingleNode('./w:name', $stylesNamespace)
if ($null -eq $idAttribute -or $null -eq $nameNode) {
continue
}
$nameAttribute = $nameNode.Attributes.GetNamedItem('val', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
if ($null -ne $nameAttribute) {
$stylesByID[$idAttribute.Value] = $nameAttribute.Value
}
}
}
}
finally {
$archive.Dispose()
}
$namespace = [System.Xml.XmlNamespaceManager]::new($document.NameTable)
$namespace.AddNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
$paragraphs = $document.SelectNodes('/w:document/w:body/w:p', $namespace)
if ($paragraphs.Count -eq 0) {
throw 'DOCX正文没有可追踪段落'
}
$hash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant()
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('# 原始需求来源段落清单')
$lines.Add('')
$lines.Add('> 此文件由`scripts/extract-requirements.ps1`从原始DOCX机械提取,用于稳定引用来源,不替代原需求文档。`Pxxxx`是`word/document.xml`正文段落的零基顺序号;空段落保留编号但不输出正文。')
$lines.Add('')
$lines.Add("- 来源文件:$([System.IO.Path]::GetFileName($sourcePath))")
$lines.Add(('- SHA-256:`{0}`' -f $hash))
$lines.Add("- 正文段落总数:$($paragraphs.Count)")
$lines.Add('')
$lines.Add('## 非空段落')
$lines.Add('')
$nonEmpty = 0
for ($index = 0; $index -lt $paragraphs.Count; $index++) {
$paragraph = $paragraphs[$index]
$textParts = $paragraph.SelectNodes('.//w:t', $namespace) | ForEach-Object { $_.InnerText }
$text = (($textParts -join '') -replace '[\r\n\t]+', ' ' -replace '\s{2,}', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($text)) {
continue
}
$nonEmpty++
$styleNode = $paragraph.SelectSingleNode('./w:pPr/w:pStyle', $namespace)
$style = 'Normal'
if ($null -ne $styleNode) {
$styleAttribute = $styleNode.Attributes.GetNamedItem('val', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
if ($null -ne $styleAttribute -and -not [string]::IsNullOrWhiteSpace($styleAttribute.Value)) {
$style = $styleAttribute.Value
if ($stylesByID.ContainsKey($style)) {
$style = $stylesByID[$style]
}
}
}
$safeText = $text.Replace('\', '\\').Replace('`', '\`')
$lines.Add(('- `P{0}` [{1}] {2}' -f $index.ToString('0000'), $style, $safeText))
}
$lines.Insert(7, "- 非空段落数:$nonEmpty")
[System.IO.File]::WriteAllLines($outputPath, $lines, [System.Text.UTF8Encoding]::new($false))
Write-Output "已提取 $nonEmpty/$($paragraphs.Count) 个段落:$outputPath"
+89
View File
@@ -0,0 +1,89 @@
param(
[int]$ConversationCount=1000,
[int]$ExpiredCount=50,
[int]$TimeoutSeconds=30
)
$ErrorActionPreference='Stop'
$PSNativeCommandUseErrorActionPreference=$true
if($ConversationCount-lt1-or$ConversationCount-gt10000-or$ExpiredCount-lt1-or$ExpiredCount-gt$ConversationCount){throw 'Load counts are outside the supported safety boundary'}
$root=Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$run=[Guid]::NewGuid().ToString('N')
$model="load-state-$run"
$prefix="load-state-$run-"
$tenant='10000000-0000-0000-0000-000000000001'
function Sql([string]$statement){
$result=$statement|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if($LASTEXITCODE-ne0){throw 'Conversation state load SQL failed'}
return ($result|Out-String).Trim()
}
try{
$setup=@"
BEGIN;
CREATE TEMP TABLE load_base ON COMMIT DROP AS
WITH selected AS (
SELECT household_id,id subject_id,tenant_id,profile_version FROM subject_profile WHERE tenant_id='$tenant'::uuid AND status='active' ORDER BY created_at LIMIT 1
), consent AS (
INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
SELECT tenant_id,subject_id,profile_version,'{}'::jsonb,jsonb_build_array('$model') FROM selected
RETURNING id,tenant_id,subject_id
), context AS (
INSERT INTO context_snapshot(tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
SELECT c.tenant_id,c.subject_id,s.profile_version,c.id,'[]'::jsonb,'$model','s2s_hybrid',encode(digest('$model','sha256'),'hex'),'合成通话状态负载夹具'
FROM consent c JOIN selected s ON s.subject_id=c.subject_id
RETURNING id,tenant_id,subject_id,consent_snapshot_id
)
SELECT s.household_id,s.subject_id,c.consent_snapshot_id,c.id context_id FROM selected s JOIN context c ON c.subject_id=s.subject_id;
CREATE TEMP TABLE load_devices ON COMMIT DROP AS
WITH inserted AS (
INSERT INTO device(tenant_id,serial_digest,model,hardware_revision,credential_ref,minimum_security_version)
SELECT '$tenant'::uuid,encode(digest('$run-device-'||g,'sha256'),'hex'),'$model','load','load://state/'||g,'load'
FROM generate_series(1,$ConversationCount) g
RETURNING id
) SELECT id,row_number() OVER() AS n FROM inserted;
WITH inserted AS (
INSERT INTO conversation(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,lease_expires_at,started_at)
SELECT '$tenant'::uuid,b.household_id,b.subject_id,d.id,b.consent_snapshot_id,b.context_id,'$prefix'||d.n,'s2s_hybrid','none','ACTIVE',now()+interval '10 minutes',now()+interval '10 minutes',now()
FROM load_devices d CROSS JOIN load_base b RETURNING id,provider_task_ref
)
INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status,lease_expires_at)
SELECT id,'mock',provider_task_ref,'room-'||provider_task_ref,'device-'||provider_task_ref,'agent-'||provider_task_ref,'ACTIVE',now()+interval '10 minutes' FROM inserted;
WITH targets AS (SELECT id FROM conversation WHERE provider_task_ref LIKE '$prefix%' ORDER BY provider_task_ref LIMIT $ExpiredCount)
UPDATE conversation SET lease_expires_at=now()-interval '1 minute',updated_at=now()-interval '1 minute' WHERE id IN(SELECT id FROM targets);
COMMIT;
SELECT count(*)||'|'||count(*) FILTER(WHERE lease_expires_at<now()) FROM conversation WHERE provider_task_ref LIKE '$prefix%';
"@
$evidence=Sql $setup
if($evidence-ne"$ConversationCount|$ExpiredCount"){throw "Conversation load fixture mismatch: $evidence"}
$deadline=[DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
do{
Start-Sleep -Milliseconds 500
$state=Sql "SELECT count(*) FILTER(WHERE status='ACTIVE')||'|'||count(*) FILTER(WHERE status='STOPPING')||'|'||count(*) FROM conversation WHERE provider_task_ref LIKE '$prefix%';"
}while($state-ne"$($ConversationCount-$ExpiredCount)|$ExpiredCount|$ConversationCount"-and[DateTime]::UtcNow-lt$deadline)
if($state-ne"$($ConversationCount-$ExpiredCount)|$ExpiredCount|$ConversationCount"){throw "Recovery scan did not converge exactly: $state"}
$recovery=Sql "SELECT count(*)||'|'||count(DISTINCT aggregate_id)||'|'||COALESCE(max(per_conversation),0) FROM (SELECT aggregate_id,count(*) per_conversation FROM outbox_event WHERE event_type='conversation.agent_stop_requested' AND aggregate_id IN(SELECT id FROM conversation WHERE provider_task_ref LIKE '$prefix%') GROUP BY aggregate_id) x;"
if($recovery-ne"$ExpiredCount|$ExpiredCount|1"){throw "Recovery generated missing or duplicate stop intents: $recovery"}
$wrong=Sql "SELECT count(*) FROM conversation WHERE provider_task_ref LIKE '$prefix%' AND ((lease_expires_at>=now() AND status<>'ACTIVE') OR (lease_expires_at<now() AND status<>'STOPPING'));"
if($wrong-ne'0'){throw "Recovery incorrectly changed healthy or failed to change expired conversations: $wrong"}
Write-Host "Conversation state load passed: total=$ConversationCount, healthy_active=$($ConversationCount-$ExpiredCount), expired_recovered=$ExpiredCount, stop_intents=$recovery, no_double_task=true"
}finally{
$cleanup=@"
BEGIN;
CREATE TEMP TABLE load_conversations ON COMMIT DROP AS SELECT id,public_id FROM conversation WHERE provider_task_ref LIKE '$prefix%';
CREATE TEMP TABLE load_snapshots ON COMMIT DROP AS SELECT id context_id,consent_snapshot_id FROM context_snapshot WHERE prompt_bundle_version='$model';
DELETE FROM alert_incident WHERE tenant_id='$tenant'::uuid AND resource_type='conversation' AND resource_public_id IN(SELECT public_id FROM load_conversations);
DELETE FROM web_event WHERE resource_type='conversation' AND resource_public_id IN(SELECT public_id FROM load_conversations);
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM load_conversations);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM load_conversations);
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM load_conversations);
DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM load_conversations);
DELETE FROM conversation WHERE id IN(SELECT id FROM load_conversations);
DELETE FROM device WHERE model='$model';
DELETE FROM context_snapshot WHERE id IN(SELECT context_id FROM load_snapshots);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM load_snapshots);
COMMIT;
"@
try{Sql $cleanup|Out-Null}catch{Write-Warning $_}
}
+155
View File
@@ -0,0 +1,155 @@
param(
[ValidateRange(1, 50)][int]$BatchCount = 10,
[ValidateRange(1, 1000)][int]$SegmentsPerBatch = 1000
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$api = 'http://localhost:58080'
$tenantId = '10000000-0000-0000-0000-000000000001'
$householdId = '10000000-0000-0000-0000-000000000201'
$subjectId = '10000000-0000-0000-0000-000000000401'
$deviceInternalId = '10000000-0000-0000-0000-000000000501'
$deviceId = '10000000-0000-0000-0000-000000000511'
$ownerId = '10000000-0000-0000-0000-000000000101'
$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()
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() }
finally { $sha.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 }
return Invoke-WebRequest -UseBasicParsing -Method $Method -Uri ($api + $Path) -Headers $headers -ContentType 'application/json' -Body $Body
}
function Get-ApiMemoryBytes {
$containerId = (docker compose ps -q api | Out-String).Trim()
if (-not $containerId) { throw 'API container is not running' }
$usage = ((docker stats --no-stream --format '{{.MemUsage}}' $containerId | Out-String).Trim() -split '/')[0].Trim()
if ($usage -notmatch '^([0-9.]+)([KMG]iB)$') { throw "Cannot parse API container memory usage: $usage" }
$factor = switch ($Matches[2]) { 'KiB' { 1KB } 'MiB' { 1MB } 'GiB' { 1GB } }
return [long]([double]$Matches[1] * $factor)
}
function Invoke-Database([string]$Sql) {
$Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
}
$created = $false
try {
$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_capacity"}'::jsonb,'local-v1',repeat('e',64),'$ownerId'::uuid,'local-synthetic://media-capacity',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,1000,'{"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-capacity-v1','s2s_hybrid',repeat('f',64),'synthetic media capacity');
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-capacity-$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-capacity-task-$conversationId','media-capacity-room-$conversationId','device-media-capacity','agent-media-capacity','COMPLETED',now()-interval '1 minute',now());
COMMIT;
"@
Invoke-Database $fixtureSql
$created = $true
$memoryBefore = Get-ApiMemoryBytes
$memoryPeak = $memoryBefore
$batchDurations = [Collections.Generic.List[double]]::new()
$preparedTotal = 0
for ($batchIndex = 0; $batchIndex -lt $BatchCount; $batchIndex++) {
$segments = [Collections.Generic.List[object]]::new($SegmentsPerBatch)
for ($index = 0; $index -lt $SegmentsPerBatch; $index++) {
$global = ($batchIndex * $SegmentsPerBatch) + $index
$segments.Add([ordered]@{
track = if (($global % 2) -eq 0) { 'near' } else { 'far' }
sequence_no = [int][Math]::Floor($global / 2)
codec = 'wav_pcm_s16le'
content_type = 'audio/wav'
duration_ms = 20
expected_size = 1
expected_digest = $global.ToString('x64')
})
}
$body = @{ segments = $segments } | ConvertTo-Json -Compress -Depth 5
$headers = @{ 'Idempotency-Key' = "media-capacity-$conversationId-$batchIndex" }
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
$response = Invoke-DeviceRequest 'POST' "/device/v2/conversations/$conversationId/upload-batches" $body $headers
$stopwatch.Stop()
$prepared = ($response.Content | ConvertFrom-Json).data
if ($prepared.status -ne 'UPLOADING' -or $prepared.segment_count -ne $SegmentsPerBatch -or $prepared.segments.Count -ne $SegmentsPerBatch) {
throw "Batch $batchIndex did not return all authorized segments"
}
$missingUrls = @($prepared.segments | Where-Object { -not $_.upload_url }).Count
if ($missingUrls -ne 0) { throw "Batch $batchIndex has $missingUrls missing upload URLs" }
$preparedTotal += $prepared.segments.Count
$batchDurations.Add($stopwatch.Elapsed.TotalSeconds)
$currentMemory = Get-ApiMemoryBytes
if ($currentMemory -gt $memoryPeak) { $memoryPeak = $currentMemory }
Write-Host ("Prepared batch {0}/{1}: segments={2}, elapsed_seconds={3:N2}, api_memory_mb={4:N1}" -f ($batchIndex + 1), $BatchCount, $prepared.segments.Count, $stopwatch.Elapsed.TotalSeconds, ($currentMemory / 1MB))
}
$databaseEvidence = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT count(DISTINCT ub.id),count(DISTINCT ms.id),count(ubs.*) FROM upload_batch ub JOIN upload_batch_segment ubs ON ubs.upload_batch_id=ub.id JOIN media_segment ms ON ms.id=ubs.media_segment_id WHERE ub.conversation_id='$conversationInternalId'::uuid" | Out-String).Trim()
$expectedTotal = $BatchCount * $SegmentsPerBatch
if ($preparedTotal -ne $expectedTotal -or $databaseEvidence -ne "$BatchCount|$expectedTotal|$expectedTotal") {
throw "Media capacity evidence mismatch: response=$preparedTotal database=$databaseEvidence expected=$expectedTotal"
}
$memoryAfter = Get-ApiMemoryBytes
$average = ($batchDurations | Measure-Object -Average).Average
$maximum = ($batchDurations | Measure-Object -Maximum).Maximum
$orderedDurations = @($batchDurations | Sort-Object)
$p95 = $orderedDurations[[Math]::Max(0, [Math]::Ceiling($orderedDurations.Count * 0.95) - 1)]
if ($p95 -ge 1.0) { throw ("Media manifest p95 target failed: {0:N3}s >= 1.000s" -f $p95) }
Write-Host ("Media manifest capacity passed: batches={0}, segments={1}, database={2}, avg_batch_seconds={3:N2}, p95_batch_seconds={4:N2}, max_batch_seconds={5:N2}, api_memory_before_mb={6:N1}, peak_mb={7:N1}, after_mb={8:N1}" -f $BatchCount, $expectedTotal, $databaseEvidence, $average, $p95, $maximum, ($memoryBefore / 1MB), ($memoryPeak / 1MB), ($memoryAfter / 1MB))
}
finally {
if ($created) {
$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='$conversationId'::uuid;
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM upload_batch WHERE conversation_id='$conversationInternalId'::uuid UNION 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 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;
"@
Invoke-Database $cleanupSql
}
}
+119
View File
@@ -0,0 +1,119 @@
param(
[ValidateRange(1, 100)][int]$Count = 100,
[ValidateRange(10, 600)][int]$CompletionTimeoutSeconds = 180
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000010'
$run = [Guid]::NewGuid().ToString('N')
$prefix = "privacy-capacity-$run"
$fixtureFileName = ".privacy-delete-$run.json"
$fixtureFile = Join-Path $projectRoot "test/load/$fixtureFileName"
$householdPublicId = $null
$householdInternalId = $null
function Invoke-Sql([string]$Sql) {
$result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if ($LASTEXITCODE -ne 0) { throw 'Privacy capacity SQL failed' }
return ($result | Out-String).Trim()
}
function Get-OwnerToken {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
try {
$ownerToken = Get-OwnerToken
$householdResponse = Invoke-RestMethod -Method Post -Uri "$apiBase/api/v1/households" -Headers @{
Authorization = "Bearer $ownerToken"
'X-Tenant-Id' = $tenantId
} -ContentType 'application/json' -Body (@{ name = "$prefix-household" } | ConvertTo-Json -Compress)
$householdPublicId = [string]$householdResponse.data.id
$householdEvidence = Invoke-Sql "SELECT h.id::text||'|'||h.owner_user_id::text||'|'||h.tenant_id::text FROM household h WHERE h.public_id='$householdPublicId'::uuid"
$householdParts = $householdEvidence -split '\|'
if ($householdParts.Count -ne 3) { throw "Cannot resolve capacity household: $householdEvidence" }
$householdInternalId = $householdParts[0]
$ownerInternalId = $householdParts[1]
$tenantId = $householdParts[2]
$setup = @"
INSERT INTO subject_profile(tenant_id,household_id,preferred_name,language,timezone,profile_data,conversation_preferences,created_by,updated_by)
SELECT '$tenantId'::uuid,'$householdInternalId'::uuid,'并发删除主体-'||lpad(g::text,3,'0'),'zh-CN','Asia/Shanghai',jsonb_build_object('capacity_run','$run'),'{}'::jsonb,'$ownerInternalId'::uuid,'$ownerInternalId'::uuid
FROM generate_series(1,$Count) g;
SELECT json_agg(json_build_object('subject_id',public_id::text,'preferred_name',preferred_name) ORDER BY preferred_name)::text
FROM subject_profile WHERE household_id='$householdInternalId'::uuid;
"@
$fixtureJson = ((Invoke-Sql $setup) -split "`n" | Select-Object -Last 1).Trim()
$fixtures = $fixtureJson | ConvertFrom-Json
if ($fixtures.Count -ne $Count) { throw "Expected $Count subjects but created $($fixtures.Count)" }
[IO.File]::WriteAllText($fixtureFile, $fixtureJson, [Text.UTF8Encoding]::new($false))
$startedAt = [DateTime]::UtcNow
docker run --rm -v "${projectRoot}/test/load:/scripts:ro" grafana/k6 run `
-e "API_BASE=http://host.docker.internal:58080" `
-e "ACCESS_TOKEN=$ownerToken" `
-e "FIXTURE_FILE=/scripts/$fixtureFileName" `
-e "VUS=$Count" `
/scripts/privacy-deletes.js
if ($LASTEXITCODE -ne 0) { throw 'Concurrent privacy delete submission thresholds failed' }
$submissionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds
$deadline = [DateTime]::UtcNow.AddSeconds($CompletionTimeoutSeconds)
do {
$evidence = Invoke-Sql @"
SELECT count(*)||'|'||
count(*) FILTER(WHERE pr.status='COMPLETED')||'|'||
count(*) FILTER(WHERE pr.status='PARTIAL_FAILED')||'|'||
count(*) FILTER(WHERE pr.status NOT IN('COMPLETED','PARTIAL_FAILED'))||'|'||
(SELECT count(*) FROM privacy_subtask ps JOIN privacy_request px ON px.id=ps.privacy_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid)||'|'||
(SELECT count(*) FROM tombstone t JOIN privacy_request px ON px.id=t.deletion_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid)
FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id
WHERE sp.household_id='$householdInternalId'::uuid;
"@
$parts = $evidence -split '\|'
if ($parts.Count -eq 6 -and [int]$parts[1] -eq $Count) { break }
Start-Sleep -Milliseconds 500
} while ([DateTime]::UtcNow -lt $deadline)
$completionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds
$expectedSubtasks = $Count * 6
if ($parts.Count -ne 6 -or [int]$parts[0] -ne $Count -or [int]$parts[1] -ne $Count -or [int]$parts[2] -ne 0 -or [int]$parts[3] -ne 0 -or [int]$parts[4] -ne $expectedSubtasks -or [int]$parts[5] -ne $Count) {
$failed = Invoke-Sql "SELECT pr.status||':'||count(*) FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid GROUP BY pr.status ORDER BY pr.status"
throw "Privacy delete reconciliation mismatch: evidence=$evidence statuses=$failed"
}
$profileEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE status='deleted' AND preferred_name='[已删除]' AND profile_data='{}'::jsonb AND conversation_preferences='{}'::jsonb) FROM subject_profile WHERE household_id='$householdInternalId'::uuid"
if ($profileEvidence -ne "$Count|$Count") { throw "Subject redaction mismatch: $profileEvidence" }
$subtaskEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE ps.status IN('COMPLETED','SKIPPED')) FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid"
if ($subtaskEvidence -ne "$expectedSubtasks|$expectedSubtasks") { throw "Privacy subtask convergence mismatch: $subtaskEvidence" }
Write-Host ("Concurrent privacy deletion passed: requests={0}, submitted_seconds={1:N2}, completed_seconds={2:N2}, profiles_redacted={3}, subtasks={4}, tombstones={5}" -f $Count, $submissionSeconds, $completionSeconds, $Count, $expectedSubtasks, $Count)
}
finally {
if ($householdInternalId) {
$cleanup = @"
BEGIN;
CREATE TEMP TABLE capacity_subject ON COMMIT DROP AS SELECT id,public_id FROM subject_profile WHERE household_id='$householdInternalId'::uuid;
CREATE TEMP TABLE capacity_privacy ON COMMIT DROP AS SELECT id,public_id FROM privacy_request WHERE subject_id IN(SELECT id FROM capacity_subject);
DELETE FROM alert_incident WHERE resource_public_id IN(SELECT public_id FROM capacity_privacy);
DELETE FROM tombstone WHERE deletion_request_id IN(SELECT id FROM capacity_privacy);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM capacity_privacy);
DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM capacity_privacy UNION SELECT public_id FROM capacity_subject UNION SELECT '$householdPublicId'::uuid);
DELETE FROM privacy_request WHERE id IN(SELECT id FROM capacity_privacy);
DELETE FROM subject_profile WHERE id IN(SELECT id FROM capacity_subject);
DELETE FROM household_member WHERE household_id='$householdInternalId'::uuid;
DELETE FROM household WHERE id='$householdInternalId'::uuid;
COMMIT;
"@
try { Invoke-Sql $cleanup | Out-Null } catch { Write-Warning "Capacity cleanup failed: $_" }
}
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile -Force }
}
+90
View File
@@ -0,0 +1,90 @@
param(
[ValidateSet('smoke','acceptance')][string]$Profile='smoke',
[string]$Duration='',
[int]$Rate=0,
[int]$DuplicateWindow=0,
[int]$ConversationPool=50
)
$ErrorActionPreference='Stop'
$PSNativeCommandUseErrorActionPreference=$true
$root=Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$run=[Guid]::NewGuid().ToString('N')
$model="load-callback-$run"
$prefix="load-callback-$run-"
$tenant='10000000-0000-0000-0000-000000000001'
$fixtures=$null
if($ConversationPool-lt1-or$ConversationPool-gt500){throw 'ConversationPool must be between 1 and 500'}
function Sql([string]$statement){
$result=$statement|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if($LASTEXITCODE-ne0){throw 'Provider callback load SQL failed'}
return ($result|Out-String).Trim()
}
try{
$setup=@"
BEGIN;
WITH selected AS (
SELECT household_id,id subject_id,tenant_id,profile_version FROM subject_profile WHERE tenant_id='$tenant'::uuid AND status='active' ORDER BY created_at LIMIT 1
), consent AS (
INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
SELECT tenant_id,subject_id,profile_version,'{}'::jsonb,jsonb_build_array('$model') FROM selected RETURNING id,tenant_id,subject_id
), context AS (
INSERT INTO context_snapshot(tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
SELECT c.tenant_id,c.subject_id,s.profile_version,c.id,'[]'::jsonb,'$model','s2s_hybrid',encode(digest('$model','sha256'),'hex'),'供应商通知负载夹具' FROM consent c JOIN selected s ON s.subject_id=c.subject_id RETURNING id,tenant_id,subject_id,consent_snapshot_id
), device_rows AS (
INSERT INTO device(tenant_id,serial_digest,model,hardware_revision,credential_ref,minimum_security_version)
SELECT '$tenant'::uuid,encode(digest('$run-device-'||g,'sha256'),'hex'),'$model','load','load://callback/'||g,'load' FROM generate_series(1,$ConversationPool) g RETURNING id
), numbered_devices AS (
SELECT id,row_number() OVER(ORDER BY id) n FROM device_rows
), conversation_row AS (
INSERT INTO conversation(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,lease_expires_at,started_at)
SELECT '$tenant'::uuid,s.household_id,s.subject_id,d.id,c.consent_snapshot_id,c.id,'$prefix'||d.n,'s2s_hybrid','none','ACTIVE',now()+interval '15 minutes',now()+interval '15 minutes',now()
FROM selected s CROSS JOIN context c CROSS JOIN numbered_devices d RETURNING id,public_id,provider_task_ref
), task AS (
INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status,lease_expires_at)
SELECT id,'mock',provider_task_ref,'room-'||provider_task_ref,'device-'||provider_task_ref,'agent-'||provider_task_ref,'ACTIVE',now()+interval '15 minutes' FROM conversation_row
)
SELECT string_agg(public_id::text||'|'||provider_task_ref,',' ORDER BY provider_task_ref) FROM conversation_row;
COMMIT;
"@
$fixtures=((Sql $setup)-split"`n"|Select-Object -Last 1).Trim()
if(($fixtures-split',').Count-ne$ConversationPool-or$fixtures-notmatch'^[A-Za-z0-9|,\-]+$'){throw "Invalid provider load fixture pool"}
$fixedOccurredAt=[DateTime]::UtcNow.ToString('o')
$arguments=@('run','--rm','-v',"${PWD}/test/load:/scripts:ro",'grafana/k6','run','-e','CALLBACK_URL=http://host.docker.internal:58081','-e',"PROFILE=$Profile",'-e',"PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum",'-e',"PROVIDER_FIXTURES=$fixtures",'-e',"OCCURRED_AT=$fixedOccurredAt")
if($Duration){$arguments+=@('-e',"DURATION=$Duration")}
if($Rate-gt0){$arguments+=@('-e',"RATE=$Rate")}
if($DuplicateWindow-gt0){$arguments+=@('-e',"DUPLICATE_WINDOW=$DuplicateWindow")}
$arguments+='/scripts/provider-callbacks.js'
docker @arguments
if($LASTEXITCODE-ne0){throw 'Provider callback k6 thresholds failed'}
$minimumUnique=if($DuplicateWindow-gt0){$DuplicateWindow}elseif($Profile-eq'smoke'){100}else{300000}
$evidence=Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE processing_status IN('processed','ignored'))||'|'||(SELECT count(*) FROM conversation WHERE provider_task_ref LIKE '$prefix%' AND status='ACTIVE')||'|'||(SELECT count(*) FROM rtc_task rt JOIN conversation c ON c.id=rt.conversation_id WHERE c.provider_task_ref LIKE '$prefix%' AND rt.status='ACTIVE') FROM provider_event WHERE conversation_id IN(SELECT id FROM conversation WHERE provider_task_ref LIKE '$prefix%');"
$parts=$evidence-split'\|'
if($parts.Count-ne4-or[int]$parts[0]-lt$minimumUnique-or$parts[0]-ne$parts[1]-or[int]$parts[2]-ne$ConversationPool-or[int]$parts[3]-ne$ConversationPool){throw "Provider callback persistence mismatch: $evidence"}
Write-Host "Provider callback load passed: profile=$Profile, conversations=$ConversationPool, unique_events=$($parts[0]), all_processed_or_ignored=true, conversation_tasks_active=$($parts[3])"
}finally{
if($fixtures){
$cleanup=@"
BEGIN;
CREATE TEMP TABLE load_conversation ON COMMIT DROP AS SELECT id,public_id,context_snapshot_id,consent_snapshot_id,device_id FROM conversation WHERE provider_task_ref LIKE '$prefix%';
DELETE FROM alert_incident WHERE resource_type='conversation' AND resource_public_id IN(SELECT public_id FROM load_conversation);
DELETE FROM web_event WHERE resource_type='conversation' AND resource_public_id IN(SELECT public_id FROM load_conversation);
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM load_conversation);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM load_conversation);
DELETE FROM alert_incident
WHERE tenant_id='$tenant'::uuid AND fingerprint='outbox-dead'
AND NOT EXISTS(SELECT 1 FROM outbox_event WHERE tenant_id='$tenant'::uuid AND status='dead');
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM load_conversation);
DELETE FROM provider_event WHERE conversation_id IN(SELECT id FROM load_conversation);
DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM load_conversation);
DELETE FROM conversation WHERE id IN(SELECT id FROM load_conversation);
DELETE FROM device WHERE id IN(SELECT device_id FROM load_conversation);
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM load_conversation);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM load_conversation);
COMMIT;
"@
try{Sql $cleanup|Out-Null}catch{Write-Warning $_}
}
}
+35
View File
@@ -0,0 +1,35 @@
param(
[int]$Connections=500,
[int]$OpenRate=40,
[int]$Reconnects=50,
[int]$HoldSeconds=5
)
$ErrorActionPreference='Stop'
$PSNativeCommandUseErrorActionPreference=$true
if($Connections-lt1-or$Connections-gt5000-or$OpenRate-lt1-or$OpenRate-gt50-or$Reconnects-lt0-or$Reconnects-gt$Connections-or$HoldSeconds-lt1-or$HoldSeconds-gt20){throw 'SSE load parameters exceed the safe local boundary'}
$root=Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$tokenUrl='http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$eventId=$null
function Sql([string]$statement){
$result=$statement|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if($LASTEXITCODE-ne0){throw 'SSE load fixture SQL failed'}
return ($result|Out-String).Trim()
}
try{
$eventId=Sql "INSERT INTO web_event(tenant_id,household_id,subject_id,event_type,resource_type,resource_public_id,status,expires_at) SELECT t.id,h.id,sp.id,'story.created','load_fixture',gen_random_uuid(),'ready',now()+interval '15 minutes' FROM tenant t JOIN household h ON h.tenant_id=t.id JOIN subject_profile sp ON sp.tenant_id=t.id AND sp.household_id=h.id WHERE t.public_id='10000000-0000-0000-0000-000000000010'::uuid ORDER BY h.created_at,sp.created_at LIMIT 1 RETURNING id;"
if($eventId-notmatch'^\d+$'){throw "Invalid SSE fixture event id: $eventId"}
$token=(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{grant_type='password';client_id='voice-platform-web';username='owner';password='LocalOnly123!';scope='openid'}).access_token
$previous=$env:ACCESS_TOKEN
try{
$env:ACCESS_TOKEN=$token
go run ./cmd/load-sse -url http://localhost:58080/api/v1/events -connections $Connections -open-rate $OpenRate -reconnects $Reconnects -hold "${HoldSeconds}s"
if($LASTEXITCODE-ne0){throw 'SSE connection load failed'}
}finally{
$env:ACCESS_TOKEN=$previous
}
}finally{
if($eventId-match'^\d+$'){try{Sql "DELETE FROM web_event WHERE id=$eventId;"|Out-Null}catch{Write-Warning $_}}
}
+98
View File
@@ -0,0 +1,98 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$api = 'http://localhost:58080'
$callbacks = 'http://localhost:58081'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000001'
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$sentinel = 'security-body-' + [Guid]::NewGuid().ToString('N')
function Get-Token([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 Get-StatusAndBody([scriptblock]$Action) {
try {
$response = & $Action
return [pscustomobject]@{ Status = [int]$response.StatusCode; Body = [string]$response.Content }
} catch {
$status = [int]$_.Exception.Response.StatusCode
$body = ''
if ($_.ErrorDetails.Message) { $body = [string]$_.ErrorDetails.Message }
return [pscustomobject]@{ Status = $status; Body = $body }
}
}
function Invoke-Scalar([string]$Sql) {
$result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if ($LASTEXITCODE -ne 0) { throw 'Security smoke SQL failed' }
return ($result | Out-String).Trim()
}
function New-SignedConfigHeaders {
$path = '/device/v2/config'
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([byte[]]::new(0))).ToLowerInvariant()
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$nonce = [Guid]::NewGuid().ToString('N')
$canonical = "GET`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() }
return @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
}
$owner = Get-Token 'owner'
$outsider = Get-Token 'outsider'
$countsBefore = Invoke-Scalar "SELECT count(*)||'|'||(SELECT count(*) FROM subject_profile) FROM household"
$me = Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" -Headers @{ Authorization = "Bearer $owner" }
if ($me.StatusCode -ne 200 -or $me.Headers['X-Content-Type-Options'] -ne 'nosniff' -or $me.Headers['X-Frame-Options'] -ne 'DENY' -or $me.Headers['Referrer-Policy'] -ne 'no-referrer') {
throw 'Security response headers are incomplete'
}
$anonymous = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" }
if ($anonymous.Status -ne 401) { throw "Anonymous access was not rejected: $($anonymous.Status)" }
$opsDenied = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/alerts" -Headers @{ Authorization = "Bearer $outsider"; 'X-Tenant-Id' = $tenantId } }
if ($opsDenied.Status -ne 403) { throw "Viewer reached operations alerts: $($opsDenied.Status)" }
foreach ($path in @('/api/v1/subjects/%27%20OR%201%3D1--','/api/v1/subjects/..%2F..%2Fmetrics','/api/v1/privacy-requests/not-a-uuid')) {
$result = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri ($api + $path) -Headers @{ Authorization = "Bearer $owner" } }
if ($result.Status -ne 400) { throw "Malformed resource path did not fail safely: $path -> $($result.Status)" }
if ($result.Body -match '(?i)(sqlstate|syntax error|postgres|pgx|select\s|database error)') { throw "Database detail leaked for malformed path: $path" }
}
$largeBody = '{"name":"' + $sentinel + (('x') * 1100000) + '"}'
$oversized = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households" -Headers @{ Authorization = "Bearer $owner" } -ContentType 'application/json' -Body $largeBody }
if ($oversized.Status -ne 413 -or $oversized.Body -notmatch 'REQUEST_BODY_TOO_LARGE') { throw "Oversized request was not rejected with safe 413: $($oversized.Status)" }
$invalidCallback = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$callbacks/callbacks/v1/vertc/voice-chat" -Headers @{
'X-Provider-Timestamp' = [DateTime]::UtcNow.ToString('o')
'X-Provider-Signature' = 'invalid-signature'
} -ContentType 'application/json' -Body '{"provider":"mock"}' }
if ($invalidCallback.Status -ne 401 -or $invalidCallback.Body -notmatch 'PROVIDER_CALLBACK_AUTHENTICATION_FAILED') { throw "Invalid callback signature was not rejected safely: $($invalidCallback.Status)" }
$signedHeaders = New-SignedConfigHeaders
$firstDevice = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/device/v2/config" -Headers $signedHeaders }
$replayDevice = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/device/v2/config" -Headers $signedHeaders }
if ($firstDevice.Status -ne 200 -or $replayDevice.Status -ne 401) { throw "Device nonce replay result was unexpected: first=$($firstDevice.Status) replay=$($replayDevice.Status)" }
$countsAfter = Invoke-Scalar "SELECT count(*)||'|'||(SELECT count(*) FROM subject_profile) FROM household"
if ($countsAfter -ne $countsBefore) { throw "Security probes changed business counts: before=$countsBefore after=$countsAfter" }
$recentLogs = docker compose logs --since 2m api callbacks | Out-String
if ($recentLogs.Contains($sentinel)) { throw 'Oversized request content appeared in service logs' }
Write-Host "HTTP security smoke passed: authn_401=true, authz_403=true, malformed_paths_400=true, oversized_413=true, callback_signature_401=true, device_replay_401=true, security_headers=true, body_not_logged=true, business_counts=$countsAfter"
+67
View File
@@ -0,0 +1,67 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$key = 'acceptance-snapshot-smoke-' + [Guid]::NewGuid().ToString('N')
$requestID = 'acceptance-snapshot-smoke-' + [Guid]::NewGuid().ToString('N')
$snapshot = $null
function Get-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
}
try {
$owner = Get-Token 'owner'
$outsider = Get-Token 'outsider'
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($imageDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API候选镜像摘要' }
$body = @{
candidate_version = '0.51.0-rc.local'
build_digest = $imageDigest
environment = 'LAB'
run_type = 'REAL_DEVICE_PROVIDER'
scope = '本地真实OIDC和平台技术预检'
required_dependencies = @('oidc_jwks')
reason = '验证发布候选验收快照的真实登录、门禁聚合和归档输出'
} | ConvertTo-Json -Compress
$headers = @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $key; 'X-Request-Id' = $requestID}
$createdResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/acceptance-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
$snapshot = ($createdResponse.Content | ConvertFrom-Json).data
if ($createdResponse.StatusCode -ne 201 -or $snapshot.redaction_policy -ne 'ops-acceptance-minimal-v1') { throw '验收快照首次生成结果不正确' }
if ($snapshot.database.migration_version -lt 36 -or $snapshot.database.dirty) { throw '验收快照未正确冻结数据库迁移状态' }
if (@($snapshot.external_dependencies).Count -ne 1 -or $snapshot.external_dependencies[0].dependency -ne 'oidc_jwks' -or -not $snapshot.external_dependencies[0].gate_satisfied) { throw '验收快照未正确冻结OIDC外部依赖状态' }
if (@($snapshot.gates).Count -ne 6) { throw '验收快照门禁数量不正确' }
$failedGates = @($snapshot.gates | Where-Object { -not $_.satisfied })
if ($snapshot.overall_gate_satisfied -ne ($failedGates.Count -eq 0)) { throw '验收快照总结果与六项门禁不一致' }
$blockingItems = @($snapshot.alerts.blocking_items)
$expectedItems = [Math]::Min([int]$snapshot.alerts.blocking, 100)
if ($blockingItems.Count -ne $expectedItems -or $snapshot.alerts.blocking_items_truncated -ne ($snapshot.alerts.blocking -gt 100)) { throw '阻断告警定位数量或截断标识不正确' }
foreach ($item in $blockingItems) {
if ($item.id -notmatch '^[0-9a-fA-F-]{36}$' -or -not $item.rule_code -or @('PRESENT','MISSING','AGGREGATE','UNKNOWN') -notcontains $item.resource_state) { throw '阻断告警定位字段不正确' }
}
if (($createdResponse.Content -match 'https?://') -or ($createdResponse.Content -match '"endpoint"')) { throw '验收快照泄露了外部探针端点' }
Start-Sleep -Milliseconds 550
$replayResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/acceptance-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
if ($replayResponse.StatusCode -ne 200 -or $replayResponse.Headers.'Idempotency-Replayed' -ne 'true' -or ($replayResponse.Content | ConvertFrom-Json).data.id -ne $snapshot.id) { throw '验收快照幂等重放不正确' }
Start-Sleep -Milliseconds 550
try {
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/acceptance-snapshots" -Headers @{Authorization = "Bearer $outsider"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = ('outsider-' + $key)} -ContentType 'application/json' -Body $body | Out-Null
throw '跨客户组织生成验收快照不应成功'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 403) { throw }
}
$failedCodes = if ($failedGates.Count) { ($failedGates.code -join ',') } else { 'none' }
Write-Host "发布候选验收快照烟测通过:真实OIDC、迁移冻结、外部依赖、六项门禁、阻断告警定位、幂等重放、租户隔离和端点脱敏均正确;当前阻断项=$failedCodes。"
} finally {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; DELETE FROM audit_event WHERE request_id='$requestID'; DELETE FROM idempotency_record WHERE idempotency_key='$key'; COMMIT;" | Out-Null
Write-Host '已清理验收快照烟测审计和幂等记录;快照本身不在服务端留存。'
}
@@ -0,0 +1,34 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('agent-models-conformance-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'capture.json'
$reportPath = Join-Path $temporaryRoot 'report.json'
function Category-Results([string[]]$categories) {
return @($categories | ForEach-Object { @{ category = $_; status = 'PASS'; evidence_ref = "local-toolchain:$_" } })
}
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($imageDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API镜像摘要' }
$observedAt = (Get-Date).ToUniversalTime().AddMinutes(-1)
$capture = @{
schema_version = 'real-agent-models-capture-v1'; evidence_source = 'LOCAL_MODELS'; candidate_version = '0.63.0-rc.local'; build_digest = $imageDigest; environment = 'LOCAL'
change_ticket_ref = 'local-smoke'; executed_by_ref = 'local-toolchain'; attested_by_ref = 'local-toolchain'; attested_at = $observedAt.ToString('o'); collected_at = (Get-Date).ToUniversalTime().ToString('o')
generation = @{ provider_class = 'LOCAL_IMPLEMENTATION'; provider_ref = 'local-deterministic-generator'; model_config_ref = 'runtime-config://voice/agent-baseline-v1'; model_resource_ref = 'local-generator-v1'; account_ref = 'local'; region_ref = 'local'; data_terms_ref = 'local:no-external-transfer'; quota_evidence_ref = 'local:no-quota'; evaluation_evidence_ref = 'artifacts/agent-eval-report.mock.v1.json' }
embedding = @{ provider_class = 'LOCAL_IMPLEMENTATION'; provider_ref = 'local-deterministic-hash-v1'; model_ref = 'local-embedding-v1'; dimensions = 1536; dataset_ref = 'local-schema-smoke'; thresholds_ref = 'local-schema-smoke'; query_count = 6; passed_query_count = 6; data_terms_ref = 'local:no-external-transfer'; quota_evidence_ref = 'local:no-quota'; categories = Category-Results @('EXACT_CONFIRMED_MEMORY','PARAPHRASE_RECALL','CROSS_SUBJECT_ISOLATION','REVOKED_MEMORY_EXCLUSION','SENSITIVE_MEMORY_EXCLUSION','NO_MATCH_FALLBACK') }
content_safety = @{ provider_class = 'LOCAL_IMPLEMENTATION'; provider_ref = 'local-content-policy'; policy_ref = 'local-content-safety-policy'; dataset_ref = 'local-schema-smoke'; thresholds_ref = 'local-schema-smoke'; case_count = 6; passed_case_count = 6; data_terms_ref = 'local:no-external-transfer'; audit_evidence_ref = 'internal/contentpolicy'; categories = Category-Results @('VERIFICATION_CODE','FINANCIAL_ACCOUNT','PROMPT_INJECTION','SENSITIVE_MEDICAL','NORMAL_BENIGN','MODEL_OUTPUT_SAFETY') }
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 14), [System.Text.UTF8Encoding]::new($false))
go run ./cmd/agent-models-conformance -capture $capturePath -suite ./testdata/agent-eval-suite.v1.json -observations ./testdata/agent-eval-observations.mock.v1.json -agent-report ./artifacts/agent-eval-report.mock.v1.json -candidate-version '0.63.0-rc.local' -build-digest $imageDigest -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_AGENT_TOOLCHAIN' -or -not $report.external_validation_required -or $report.generation_case_count -ne 10 -or @($report.embedding_categories).Count -ne 6 -or @($report.content_safety_categories).Count -ne 6) { throw '本地Agent模型工具链报告边界不正确' }
Write-Host 'Agent模型证据工具链冒烟通过:固定10类生成评测、6类Embedding和6类内容安全结构完整;真实模型门禁继续待验。'
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '已清理Agent模型证据工具链临时文件。'
}
+109
View File
@@ -0,0 +1,109 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$api = 'http://localhost:58080'
$receiver = 'http://localhost:59210'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenant = '10000000-0000-0000-0000-000000000010'
$key = 'alert-delivery-test-smoke-' + [Guid]::NewGuid().ToString('N')
$routeKey = 'alert-delivery-route-smoke-' + [Guid]::NewGuid().ToString('N')
$requestID = 'alert-delivery-test-smoke-' + [Guid]::NewGuid().ToString('N')
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('alert-receiver-conformance-' + [Guid]::NewGuid().ToString('N'))
$routesPath = Join-Path $temporaryRoot 'routes.json'
$testsPath = Join-Path $temporaryRoot 'tests.json'
$incidentPath = Join-Path $temporaryRoot 'incident.json'
$capturePath = Join-Path $temporaryRoot 'receiver-capture.json'
$reportPath = Join-Path $temporaryRoot 'receiver-report.json'
$test = $null
$route = $null
$createdRoute = $false
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$token = (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
$headers = @{Authorization = "Bearer $token"; 'X-Tenant-Id' = $tenant}
$routes = (Invoke-RestMethod -Method Get -Uri "$api/api/v1/ops/alert-routing-policies" -Headers $headers).data.items
$route = @($routes | Where-Object status -eq 'ACTIVE' | Select-Object -First 1)[0]
if ($null -eq $route) {
$routeBody = @{
category = 'TECHNICAL'; severity = 'HIGH'; receiver_group = 'local-platform-oncall'; response_minutes = 15
escalation_after_minutes = 30; escalation_receiver_group = 'local-incident-command'
runbook_ref = 'runbook://local/alert-delivery-test'; change_summary = '本地告警接收器联通烟测临时路由'
} | ConvertTo-Json -Compress
$routeResponse = Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/alert-routing-policies" -Headers ($headers + @{'Idempotency-Key' = $routeKey; 'X-Request-Id' = $requestID}) -ContentType 'application/json' -Body $routeBody
$route = $routeResponse.data
$createdRoute = $true
}
$createHeaders = $headers.Clone()
$createHeaders['Idempotency-Key'] = $key
$createHeaders['X-Request-Id'] = $requestID
$body = @{reason = '本地真实Worker、HMAC签名与接收器联通验证'} | ConvertTo-Json -Compress
$created = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alert-routing-policies/$($route.id)/delivery-tests" -Headers $createHeaders -ContentType 'application/json' -Body $body
$test = ($created.Content | ConvertFrom-Json).data
if ($created.StatusCode -ne 201 -or $test.status -ne 'PENDING' -or -not $test.id -or -not $test.test_ref) { throw '联通测试创建结果不正确' }
$deadline = (Get-Date).AddSeconds(15)
do {
Start-Sleep -Milliseconds 400
$listed = (Invoke-RestMethod -Method Get -Uri "$api/api/v1/ops/alert-delivery-tests" -Headers $headers).data.items
$current = @($listed | Where-Object id -eq $test.id | Select-Object -First 1)[0]
} while (($null -eq $current -or @('PENDING','PROCESSING') -contains $current.status) -and (Get-Date) -lt $deadline)
if ($null -eq $current -or $current.status -ne 'DELIVERED' -or $current.attempt_count -lt 1) { throw "Worker未完成联通测试投递,当前状态=$($current.status)" }
$received = (Invoke-RestMethod -Method Get -Uri "$receiver/events?test_ref=$($test.test_ref)").items
if (@($received).Count -ne 1 -or $received[0].event_type -ne 'alert.webhook_test_requested' -or $received[0].data.action -ne 'TEST') { throw '验签接收器未记录唯一联通测试事件' }
$incidentCount = (docker compose exec -T postgres psql -U voice -d voice_platform -tAc "SELECT count(*) FROM alert_incident WHERE title='告警接收器联通测试' OR summary LIKE '这是一条由运营工作台发起的受控测试通知%';").Trim()
if ($incidentCount -ne '0') { throw '联通测试错误地创建了正式告警事件' }
$replayed = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alert-routing-policies/$($route.id)/delivery-tests" -Headers $createHeaders -ContentType 'application/json' -Body $body
if ($replayed.StatusCode -ne 200 -or $replayed.Headers.'Idempotency-Replayed' -ne 'true' -or ($replayed.Content | ConvertFrom-Json).data.id -ne $test.id) { throw '联通测试幂等重放不正确' }
$now = [DateTime]::UtcNow
$mockIncidentId = [Guid]::NewGuid().ToString()
$mockReceivedAt = $now.AddMinutes(-2)
$mockAcknowledgedAt = $now.AddMinutes(-1)
$buildDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
$jsonOptions = @{Depth = 12; Compress = $false}
[System.IO.File]::WriteAllText($routesPath, (@{items = @($route)} | ConvertTo-Json @jsonOptions), [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($testsPath, (@{items = @($current)} | ConvertTo-Json @jsonOptions), [System.Text.UTF8Encoding]::new($false))
$incident = @{
id = $mockIncidentId; status = 'RESOLVED'; receiver_group = $route.receiver_group
first_detected_at = $now.AddMinutes(-3).ToString('o'); resolved_at = $now.ToString('o')
actions = @(@{action_type = 'ACKNOWLEDGED'; created_at = $mockAcknowledgedAt.ToString('o')})
}
[System.IO.File]::WriteAllText($incidentPath, ($incident | ConvertTo-Json @jsonOptions), [System.Text.UTF8Encoding]::new($false))
$capture = @{
schema_version = 'alert-receiver-capture-v1'; evidence_source = 'MOCK'; candidate_version = '0.63.0-rc.local'; build_digest = $buildDigest
receiver_system_ref = 'mock-alert-receiver:local'; collected_at = $now.ToString('o')
group_mappings = @(
@{receiver_group = $route.receiver_group; mapped_target_refs = @('local-sink:primary'); verified_at = $now.AddMinutes(-1).ToString('o')},
@{receiver_group = $route.escalation_receiver_group; mapped_target_refs = @('local-sink:escalation'); verified_at = $now.AddMinutes(-1).ToString('o')}
)
test_receipts = @(@{test_ref = $test.test_ref; event_id = $received[0].event_id; receiver_group = $received[0].data.receiver_group; delivery_count = 1; signature_verified = $true; received_at = ([DateTime]$received[0].received_at).ToUniversalTime().ToString('o')})
formal_drill = @{incident_id = $mockIncidentId; receiver_group = $route.receiver_group; delivery_count = 1; signature_verified = $true; controlled_drill = $true; received_at = $mockReceivedAt.ToString('o'); acknowledged_at = $mockAcknowledgedAt.ToString('o'); acknowledgement_ref = 'mock-ticket:local-only'}
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json @jsonOptions), [System.Text.UTF8Encoding]::new($false))
$nativePreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
go run ./cmd/alert-receiver-conformance -routes $routesPath -tests $testsPath -incident $incidentPath -receiver $capturePath -candidate-version '0.63.0-rc.local' -build-digest $buildDigest -output $reportPath
$conformanceExit = $LASTEXITCODE
$PSNativeCommandUseErrorActionPreference = $nativePreference
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($conformanceExit -ne 0) { throw "接收器一致性校验失败:$((@($report.checks | Where-Object status -eq 'FAIL').code) -join ',');coverage=$($report.route_coverage | ConvertTo-Json -Compress);cloud=$($current.id)/$($current.test_ref);receipt=$($received[0].event_id)/$($received[0].test_ref)" }
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'MOCK_RECEIVER' -or -not $report.external_validation_required -or $report.active_route_count -ne 1 -or -not $report.formal_drill_verified) { throw '本地接收器一致性报告未保持模拟证据边界' }
Write-Host "告警接收器联通烟测通过:路由=$($route.id),任务=$($test.id),真实Worker投递、HMAC验签、唯一回执和映射一致;报告范围=MOCK_RECEIVER,真实网关门禁仍待验。"
} finally {
if ($null -ne $test) {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; DELETE FROM audit_event WHERE request_id='$requestID'; DELETE FROM idempotency_record WHERE idempotency_key IN ('$key','$routeKey'); DELETE FROM outbox_event WHERE public_id='$($test.id)'::uuid; COMMIT;" | Out-Null
}
if ($createdRoute -and $null -ne $route) {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM alert_routing_policy WHERE public_id='$($route.id)'::uuid;" | Out-Null
}
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
if ($null -ne $test -or $createdRoute) { Write-Host '已清理联通烟测的临时路由、审计、幂等和Outbox记录;本地接收器只保留进程内验签样本。' }
}
+96
View File
@@ -0,0 +1,96 @@
$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'
$routeId=$null;$silenceId=$null;$incidentIds=@();$outboxIds=@()
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 -q -t -A -c $sql|Out-String).Trim()}
function InsertDeadOutbox(){
$id=Scalar "INSERT INTO outbox_event(tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,last_error_code,dead_at,payload_hash) SELECT id,'alert_smoke',gen_random_uuid(),'alert.smoke.dead','{}'::jsonb,'dead',12,'SMOKE_DEAD',now(),repeat('0',64) FROM tenant WHERE public_id='$tenantPublicId'::uuid RETURNING id::text"
if($id-notmatch'^[0-9a-fA-F-]{36}$'){throw "Unable to create dead outbox signal: $id"}
$script:outboxIds+=$id
return $id
}
function WaitAlert([string]$token,[string]$expectedStatus,[int]$seconds=20){
for($i=0;$i-lt$seconds;$i++){
$result=Invoke-RestMethod -Uri "$api/api/v1/ops/alerts" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenantPublicId}
$item=@($result.data.items|Where-Object{$_.fingerprint-eq'outbox-dead' -and (!$expectedStatus-or$_.status-eq$expectedStatus) -and $_.receiver_group-eq'platform-oncall'})|Select-Object -First 1
if($item){return $item}
Start-Sleep -Seconds 1
}
throw "Alert outbox-dead did not reach status $expectedStatus"
}
function Detail([string]$token,[string]$id){(Invoke-RestMethod -Uri "$api/api/v1/ops/alerts/$id" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenantPublicId}).data}
function Transition([string]$token,[object]$item,[string]$action,[string]$note){
$body=@{note=$note}|ConvertTo-Json -Compress
return (Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/alerts/$($item.id)/$action" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenantPublicId;'If-Match'="`"v$($item.row_version)`""} -ContentType 'application/json' -Body $body).data
}
try{
$owner=Token 'owner';$outsider=Token 'outsider'
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/alerts" -Headers @{Authorization="Bearer $outsider";'X-Tenant-Id'=$tenantPublicId}} 403
$routeKey='alert-route-'+[Guid]::NewGuid().ToString('N')
$routeBody=@{category='TECHNICAL';severity='HIGH';receiver_group='platform-oncall';response_minutes=1;escalation_after_minutes=1;escalation_receiver_group='incident-command';runbook_ref='runbook://platform/outbox-dead-letter';change_summary='告警闭环冒烟路由'}|ConvertTo-Json -Compress
$created=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alert-routing-policies" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$routeKey} -ContentType 'application/json' -Body $routeBody
$route=($created.Content|ConvertFrom-Json).data;$routeId=[string]$route.id
if($route.status-ne'ACTIVE'-or$route.receiver_group-ne'platform-oncall'-or$route.runbook_ref-ne'runbook://platform/outbox-dead-letter'){throw 'Active routing policy was not created'}
$replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alert-routing-policies" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$routeKey} -ContentType 'application/json' -Body $routeBody
if($replay.StatusCode-ne200-or[string]$replay.Headers.'Idempotency-Replayed'-ne'true'-or($replay.Content|ConvertFrom-Json).data.id-ne$routeId){throw 'Alert route idempotency replay failed'}
$firstOutbox=InsertDeadOutbox
$incident=WaitAlert $owner 'OPEN'
$incidentIds+=[string]$incident.id
if($incident.receiver_group-ne'platform-oncall'-or$incident.runbook_ref-ne'runbook://platform/outbox-dead-letter'){throw "Detected incident did not freeze active route context: $($incident|ConvertTo-Json -Compress -Depth 6)"}
$detectedReceiver=Scalar "SELECT payload->>'receiver_group' FROM outbox_event WHERE event_type='alert.webhook_requested' AND aggregate_id='$($incident.id)'::uuid AND payload->>'action'='DETECTED'"
if($detectedReceiver-ne'platform-oncall'){throw "Detected notification did not target the active receiver group: $detectedReceiver"}
Scalar "DELETE FROM outbox_event WHERE id='$firstOutbox'::uuid"|Out-Null
Scalar "UPDATE alert_incident SET first_detected_at=now()-interval '2 minutes' WHERE public_id='$($incident.id)'::uuid RETURNING row_version"|Out-Null
for($i=0;$i-lt20;$i++){Start-Sleep -Seconds 1;$incident=Detail $owner $incident.id;if($incident.escalated_at){break}}
if(-not$incident.escalated_at-or-not(@($incident.actions.action_type)-contains'ESCALATED')){throw 'Incident escalation action was not recorded'}
$escalatedReceiver=Scalar "SELECT payload->>'receiver_group' FROM outbox_event WHERE event_type='alert.webhook_requested' AND aggregate_id='$($incident.id)'::uuid AND payload->>'action'='ESCALATED'"
if($escalatedReceiver-ne'incident-command'){throw "Escalated notification did not target the escalation receiver group: $escalatedReceiver"}
$beforeAcknowledge=$incident.row_version
$incident=Transition $owner $incident 'acknowledge' '值班人员已确认并接手死信处置'
if($incident.status-ne'ACKNOWLEDGED'-or-not$incident.current_owner){throw 'Incident acknowledgement did not assign an owner'}
$staleBody=@{note='使用过期版本重复确认'}|ConvertTo-Json -Compress
Expected {Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alerts/$($incident.id)/mitigate" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'="`"v$beforeAcknowledge`""} -ContentType 'application/json' -Body $staleBody} 412
$incident=Transition $owner $incident 'mitigate' '死信根因已修复且待办已受控重放'
if($incident.status-ne'MITIGATED'){throw 'Incident mitigation failed'}
$incident=Transition $owner $incident 'resolve' '监测指标恢复,复核无残留死信后关闭'
if($incident.status-ne'RESOLVED'){throw 'Incident resolution failed'}
$actionTypes=@($incident.actions.action_type)
foreach($expectedAction in @('DETECTED','ESCALATED','ACKNOWLEDGED','MITIGATED','RESOLVED')){if($actionTypes-notcontains$expectedAction){throw "Missing action $expectedAction in incident timeline"}}
$silenceKey='alert-silence-'+[Guid]::NewGuid().ToString('N')
$now=[DateTime]::UtcNow
$silenceBody=@{rule_code='OUTBOX_DEAD_LETTER';matcher=@{};reason='计划内死信重放演练';starts_at=$now.AddMinutes(-1).ToString('o');ends_at=$now.AddHours(1).ToString('o')}|ConvertTo-Json -Compress
$silenceResponse=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/alert-silences" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'Idempotency-Key'=$silenceKey} -ContentType 'application/json' -Body $silenceBody
$silence=($silenceResponse.Content|ConvertFrom-Json).data;$silenceId=[string]$silence.id
$secondOutbox=InsertDeadOutbox
$silencedIncident=WaitAlert $owner 'SILENCED'
$incidentIds+=[string]$silencedIncident.id
$silencedIncident=Detail $owner $silencedIncident.id
if(-not(@($silencedIncident.actions.action_type)-contains'SILENCED')){throw 'Silenced detection did not preserve an explicit silence action'}
$revokeBody=@{reason='演练结束,立即恢复处置队列'}|ConvertTo-Json -Compress
$revoked=(Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/alert-silences/$silenceId/revoke" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenantPublicId;'If-Match'="`"v$($silence.row_version)`""} -ContentType 'application/json' -Body $revokeBody).data
if(-not$revoked.revoked_at){throw 'Silence revocation was not recorded'}
$reopened=WaitAlert $owner 'OPEN'
$reopened=Detail $owner $reopened.id
if(-not(@($reopened.actions.action_type)-contains'REOPENED')){throw 'Incident did not reopen after silence revocation'}
Scalar "DELETE FROM outbox_event WHERE id='$secondOutbox'::uuid"|Out-Null
$reopened=Transition $owner $reopened 'resolve' '静默撤销后复核并关闭测试事件'
Write-Host "Alert smoke passed: tenant isolation, versioned route, automatic detection/deduplication, escalation, ETag conflict, audited acknowledge-mitigate-resolve, silence and reopen: $($incident.id), $($reopened.id)"
}finally{
foreach($id in $outboxIds){if($id-match'^[0-9a-fA-F-]{36}$'){Scalar "DELETE FROM outbox_event WHERE id='$id'::uuid"|Out-Null}}
$incidentIds=@($incidentIds|Where-Object{$_-match'^[0-9a-fA-F-]{36}$'}|Select-Object -Unique)
if($incidentIds.Count){$ids=($incidentIds|ForEach-Object{"'$_'::uuid"})-join',';$sql="BEGIN; DELETE FROM outbox_event WHERE event_type='alert.webhook_requested' AND aggregate_id IN($ids); DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM alert_incident WHERE public_id IN($ids)); DELETE FROM alert_incident WHERE public_id IN($ids); COMMIT;";$sql|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
if($silenceId-match'^[0-9a-fA-F-]{36}$'){$sql="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM alert_silence WHERE public_id='$silenceId'::uuid); DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM alert_silence WHERE public_id='$silenceId'::uuid); DELETE FROM alert_silence WHERE public_id='$silenceId'::uuid; COMMIT;";$sql|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
if($routeId-match'^[0-9a-fA-F-]{36}$'){$sql="BEGIN; DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM alert_routing_policy WHERE public_id='$routeId'::uuid); DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM alert_routing_policy WHERE public_id='$routeId'::uuid); DELETE FROM alert_routing_policy WHERE public_id='$routeId'::uuid; COMMIT;";$sql|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null}
}
+43
View File
@@ -0,0 +1,43 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
function Get-LocalAccessToken([string]$Username) {
$response = 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'
}
return $response.access_token
}
$expectedRoles = @{ owner = 'owner'; editor = 'editor'; outsider = 'viewer' }
foreach ($username in $expectedRoles.Keys) {
$accessToken = Get-LocalAccessToken -Username $username
$currentUser = Invoke-RestMethod -Uri 'http://localhost:58080/api/v1/me' `
-Headers @{ Authorization = "Bearer $accessToken" }
$role = $currentUser.data.accounts[0].household_memberships[0].role
if ($role -ne $expectedRoles[$username]) {
throw "用户 $username 的角色是 $role,预期为 $($expectedRoles[$username])。"
}
Write-Host "OIDC映射通过:$username -> $role"
}
try {
Invoke-WebRequest -Uri 'http://localhost:58080/api/v1/me' -ErrorAction Stop | Out-Null
throw '未登录请求不应成功。'
} catch {
if ([int]$_.Exception.Response.StatusCode -ne 401) { throw }
Write-Host '未登录请求正确返回401。'
}
$env:TEST_DATABASE_URL = 'postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable'
go test ./internal/identity ./internal/idempotency ./internal/audit ./internal/outbox -count=1
Write-Host '身份、租户隔离、幂等、审计和outbox集成测试通过。'
+115
View File
@@ -0,0 +1,115 @@
$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}
}
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("voice-ci-conformance-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
try {
$candidate = '0.64.0-rc.local'
$buildDigest = 'sha256:' + ('a' * 64)
$sourceCommit = '1' * 40
$checks = @('GO_FORMAT','GO_VET_TEST','OPENAPI_GENERATION','WEB_TYPECHECK_BUILD','COMPOSE_VALIDATION','BACKEND_IMAGE_BUILD','WEB_IMAGE_BUILD','GO_VULNERABILITY_LICENSE','FRONTEND_VULNERABILITY_LICENSE','REPOSITORY_SECRET_SCAN','INFRASTRUCTURE_CONFIG_SCAN','BACKEND_IMAGE_SCAN','WEB_IMAGE_SCAN','CYCLONEDX_SBOM')
$capture = [ordered]@{
schema_version = 'remote-ci-supply-chain-capture-v1'; evidence_source = 'LOCAL_TOOLCHAIN'; candidate_version = $candidate
build_digest = $buildDigest; source_commit = $sourceCommit; provider = 'LOCAL_GO_TEST'; repository_ref = 'local:workspace'
workflow_ref = '.github/workflows/ci.yml'; run_id = 'local-smoke'; run_url = 'https://example.invalid/local-smoke'
trigger = 'WORKFLOW_DISPATCH'; ref = 'local-worktree'; attempt = 1; conclusion = 'SUCCESS'
started_at = '2026-08-25T08:00:00Z'; completed_at = '2026-08-25T08:01:00Z'; collected_at = '2026-08-25T08:02:00Z'
executed_by_ref = 'local-developer'; reviewed_by_ref = 'local-reviewer'; review_ref = 'local-toolchain-only'
checks = @($checks | ForEach-Object { [ordered]@{code=$_; status='PASS'; evidence_ref="local:test/$($_.ToLower())"} })
images = @(
[ordered]@{subject='BACKEND_IMAGE'; reference='local:backend'; digest=('sha256:' + ('b' * 64)); evidence_ref='local:image/backend'},
[ordered]@{subject='WEB_IMAGE'; reference='local:web'; digest=('sha256:' + ('c' * 64)); evidence_ref='local:image/web'}
)
sboms = @(
[ordered]@{subject='SOURCE'; format='CYCLONEDX_JSON'; digest=('sha256:' + ('d' * 64)); evidence_ref='local:sbom/source'},
[ordered]@{subject='BACKEND_IMAGE'; format='CYCLONEDX_JSON'; digest=('sha256:' + ('e' * 64)); evidence_ref='local:sbom/backend'},
[ordered]@{subject='WEB_IMAGE'; format='CYCLONEDX_JSON'; digest=('sha256:' + ('f' * 64)); evidence_ref='local:sbom/web'}
)
}
$capturePath = Join-Path $temporaryRoot 'capture.json'
$reportPath = Join-Path $temporaryRoot 'report.json'
$captureJson = $capture | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($capturePath, $captureJson, (New-Object System.Text.UTF8Encoding($false)))
Push-Location $root
try {
& go run ./cmd/ci-supply-chain-conformance -capture $capturePath -candidate-version $candidate -build-digest $buildDigest -source-commit $sourceCommit -output $reportPath
if ($LASTEXITCODE -ne 0) { throw 'Local CI supply-chain conformance command failed' }
} finally { Pop-Location }
$reportJson = [System.IO.File]::ReadAllText($reportPath, (New-Object System.Text.UTF8Encoding($false)))
$report = $reportJson | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_CI_TOOLCHAIN' -or -not $report.external_validation_required -or @($report.completed_checks).Count -ne 14 -or @($report.image_digests.PSObject.Properties).Count -ne 2 -or @($report.sbom_digests.PSObject.Properties).Count -ne 3) {
throw 'Local toolchain report must not satisfy the remote CI supply-chain gate'
}
Write-Host 'CI supply-chain toolchain smoke passed: checks=14 images=2 sboms=3 external_validation_required=true.'
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
}
+145
View File
@@ -0,0 +1,145 @@
$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'
$deviceId = '10000000-0000-0000-0000-000000000511'
$subjectId = '10000000-0000-0000-0000-000000000411'
$subjectInternalId = '10000000-0000-0000-0000-000000000401'
$consentId = '10000000-0000-0000-0000-000000000812'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversationId = $null
$withdrawalSnapshotId = $null
$originalConsentVersion = $null
$withdrawRequestId = 'consent-stop-smoke-' + [Guid]::NewGuid().ToString('N')
function Scalar([string]$Sql) {
$value = docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql
if ($LASTEXITCODE -ne 0) { throw '数据库查询失败。' }
return ($value | Out-String).Trim()
}
function Token() {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'
password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
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()
}
$headers = @{
'X-Device-Id' = $deviceId; 'X-Device-Timestamp' = $timestamp; 'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest; 'X-Device-Signature' = $signature
}
if ($Key) { $headers['Idempotency-Key'] = $Key }
return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($api + $Path) -ContentType 'application/json' -Body $Body -Headers $headers
}
try {
if ((Scalar "SELECT count(*) FROM conversation c JOIN device d ON d.id=c.device_id WHERE d.public_id='$deviceId'::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED')") -ne '0') {
throw '测试设备存在未结束通话。'
}
$originalConsentVersion = Scalar "SELECT version FROM consent_record WHERE public_id='$consentId'::uuid AND withdrawn_at IS NULL"
if (-not $originalConsentVersion) { throw '故事整理授权不是有效状态。' }
$createBody = @{
device_time = [DateTime]::UtcNow.ToString('o'); monotonic_ms = 51001
capabilities = @{ rtc = $true; audio_tracks = @('near', 'far'); upload_resume = $true }
network = @{ type = 'wifi'; rssi = -48 }; requested_goal = 'open_conversation'
} | ConvertTo-Json -Compress -Depth 6
$created = ((SignedPost '/device/v2/conversations' $createBody ('consent-stop-create-' + [Guid]::NewGuid().ToString('N'))).Content | ConvertFrom-Json).data
$conversationId = [string]$created.conversation_id
$activatePath = "/device/v2/conversations/$conversationId/activate"
$activateBody = @{
joined_at = [DateTime]::UtcNow.ToString('o'); room_id = $created.rtc.room_id
rtc_user_id = $created.rtc.user_id; rtc_join_result = 'success'; config_revision = 1
connection_reference = 'consent-withdrawal-smoke'
} | ConvertTo-Json -Compress
SignedPost $activatePath $activateBody ('consent-stop-activate-' + [Guid]::NewGuid().ToString('N')) | Out-Null
$deadline = [DateTime]::UtcNow.AddSeconds(15)
do {
Start-Sleep -Milliseconds 300
$state = Scalar "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid"
} while ($state -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
if ($state -ne 'ACTIVE') { throw "通话没有进入ACTIVE:$state" }
$accessToken = Token
$withdraw = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/subjects/$subjectId/consents/$consentId/withdraw" `
-Headers @{ Authorization = "Bearer $accessToken"; 'X-Request-Id' = $withdrawRequestId }
if ($withdraw.StatusCode -ne 200 -or -not (($withdraw.Content | ConvertFrom-Json).data.withdrawn_at)) { throw '授权撤回接口未成功。' }
$withdrawalSnapshotId = Scalar "SELECT cs.public_id::text FROM consent_snapshot cs WHERE cs.subject_id='$subjectInternalId'::uuid AND cs.id<>(SELECT consent_snapshot_id FROM conversation WHERE public_id='$conversationId'::uuid) ORDER BY cs.created_at DESC,cs.id DESC LIMIT 1"
$stopEvidence = Scalar "SELECT c.stop_reason||'|'||(SELECT count(*) FROM conversation_state_history h WHERE h.conversation_id=c.id AND h.from_status='ACTIVE' AND h.to_status='STOPPING')||'|'||(SELECT count(*) FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.agent_stop_requested') FROM conversation c WHERE c.public_id='$conversationId'::uuid"
if ($stopEvidence -ne 'consent_withdrawn:story_extraction|1|1') { throw "授权撤回停止意图不完整:$stopEvidence" }
$deadline = [DateTime]::UtcNow.AddSeconds(15)
do {
Start-Sleep -Milliseconds 300
$finalState = Scalar "SELECT c.status||'|'||rt.status||'|'||rt.stop_attempts FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id='$conversationId'::uuid"
} while ($finalState -ne 'COMPLETED|COMPLETED|1' -and [DateTime]::UtcNow -lt $deadline)
if ($finalState -ne 'COMPLETED|COMPLETED|1') { throw "Agent没有完成可靠停止:$finalState" }
$heartbeatBody = @{
sequence_no = 3; device_time = [DateTime]::UtcNow.ToString('o'); monotonic_ms = 52001
conversation_status = 'ACTIVE'; rtc = @{ joined = $true; uplink = $true; downlink = $true }
network = @{ type = 'wifi'; rssi = -50 }; firmware_version = '0.1.0-sim'; reported_config_revision = 1
recording_queue = @{ pending_segments = 0; failed_segments = 0 }; command_results = @()
rtc_credential_expires_at = [string]$created.rtc.expires_at
} | ConvertTo-Json -Compress -Depth 6
$heartbeat = ((SignedPost "/device/v2/conversations/$conversationId/heartbeat" $heartbeatBody '').Content | ConvertFrom-Json).data
if ($heartbeat.actions[0].type -ne 'stop') { throw '设备心跳没有收到停止动作。' }
$detail = (Invoke-RestMethod -Uri "$api/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $accessToken" }).data
if ($detail.status -ne 'COMPLETED' -or $detail.stop_reason -ne 'consent_withdrawn:story_extraction') { throw '家庭通话详情没有展示授权撤回后的权威终态。' }
Write-Host '授权撤回停止烟测通过:SaaS事务意图、Agent可靠停止、设备stop动作和家庭通话终态一致。'
} finally {
if ($originalConsentVersion) {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c `
"UPDATE consent_record SET withdrawn_at=NULL,version=$originalConsentVersion,updated_at=now() WHERE public_id='$consentId'::uuid;" | Out-Null
}
if ($conversationId -match '^[0-9a-fA-F-]{36}$') {
$cleanup = @"
BEGIN;
CREATE TEMP TABLE consent_stop_cleanup AS
SELECT id,public_id,device_id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversationId'::uuid;
DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM consent_stop_cleanup)
OR resource_public_id IN(SELECT public_id FROM device WHERE id IN(SELECT device_id FROM consent_stop_cleanup));
DELETE FROM conversation_summary WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM provider_event WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM audit_event WHERE request_id='$withdrawRequestId' OR resource_id IN(SELECT id FROM consent_stop_cleanup) OR resource_id IN(SELECT public_id FROM consent_stop_cleanup);
DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM conversation WHERE id IN(SELECT id FROM consent_stop_cleanup);
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM consent_stop_cleanup);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM consent_stop_cleanup);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $cleanup | Out-Null
docker compose exec -T redis redis-cli DEL "voice-platform:conversation:lease:$conversationId" "voice-platform:device:presence:$deviceId" | Out-Null
}
if ($withdrawalSnapshotId -match '^[0-9a-fA-F-]{36}$') {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c `
"DELETE FROM consent_snapshot WHERE public_id='$withdrawalSnapshotId'::uuid AND NOT EXISTS(SELECT 1 FROM conversation WHERE consent_snapshot_id=consent_snapshot.id);" | Out-Null
}
Write-Host '已恢复合成授权并清理通话、快照、任务、审计和幂等记录。'
}
@@ -0,0 +1,111 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$deviceId = '10000000-0000-0000-0000-000000000511'
$subjectInternalId = '10000000-0000-0000-0000-000000000401'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversationId = $null
$originalProfileBase64 = $null
$idempotencyKey = 'context-preparation-failure-' + [Guid]::NewGuid().ToString('N')
function Scalar([string]$Sql) {
$value = docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql
if ($LASTEXITCODE -ne 0) { throw '数据库查询失败。' }
return ($value | Out-String).Trim()
}
function Token() {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'
password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
function SignedCreate([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/device/v2/conversations`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()
}
return Invoke-WebRequest -UseBasicParsing -SkipHttpErrorCheck -Method Post -Uri "$api/device/v2/conversations" `
-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
}
}
try {
if ((Scalar "SELECT count(*) FROM conversation c JOIN device d ON d.id=c.device_id WHERE d.public_id='$deviceId'::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED')") -ne '0') {
throw '测试设备存在未结束通话,拒绝改动背景资料。'
}
$originalProfileBase64 = Scalar "SELECT encode(convert_to(profile_data::text,'UTF8'),'base64') FROM subject_profile WHERE id='$subjectInternalId'::uuid"
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c `
"UPDATE subject_profile SET profile_data=jsonb_set(profile_data,'{background}','42'::jsonb,true) WHERE id='$subjectInternalId'::uuid;" | Out-Null
$body = @{
device_time = [DateTime]::UtcNow.ToString('o'); monotonic_ms = 42001
capabilities = @{ rtc = $true; audio_tracks = @('near', 'far'); upload_resume = $true }
network = @{ type = 'wifi'; rssi = -50 }; requested_goal = 'open_conversation'
} | ConvertTo-Json -Compress -Depth 6
$first = SignedCreate $body $idempotencyKey
if ($first.StatusCode -ne 422) { throw "首次请求未返回422:$($first.StatusCode) $($first.Content)" }
$firstError = ($first.Content | ConvertFrom-Json).error
$conversationId = [string]$firstError.details.conversation_id
if ($firstError.code -ne 'AGENT_CONTEXT_PREPARATION_FAILED' -or $firstError.details.status -ne 'FAILED' -or $firstError.details.failure_code -ne 'context_preparation_failed') {
throw "设备失败响应不正确:$($first.Content)"
}
$second = SignedCreate $body $idempotencyKey
if ($second.StatusCode -ne 422 -or $second.Headers['Idempotency-Replayed'] -ne 'true') { throw '失败结果没有按同一幂等键重放。' }
$secondError = ($second.Content | ConvertFrom-Json).error
if ($secondError.details.conversation_id -ne $conversationId) { throw '重放返回了不同通话编号。' }
$databaseState = Scalar "SELECT c.status||'|'||c.failure_code||'|'||xs.preparation_status||'|'||xs.preparation_failure_code||'|'||(NOT EXISTS(SELECT 1 FROM rtc_task rt WHERE rt.conversation_id=c.id))::text||'|'||(SELECT count(*) FROM conversation_state_history h WHERE h.conversation_id=c.id) FROM conversation c JOIN context_snapshot xs ON xs.id=c.context_snapshot_id WHERE c.public_id='$conversationId'::uuid"
if ($databaseState -ne 'FAILED|context_preparation_failed|FAILED|context_preparation_failed|true|2') { throw "权威失败状态不正确:$databaseState" }
$accessToken = Token
$detail = (Invoke-RestMethod -Uri "$api/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $accessToken" }).data
if ($detail.status -ne 'FAILED' -or $detail.failure_code -ne 'context_preparation_failed' -or $detail.snapshots.context_preparation_status -ne 'FAILED' -or $null -ne $detail.rtc) {
throw '家庭通话详情与设备失败结果不一致。'
}
$diagnostic = (Invoke-RestMethod -Uri "$api/api/v1/ops/diagnostics/search?field=conversation_id&value=$conversationId" -Headers @{ Authorization = "Bearer $accessToken"; 'X-Tenant-Id' = $tenant }).data.items
if (@($diagnostic).Count -ne 1 -or $diagnostic[0].conversation_failure_code -ne 'context_preparation_failed' -or $diagnostic[0].provider_task_status) {
throw '运营诊断结果与设备失败结果不一致。'
}
Write-Host '背景资料准备失败烟测通过:设备422及幂等重放、FAILED权威记录、无RTC房间、家庭详情和运营诊断结果一致。'
} finally {
if ($originalProfileBase64) {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c `
"UPDATE subject_profile SET profile_data=convert_from(decode('$originalProfileBase64','base64'),'UTF8')::jsonb WHERE id='$subjectInternalId'::uuid;" | Out-Null
}
if ($conversationId -match '^[0-9a-fA-F-]{36}$') {
$cleanup = @"
BEGIN;
CREATE TEMP TABLE context_failure_cleanup 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 context_failure_cleanup);
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM context_failure_cleanup);
DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM context_failure_cleanup);
DELETE FROM conversation WHERE id IN(SELECT id FROM context_failure_cleanup);
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM context_failure_cleanup);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM context_failure_cleanup);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $cleanup | Out-Null
}
Write-Host '已恢复合成长者背景并清理烟测通话、快照、审计和幂等记录。'
}
+506
View File
@@ -0,0 +1,506 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBaseUrl = 'http://localhost:58080'
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversationId = $null
$memoryConversationId = $null
$disabledMemoryConversationId = $null
$deletedMemoryConversationId = $null
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
function Get-LocalAccessToken([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 Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) {
if ([string]$Actual -ne [string]$Expected) {
throw "$Message。实际值:$Actual;预期值:$Expected"
}
}
function Invoke-SignedPost([string]$Path, [string]$Body, [string]$IdempotencyKey) {
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
$digest = [Convert]::ToHexString(
[Security.Cryptography.SHA256]::HashData($bodyBytes)
).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()
}
$headers = @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
if ($IdempotencyKey) {
$headers['Idempotency-Key'] = $IdempotencyKey
}
return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($apiBaseUrl + $Path) `
-ContentType 'application/json' -Body $Body -Headers $headers
}
function Invoke-ScalarQuery([string]$Sql) {
$value = docker compose exec -T postgres psql -U voice -d voice_platform `
-v ON_ERROR_STOP=1 -t -A -c $Sql
if ($LASTEXITCODE -ne 0) {
throw '数据库断言查询失败。'
}
return ($value | Out-String).Trim()
}
function Remove-SmokeConversation([string]$PublicId) {
if ($PublicId -notmatch '^[0-9a-fA-F-]{36}$') {
throw '拒绝清理格式不正确的通话编号。'
}
$cleanupSql = @"
BEGIN;
CREATE TEMP TABLE smoke_cleanup AS
SELECT id, public_id, device_id, context_snapshot_id, consent_snapshot_id
FROM conversation WHERE public_id = '$PublicId'::uuid;
CREATE TEMP TABLE smoke_story_cleanup AS
SELECT id, public_id, current_revision_id FROM story_draft WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
CREATE TEMP TABLE smoke_memory_cleanup AS
SELECT DISTINCT cm.id, cm.public_id, cm.current_version_id
FROM curated_memory cm
JOIN memory_version mv ON mv.memory_id=cm.id
WHERE mv.source_refs @> ('[{"type":"story","story_id":"' ||
(SELECT sd.public_id::text FROM story_draft sd WHERE sd.id IN (SELECT id FROM smoke_story_cleanup) LIMIT 1) || '"}]')::jsonb;
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_memory_cleanup);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_memory_cleanup);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_story_cleanup);
DELETE FROM web_event WHERE resource_public_id IN (SELECT public_id FROM smoke_story_cleanup UNION SELECT public_id FROM smoke_cleanup)
OR resource_public_id IN (SELECT public_id FROM device WHERE id IN (SELECT device_id FROM smoke_cleanup));
DELETE FROM device_presence_state WHERE device_id IN (SELECT device_id FROM smoke_cleanup);
DELETE FROM memory_index_entry WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup);
UPDATE curated_memory SET current_version_id=NULL WHERE id IN (SELECT id FROM smoke_memory_cleanup);
DELETE FROM memory_version WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup);
DELETE FROM curated_memory WHERE id IN (SELECT id FROM smoke_memory_cleanup);
DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup));
UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM rtc_task WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM audit_event WHERE resource_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM idempotency_record WHERE resource_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM conversation WHERE id IN (SELECT id FROM smoke_cleanup);
DELETE FROM context_snapshot WHERE id IN (SELECT context_snapshot_id FROM smoke_cleanup);
DELETE FROM consent_snapshot WHERE id IN (SELECT consent_snapshot_id FROM smoke_cleanup);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform `
-v ON_ERROR_STOP=1 -q -c $cleanupSql | Out-Null
if ($LASTEXITCODE -ne 0) {
throw '冒烟数据清理失败。'
}
docker compose exec -T redis redis-cli DEL `
"voice-platform:conversation:lease:$PublicId" `
"voice-platform:device:presence:$deviceId" | Out-Null
}
try {
foreach ($healthUrl in @(
'http://localhost:58080/health/live',
'http://localhost:58081/health/live',
'http://localhost:58082/health/live',
'http://localhost:59200/health/live'
)) {
$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl
Assert-Equal $health.StatusCode 200 "$healthUrl 存活检查失败"
}
$createBody = @{
device_time = [DateTime]::UtcNow.ToString('o')
monotonic_ms = 12345
capabilities = @{
rtc = $true
audio_tracks = @('near', 'far')
upload_resume = $true
}
network = @{ type = 'wifi'; rssi = -48 }
requested_goal = 'open_conversation'
} | ConvertTo-Json -Compress -Depth 5
$createKey = 'smoke-create-' + [Guid]::NewGuid().ToString('N')
$createResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $createKey
Assert-Equal $createResponse.StatusCode 201 '通话创建失败'
$created = ($createResponse.Content | ConvertFrom-Json).data
$conversationId = [string]$created.conversation_id
if ($conversationId -notmatch '^[0-9a-fA-F-]{36}$') {
throw "服务端返回了格式不正确的通话编号:$conversationId"
}
$activationPath = "/device/v2/conversations/$conversationId/activate"
$activationBody = @{
joined_at = [DateTime]::UtcNow.ToString('o')
room_id = $created.rtc.room_id
rtc_user_id = $created.rtc.user_id
rtc_join_result = 'success'
config_revision = 1
connection_reference = 'local-e2e-smoke'
} | ConvertTo-Json -Compress
$activationKey = 'smoke-activate-' + [Guid]::NewGuid().ToString('N')
$activationResponse = Invoke-SignedPost $activationPath $activationBody $activationKey
Assert-Equal $activationResponse.StatusCode 202 '首次激活失败'
Assert-Equal (($activationResponse.Content | ConvertFrom-Json).data.status) 'STARTING_AGENT' '首次激活状态不正确'
$replayedResponse = Invoke-SignedPost $activationPath $activationBody $activationKey
Assert-Equal $replayedResponse.StatusCode 202 '激活幂等重放失败'
Assert-Equal $replayedResponse.Headers['Idempotency-Replayed'][0] 'true' '激活响应缺少幂等重放标记'
$deadline = [DateTime]::UtcNow.AddSeconds(15)
do {
Start-Sleep -Milliseconds 500
$currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;"
} while ($currentState -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $currentState 'ACTIVE' '供应商启动回调未使通话进入ACTIVE'
$heartbeatPath = "/device/v2/conversations/$conversationId/heartbeat"
$heartbeatBody = @{
sequence_no = 2
device_time = [DateTime]::UtcNow.ToString('o')
monotonic_ms = 20000
conversation_status = 'ACTIVE'
rtc = @{ joined = $true; uplink = $true; downlink = $true }
network = @{ type = 'wifi'; rssi = -51 }
firmware_version = 'local-smoke-1.0.0'
reported_config_revision = 1
rtc_credential_expires_at = [string]$created.rtc.expires_at
recording_queue = @{ pending_segments = 2; failed_segments = 0 }
command_results = @()
} | ConvertTo-Json -Compress -Depth 5
$heartbeatResponse = Invoke-SignedPost $heartbeatPath $heartbeatBody ''
Assert-Equal $heartbeatResponse.StatusCode 200 '通话心跳失败'
$heartbeatData = ($heartbeatResponse.Content | ConvertFrom-Json).data
Assert-Equal $heartbeatData.actions[0].type 'continue' 'ACTIVE心跳没有返回continue动作'
$originalLease = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;"
$staleHeartbeatBody = $heartbeatBody | ConvertFrom-Json
$staleHeartbeatBody.sequence_no = 1
$staleHeartbeatBody.device_time = [DateTime]::UtcNow.ToString('o')
$staleHeartbeatResponse = Invoke-SignedPost $heartbeatPath ($staleHeartbeatBody | ConvertTo-Json -Compress -Depth 5) ''
Assert-Equal $staleHeartbeatResponse.StatusCode 200 '乱序心跳没有安全返回'
$leaseAfterStale = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;"
Assert-Equal $leaseAfterStale $originalLease '乱序心跳错误延长了数据库租约'
$cachedSequence = docker compose exec -T redis redis-cli --raw HGET `
"voice-platform:conversation:lease:$conversationId" sequence_no
Assert-Equal (($cachedSequence | Out-String).Trim()) 2 'Redis租约心跳序号不正确'
$repeatResponse = Invoke-SignedPost $activationPath $activationBody `
('smoke-activate-repeat-' + [Guid]::NewGuid().ToString('N'))
Assert-Equal $repeatResponse.StatusCode 202 'ACTIVE状态下重复确认失败'
Assert-Equal (($repeatResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '重复确认导致状态回退'
$stateEvidence = Invoke-ScalarQuery @"
SELECT c.status || '|' || rt.status || '|' || rt.start_attempts
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id
WHERE c.public_id='$conversationId'::uuid;
"@
Assert-Equal $stateEvidence 'ACTIVE|ACTIVE|1' '通话、RTC任务或供应商调用次数不正确'
$historyCount = Invoke-ScalarQuery @"
SELECT count(*) FROM conversation_state_history h
JOIN conversation c ON c.id=h.conversation_id
WHERE c.public_id='$conversationId'::uuid;
"@
Assert-Equal $historyCount 4 '通话状态历史不完整或出现重复迁移'
$providerEventCount = Invoke-ScalarQuery @"
SELECT count(*) FROM provider_event pe
JOIN conversation c ON c.id=pe.conversation_id
WHERE c.public_id='$conversationId'::uuid
AND pe.event_type='voice_chat.started'
AND pe.processing_status='processed';
"@
Assert-Equal $providerEventCount 1 '重复供应商回调没有正确去重'
$startOutboxCount = Invoke-ScalarQuery @"
SELECT count(*) FROM outbox_event o
JOIN conversation c ON c.id=o.aggregate_id
WHERE c.public_id='$conversationId'::uuid
AND o.event_type='conversation.agent_start_requested'
AND o.status='completed' AND o.attempt_count=1;
"@
Assert-Equal $startOutboxCount 1 '语音供应商启动待办执行次数不正确'
$stopPath = "/device/v2/conversations/$conversationId/stop"
$stopBody = @{
reason = 'handset_on_hook'
device_time = [DateTime]::UtcNow.ToString('o')
last_heartbeat_sequence = 2
rtc_left = $true
recording = @{ near_segments = 2; far_segments = 2 }
} | ConvertTo-Json -Compress -Depth 4
$stopKey = 'smoke-stop-' + [Guid]::NewGuid().ToString('N')
$stopResponse = Invoke-SignedPost $stopPath $stopBody $stopKey
Assert-Equal $stopResponse.StatusCode 202 '停止通话请求失败'
Assert-Equal (($stopResponse.Content | ConvertFrom-Json).data.status) 'STOPPING' '停止请求没有进入STOPPING'
$stopReplay = Invoke-SignedPost $stopPath $stopBody $stopKey
Assert-Equal $stopReplay.StatusCode 202 '停止请求幂等重放失败'
Assert-Equal $stopReplay.Headers['Idempotency-Replayed'][0] 'true' '停止响应缺少幂等重放标记'
$deadline = [DateTime]::UtcNow.AddSeconds(15)
do {
Start-Sleep -Milliseconds 500
$currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;"
} while ($currentState -ne 'COMPLETED' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $currentState 'COMPLETED' '供应商停止回调未使通话进入COMPLETED'
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$contentEvidence = Invoke-ScalarQuery @"
SELECT
(SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' ||
(SELECT count(*) FROM conversation_summary cs JOIN conversation c ON c.id=cs.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' ||
(SELECT count(*) FROM story_draft sd JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid)::text;
"@
} while ($contentEvidence -ne '2|2|1' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $contentEvidence '2|2|1' '最终转写、摘要或故事候选没有按期生成'
$autoMemoryCount = Invoke-ScalarQuery "SELECT count(*) FROM curated_memory cm JOIN story_draft sd ON sd.subject_id=cm.subject_id JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid AND cm.created_at>=sd.created_at;"
Assert-Equal $autoMemoryCount 0 '未经人工确认不应自动生成长期记忆'
$transcriptPath = "/device/v2/conversations/$conversationId/turns:batch"
$transcriptBody = Invoke-ScalarQuery @"
SELECT jsonb_build_object('turns',jsonb_agg(jsonb_build_object(
'turn_key',ct.turn_key,'sequence_no',ct.sequence_no,'speaker',ct.speaker,
'start_at',ct.start_at,'end_at',ct.end_at,'final_text',ct.final_text,'interrupted',ct.interrupted
) ORDER BY ct.sequence_no))::text
FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id
WHERE c.public_id='$conversationId'::uuid;
"@
$transcriptKey = 'smoke-transcript-' + [Guid]::NewGuid().ToString('N')
$transcriptResponse = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey
Assert-Equal $transcriptResponse.StatusCode 200 '设备终稿补传去重请求失败'
$transcriptData = ($transcriptResponse.Content | ConvertFrom-Json).data
Assert-Equal $transcriptData.accepted_count 0 '已有供应商终稿被错误重复保存'
Assert-Equal $transcriptData.duplicate_count 2 '已有供应商终稿没有全部判定为重复'
$transcriptReplay = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey
Assert-Equal $transcriptReplay.Headers['Idempotency-Replayed'][0] 'true' '终稿补传响应缺少幂等重放标记'
$conflictingTranscript = $transcriptBody | ConvertFrom-Json
$conflictingTranscript.turns[0].final_text = ([string]$conflictingTranscript.turns[0].final_text) + '(冲突)'
try {
Invoke-SignedPost $transcriptPath ($conflictingTranscript | ConvertTo-Json -Compress -Depth 5) `
('smoke-transcript-conflict-' + [Guid]::NewGuid().ToString('N')) | Out-Null
throw '不同内容占用同一终稿业务键不应成功。'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 409) { throw }
}
Assert-Equal (Invoke-ScalarQuery "SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid;") 2 '终稿冲突请求没有整批回滚'
$repeatStop = Invoke-SignedPost $stopPath $stopBody `
('smoke-stop-repeat-' + [Guid]::NewGuid().ToString('N'))
Assert-Equal $repeatStop.StatusCode 202 '终态重复停止没有安全返回'
Assert-Equal (($repeatStop.Content | ConvertFrom-Json).data.status) 'COMPLETED' '终态重复停止返回状态不正确'
$finalEvidence = Invoke-ScalarQuery @"
SELECT c.status || '|' || rt.status || '|' || rt.start_attempts || '|' || rt.stop_attempts || '|' ||
(c.lease_expires_at IS NULL)::text
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id
WHERE c.public_id='$conversationId'::uuid;
"@
Assert-Equal $finalEvidence 'COMPLETED|COMPLETED|1|1|true' '停止后的通话、任务、调用次数或租约不正确'
$finalHistoryCount = Invoke-ScalarQuery @"
SELECT count(*) FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
WHERE c.public_id='$conversationId'::uuid;
"@
Assert-Equal $finalHistoryCount 6 '完整创建到停止状态链不正确'
$stopProviderEventCount = Invoke-ScalarQuery @"
SELECT count(*) FROM provider_event pe JOIN conversation c ON c.id=pe.conversation_id
WHERE c.public_id='$conversationId'::uuid AND pe.event_type='voice_chat.stopped'
AND pe.processing_status='processed';
"@
Assert-Equal $stopProviderEventCount 1 '重复停止回调没有正确去重'
$stopOutboxCount = Invoke-ScalarQuery @"
SELECT count(*) FROM outbox_event o JOIN conversation c ON c.id=o.aggregate_id
WHERE c.public_id='$conversationId'::uuid
AND o.event_type='conversation.agent_stop_requested'
AND o.status='completed' AND o.attempt_count=1;
"@
Assert-Equal $stopOutboxCount 1 '供应商停止待办执行次数不正确'
$cachedLeaseExists = docker compose exec -T redis redis-cli --raw EXISTS `
"voice-platform:conversation:lease:$conversationId"
Assert-Equal (($cachedLeaseExists | Out-String).Trim()) 0 '停止后Redis热租约未失效'
$ownerToken = Get-LocalAccessToken 'owner'
$queryHeaders = @{ Authorization = "Bearer $ownerToken" }
$queryResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/conversations?limit=1&status=COMPLETED" -Headers $queryHeaders
$queryData = $queryResponse.Content | ConvertFrom-Json
Assert-Equal $queryData.data.items[0].id $conversationId '网页通话列表没有返回刚完成的通话'
$detailResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers $queryHeaders
$detailData = $detailResponse.Content | ConvertFrom-Json
Assert-Equal $detailData.data.history.Count 6 '网页通话详情状态历史不完整'
Assert-Equal $detailData.data.snapshots.consent_snapshot_version 2 '网页通话详情授权快照版本不正确'
if ($detailResponse.Content -match 'token|provider_task_ref|room_id') { throw '网页通话详情泄露了禁止返回的运行凭据或供应商引用。' }
$turnResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1" -Headers $queryHeaders
$turnData = ($turnResponse.Content | ConvertFrom-Json).data
Assert-Equal $turnData.items.Count 1 '话轮分页第一页数量不正确'
if (-not $turnData.next_cursor) { throw '话轮分页缺少下一页游标。' }
$turnPage2 = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1&cursor=$([uri]::EscapeDataString($turnData.next_cursor))" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $turnPage2.data.items.Count 1 '话轮分页第二页数量不正确'
$summaryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/summaries" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $summaryData.data.items.Count 2 '通话摘要和开放话题数量不正确'
$storiesData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/stories?status=CANDIDATE" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $storiesData.data.items.Count 1 '故事候选列表数量不正确'
$storyId = [string]$storiesData.data.items[0].id
$storyResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/stories/$storyId" -Headers $queryHeaders
$storyData = ($storyResponse.Content | ConvertFrom-Json).data
Assert-Equal $storyData.sources.Count 1 '故事候选没有保留长者原话来源'
Assert-Equal $storyData.facts.Count 1 '故事候选没有生成可审核事实'
$confirmHeaders = @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $storyResponse.Headers['ETag'][0] }
$confirmBody = @{ reason = '端到端冒烟人工确认' } | ConvertTo-Json -Compress
$confirmData = (Invoke-WebRequest -Method Post -Uri "$apiBaseUrl/api/v1/stories/$storyId/confirm" -Headers $confirmHeaders -ContentType 'application/json' -Body $confirmBody).Content | ConvertFrom-Json
Assert-Equal $confirmData.data.story.status 'CONFIRMED' '故事人工确认状态不正确'
$memoryId = [string]$confirmData.data.memory.id
if (-not $memoryId) { throw '人工确认后没有生成长期记忆。' }
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$memoryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json
} while ($memoryData.data.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $memoryData.data.index_status 'ACTIVE' '人工确认后的记忆没有完成本地开发索引'
$memoryList = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $memoryList.data.items.Count 1 '长者记忆列表没有返回已确认记忆'
$memorySearchTerm = if ([string]$memoryData.data.content -and ([string]$memoryData.data.content).Length -gt 4) {
([string]$memoryData.data.content).Substring(0, 4)
} else {
[string]$memoryData.data.content
}
$memorySearchHit = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$([uri]::EscapeDataString($memorySearchTerm))" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $memorySearchHit.data.items.Count 1 '记忆文本搜索没有命中已确认记忆'
$memorySearchMissTerm = 'smoke-no-match-' + [Guid]::NewGuid().ToString('N')
$memorySearchMiss = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$memorySearchMissTerm" -Headers $queryHeaders).Content | ConvertFrom-Json
Assert-Equal $memorySearchMiss.data.items.Count 0 '记忆文本搜索对不相关查询返回了误命中'
$memoryCreateKey = 'smoke-memory-create-' + [Guid]::NewGuid().ToString('N')
$memoryConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $memoryCreateKey
Assert-Equal $memoryConversationResponse.StatusCode 201 '确认记忆后的下一次通话创建失败'
$memoryConversationId = [string](($memoryConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
$memoryContextEvidence = Invoke-ScalarQuery @"
SELECT jsonb_array_length(cs.memory_versions)::text || '|' || cs.prompt_bundle_version
FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id
WHERE c.public_id='$memoryConversationId'::uuid;
"@
Assert-Equal $memoryContextEvidence '1|minimal-v2' '下一次通话没有按服务端固定边界冻结已确认记忆版本'
Remove-SmokeConversation $memoryConversationId
Write-Host "已清理记忆召回验证通话:$memoryConversationId"
$memoryConversationId = $null
$memoryEtag = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Headers['ETag'][0]
$editMemoryBody = @{ content = '我小时候常和家人在河边散步,这条内容经过家庭成员再次核对。'; reason = '端到端冒烟修订记忆' } | ConvertTo-Json -Compress
$editMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $memoryEtag } -ContentType 'application/json' -Body $editMemoryBody
$editedMemory = ($editMemoryResponse.Content | ConvertFrom-Json).data
Assert-Equal $editedMemory.version_no 2 '修改记忆没有生成新版本'
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$editedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
} while ($editedMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $editedMemory.index_status 'ACTIVE' '记忆新版本没有完成重新索引'
$disableMemoryBody = @{ status = 'DISABLED'; reason = '端到端冒烟验证停用' } | ConvertTo-Json -Compress
$disabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v2"' } -ContentType 'application/json' -Body $disableMemoryBody
$disabledMemory = ($disabledMemoryResponse.Content | ConvertFrom-Json).data
Assert-Equal $disabledMemory.status 'DISABLED' '记忆停用状态不正确'
Assert-Equal $disabledMemory.index_status 'STALE' '停用记忆的索引没有立即失效'
$disabledCreateKey = 'smoke-disabled-memory-create-' + [Guid]::NewGuid().ToString('N')
$disabledConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $disabledCreateKey
Assert-Equal $disabledConversationResponse.StatusCode 201 '停用记忆后的通话创建失败'
$disabledMemoryConversationId = [string](($disabledConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
$disabledContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$disabledMemoryConversationId'::uuid;"
Assert-Equal $disabledContextCount 0 '停用记忆仍被召回到新通话'
$enableMemoryBody = @{ status = 'ACTIVE'; reason = '端到端冒烟恢复有效记忆' } | ConvertTo-Json -Compress
$enabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v3"' } -ContentType 'application/json' -Body $enableMemoryBody
Assert-Equal (($enabledMemoryResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '记忆重新启用状态不正确'
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$enabledMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
} while ($enabledMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $enabledMemory.index_status 'ACTIVE' '重新启用的记忆没有恢复索引'
Remove-SmokeConversation $disabledMemoryConversationId
Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId"
$disabledMemoryConversationId = $null
$deleteMemoryBody = @{ reason = '端到端冒烟永久删除记忆' } | ConvertTo-Json -Compress
$deleteMemoryResponse = Invoke-WebRequest -Method Delete -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v4"' } -ContentType 'application/json' -Body $deleteMemoryBody
Assert-Equal $deleteMemoryResponse.StatusCode 202 '记忆删除请求未被受理'
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$deletedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
} while ($deletedMemory.status -ne 'DELETED' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $deletedMemory.status 'DELETED' '记忆后台删除没有完成'
Assert-Equal $deletedMemory.index_status 'DELETED' '记忆索引没有完成删除'
Assert-Equal $deletedMemory.content '[已按用户请求删除]' '记忆数据库正文没有被清除'
$deletedCreateKey = 'smoke-deleted-memory-create-' + [Guid]::NewGuid().ToString('N')
$deletedConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $deletedCreateKey
Assert-Equal $deletedConversationResponse.StatusCode 201 '删除记忆后的通话创建失败'
$deletedMemoryConversationId = [string](($deletedConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
$deletedContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$deletedMemoryConversationId'::uuid;"
Assert-Equal $deletedContextCount 0 '已删除记忆仍被召回到新通话'
$outsiderToken = Get-LocalAccessToken 'outsider'
try {
Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $outsiderToken" } | Out-Null
throw '跨租户查看通话详情不应成功。'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 403) { throw }
}
Write-Host "端到端通话冒烟通过:$conversationId"
Write-Host '状态链:PREPARING -> ROOM_ISSUED -> STARTING_AGENT -> ACTIVE -> STOPPING -> COMPLETED'
Write-Host '供应商启动调用1次;重复回调仅处理1次;激活幂等重放通过。'
Write-Host '心跳刷新PostgreSQL/Redis租约;乱序心跳未延长租约。'
Write-Host '供应商停止调用1次;重复停止回调仅处理1次;最终租约已释放。'
Write-Host '网页通话列表、脱敏详情、六段状态历史和跨租户403验证通过。'
Write-Host '最终话轮去重、摘要、故事来源、人工确认、记忆生成与本地开发索引验证通过。'
Write-Host '设备终稿补传对供应商终稿完成语义去重、幂等重放和冲突整批回滚。'
Write-Host '新确认记忆已在下一次通话创建时按租户、长者、授权和状态边界冻结到背景快照。'
Write-Host '记忆修订生成新版本并重新索引;停用后新通话召回为0;重新启用进入索引流程。'
Write-Host '永久删除任务完成索引清理和数据库正文清除;删除后的新通话召回为0。'
} finally {
if ($deletedMemoryConversationId) {
Remove-SmokeConversation $deletedMemoryConversationId
Write-Host "已清理删除记忆验证通话:$deletedMemoryConversationId"
}
if ($disabledMemoryConversationId) {
Remove-SmokeConversation $disabledMemoryConversationId
Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId"
}
if ($memoryConversationId) {
Remove-SmokeConversation $memoryConversationId
Write-Host "已清理记忆召回验证通话:$memoryConversationId"
}
if ($conversationId) {
Remove-SmokeConversation $conversationId
Write-Host "已清理本次合成通话数据:$conversationId"
}
}
+8
View File
@@ -0,0 +1,8 @@
$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'
$deviceId='10000000-0000-0000-0000-000000000511';$secret=[Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum');$commandId=$null
function Token([string]$user){return(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{grant_type='password';client_id='voice-platform-web';username=$user;password='LocalOnly123!';scope='openid'}).access_token}
function Signed([string]$method,[string]$path,[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');$nonce=[Guid]::NewGuid().ToString('N');$canonical="$method`n$path`n$timestamp`n$nonce`n$digest";$h=[Security.Cryptography.HMACSHA256]::new($secret);try{$signature=[Convert]::ToBase64String($h.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+','-').Replace('/','_')}finally{$h.Dispose()};$headers=@{'X-Device-Id'=$deviceId;'X-Device-Timestamp'=$timestamp;'X-Device-Nonce'=$nonce;'X-Device-Content-SHA256'=$digest;'X-Device-Signature'=$signature};$args=@{UseBasicParsing=$true;Method=$method;Uri=($api+$path);Headers=$headers};if($body){$args.ContentType='application/json';$args.Body=$body};return Invoke-WebRequest @args}
function Expected([scriptblock]$action,[int]$status){try{$r=&$action;if($r.StatusCode-ne$status){throw "预期$status,实际$($r.StatusCode)"}}catch{if($_.Exception.Response.StatusCode.value__-ne$status){throw}}}
try{$owner=Token 'owner';$outsider=Token 'outsider';$key='device-command-'+[Guid]::NewGuid().ToString('N');$body=@{type='diagnostics';payload=@{checks=@('audio','network')};expires_in_sec=300}|ConvertTo-Json -Compress -Depth 5;$headers=@{Authorization="Bearer $owner";'Idempotency-Key'=$key};$created=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/devices/$deviceId/commands" -Headers $headers -ContentType 'application/json' -Body $body;$commandId=[string](($created.Content|ConvertFrom-Json).data.id);if($created.StatusCode-ne 202){throw '命令未排队'};$replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/devices/$deviceId/commands" -Headers $headers -ContentType 'application/json' -Body $body;if($replay.StatusCode-ne 200-or[string]$replay.Headers.'Idempotency-Replayed'-ne'true'){throw '命令幂等重放失败'};Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/devices/$deviceId/commands" -Headers @{Authorization="Bearer $outsider"}} 403;$pulled=(Signed 'GET' '/device/v2/commands?limit=10').Content|ConvertFrom-Json;$item=$pulled.data.items|Where-Object id -eq $commandId;if(!$item-or$item.status-ne'DELIVERED'){throw '设备未拉取到待执行命令'};$ackBody=@{status='ACKNOWLEDGED';result_summary=@{accepted=$true}}|ConvertTo-Json -Compress;Signed 'POST' "/device/v2/commands/$commandId/ack" $ackBody|Out-Null;$doneBody=@{status='SUCCEEDED';result_summary=@{audio='ok';network='ok'}}|ConvertTo-Json -Compress;Signed 'POST' "/device/v2/commands/$commandId/ack" $doneBody|Out-Null;$list=(Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/devices/$deviceId/commands" -Headers @{Authorization="Bearer $owner"}).Content|ConvertFrom-Json;$final=$list.data.items|Where-Object id -eq $commandId;if($final.status-ne'SUCCEEDED'-or$final.delivery_attempts-lt 1){throw '命令终态或投递次数不正确'};Write-Host "设备命令端到端冒烟通过:$commandId(QUEUED → DELIVERED → ACKNOWLEDGED → SUCCEEDED)"}finally{if($commandId-match'^[0-9a-fA-F-]{36}$'){docker compose exec -T postgres psql -U voice -d voice_platform -q -c "DELETE FROM audit_event WHERE resource_id='$commandId'::uuid; DELETE FROM web_event WHERE resource_public_id='$commandId'::uuid; DELETE FROM device_command WHERE public_id='$commandId'::uuid;"|Out-Null;Write-Host "已清理设备命令冒烟数据:$commandId"}}
+104
View File
@@ -0,0 +1,104 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$deviceId = '10000000-0000-0000-0000-000000000511'
$originalTwin = $null
$patchRequestId = $null
$claimBindingId = $null
$claimRequestId = $null
function Get-LocalAccessToken([string]$Username) {
$response = 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'
}
return $response.access_token
}
function Invoke-JSONRequest([string]$Method, [string]$Uri, [string]$Token, [hashtable]$Headers = @{}, [object]$Body = $null) {
$requestHeaders = @{ Authorization = "Bearer $Token" }
foreach ($key in $Headers.Keys) { $requestHeaders[$key] = $Headers[$key] }
$arguments = @{ Method = $Method; Uri = $Uri; Headers = $requestHeaders }
if ($null -ne $Body) {
$arguments.ContentType = 'application/json; charset=utf-8'
$arguments.Body = $Body | ConvertTo-Json -Depth 12 -Compress
}
return Invoke-WebRequest @arguments
}
function Assert-Status([scriptblock]$Operation, [int]$ExpectedStatus) {
try { & $Operation | Out-Null; throw "请求不应成功,预期HTTP $ExpectedStatus。" }
catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne $ExpectedStatus) { throw } }
}
try {
$ownerToken = Get-LocalAccessToken 'owner'
$editorToken = Get-LocalAccessToken 'editor'
$outsiderToken = Get-LocalAccessToken 'outsider'
$devices = (Invoke-JSONRequest Get "$apiBase/api/v1/devices" $ownerToken).Content | ConvertFrom-Json
if ($deviceId -notin @($devices.data.items.id)) { throw '所有者设备列表未包含合成设备。' }
Assert-Status { Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId" $outsiderToken } 403
$twinResponse = Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId/twin" $editorToken
$originalTwin = $twinResponse.Content | ConvertFrom-Json
$originalETag = [string]$twinResponse.Headers.ETag
$presenceResponse = Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId/presence" $ownerToken
$presence = $presenceResponse.Content | ConvertFrom-Json
if ($presence.data.device_id -ne $deviceId -or [string]$presenceResponse.Headers.'Cache-Control' -ne 'no-store') {
throw '设备在线状态响应不符合契约。'
}
Assert-Status { Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{
desired_config = @{ upload = @{ api_token = 'forbidden' } }
} } 400
$patchedResponse = Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{
desired_config = @{
volume = @{ speaker = 60 }
interaction = @{ pace = 'normal' }
upload = @{ policy = 'wifi_preferred' }
policy_refs = @{ retention = 'local-v1' }
}
}
$patched = $patchedResponse.Content | ConvertFrom-Json
$patchRequestId = $patched.request_id
if ($patched.data.desired_revision -ne ($originalTwin.data.desired_revision + 1) -or $patched.data.sync_state -ne 'pending') {
throw '设备影子期望版本或同步状态不正确。'
}
Assert-Status { Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{ desired_config = @{} } } 412
$claimBody = @{
claim_code = 'LOCAL-CLAIM-0001'
household_id = '10000000-0000-0000-0000-000000000211'
subject_id = '10000000-0000-0000-0000-000000000411'
}
Assert-Status { Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $outsiderToken @{} $claimBody } 403
$claimResponse = Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $ownerToken @{} $claimBody
$claim = $claimResponse.Content | ConvertFrom-Json
$claimBindingId = $claim.data.binding_id
$claimRequestId = $claim.request_id
$claimReplay = Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $ownerToken @{} $claimBody
$claimReplayBody = $claimReplay.Content | ConvertFrom-Json
if ([string]$claimReplay.Headers.'Idempotency-Replayed' -ne 'true' -or $claimReplayBody.data.binding_id -ne $claimBindingId) {
throw '一次性设备绑定码安全重试结果不正确。'
}
Write-Host "设备管理烟测通过:设备 $deviceId,在线状态来源 $($presence.data.source),绑定 $claimBindingId。"
} finally {
if (-not [string]::IsNullOrWhiteSpace($claimBindingId)) {
$deleteClaimAudit = if ([string]::IsNullOrWhiteSpace($claimRequestId)) { '' } else { "DELETE FROM audit_event WHERE request_id='$claimRequestId';" }
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c `
"BEGIN; UPDATE device_claim SET used_at=NULL,used_binding_id=NULL WHERE public_id='10000000-0000-0000-0000-000000000911'::uuid; DELETE FROM device_binding WHERE public_id='$claimBindingId'::uuid; $deleteClaimAudit COMMIT;" | Out-Null
}
if ($null -ne $originalTwin) {
$desiredJSON = ($originalTwin.data.desired_config | ConvertTo-Json -Depth 12 -Compress).Replace("'", "''")
$updatedAt = ([DateTimeOffset]$originalTwin.data.updated_at).ToString('o')
$deleteAudit = if ([string]::IsNullOrWhiteSpace($patchRequestId)) { '' } else { "DELETE FROM audit_event WHERE request_id='$patchRequestId';" }
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c `
"BEGIN; UPDATE device_twin SET desired_revision=$($originalTwin.data.desired_revision),desired_config='$desiredJSON'::jsonb,updated_at='$updatedAt'::timestamptz WHERE device_id=(SELECT id FROM device WHERE public_id='$deviceId'::uuid); $deleteAudit COMMIT;" | Out-Null
}
}
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$tenantId = '10000000-0000-0000-0000-000000000001'
$deviceId = '10000000-0000-0000-0000-000000000501'
try {
$setupSql = @"
DELETE FROM web_event WHERE resource_type='device' AND resource_public_id=(SELECT public_id FROM device WHERE id='$deviceId'::uuid);
DELETE FROM device_presence_state WHERE tenant_id='$tenantId'::uuid AND device_id='$deviceId'::uuid;
SELECT record_device_presence('$tenantId'::uuid,'$deviceId'::uuid,now());
SELECT record_device_presence('$tenantId'::uuid,'$deviceId'::uuid,now()+interval '1 second');
UPDATE device_presence_state SET last_seen_at=now()-interval '10 minutes' WHERE tenant_id='$tenantId'::uuid AND device_id='$deviceId'::uuid;
SELECT expire_device_presence(now()-interval '2 minutes',1000);
"@
$setupSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
$rows = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT event_type,status FROM web_event WHERE resource_type='device' AND resource_public_id=(SELECT public_id FROM device WHERE id='$deviceId'::uuid) ORDER BY id" | Out-String
$events = @($rows.Trim() -split "`r?`n")
if ($events.Count -ne 2) { throw "Expected one online and one offline transition, received $($events.Count): $rows" }
if ($events[0] -ne 'device.presence.changed|online' -or $events[1] -ne 'device.presence.changed|offline') {
throw "Presence transition sequence was unexpected: $rows"
}
$state = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT online FROM device_presence_state WHERE tenant_id='$tenantId'::uuid AND device_id='$deviceId'::uuid" | Out-String).Trim()
if ($state -ne 'f') { throw "Device presence state was not expired: $state" }
Write-Host 'Device presence online deduplication and offline expiry event smoke passed'
}
finally {
$cleanupSql = @"
DELETE FROM web_event WHERE resource_type='device' AND resource_public_id=(SELECT public_id FROM device WHERE id='$deviceId'::uuid);
DELETE FROM device_presence_state WHERE tenant_id='$tenantId'::uuid AND device_id='$deviceId'::uuid;
"@
$cleanupSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
}
+124
View File
@@ -0,0 +1,124 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBaseUrl = 'http://localhost:58080'
$deviceInternalId = '95000000-0000-0000-0000-000000000501'
$deviceId = '95000000-0000-0000-0000-000000000511'
$commandInternalId = '95000000-0000-0000-0000-000000000601'
$commandId = '95000000-0000-0000-0000-000000000611'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
function Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) {
if ([string]$Actual -ne [string]$Expected) {
throw "$Message。实际值:$Actual;预期值:$Expected"
}
}
function Invoke-Database([string]$Sql) {
$result = docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql
if ($LASTEXITCODE -ne 0) { throw '数据库操作失败。' }
return ($result | Out-String).Trim()
}
function Invoke-SignedTelemetry([string]$Body, [string]$IdempotencyKey) {
$path = '/device/v2/telemetry:batch'
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bodyBytes)).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()
}
return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($apiBaseUrl + $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' = $IdempotencyKey
}
}
function Remove-TelemetryFixture {
$cleanup = @"
BEGIN;
DELETE FROM device_telemetry_event WHERE device_id='$deviceInternalId'::uuid;
DELETE FROM idempotency_record WHERE actor_key='device:$deviceId';
DELETE FROM device_presence_state WHERE device_id='$deviceInternalId'::uuid;
DELETE FROM device_command WHERE device_id='$deviceInternalId'::uuid;
DELETE FROM device_twin WHERE device_id='$deviceInternalId'::uuid;
DELETE FROM device WHERE id='$deviceInternalId'::uuid;
COMMIT;
"@
Invoke-Database $cleanup | Out-Null
}
try {
Remove-TelemetryFixture
$fixture = @"
BEGIN;
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref)
VALUES('$deviceInternalId'::uuid,'$deviceId'::uuid,'10000000-0000-0000-0000-000000000001',repeat('6',64),'telemetry-smoke','registered','local-dev://simulated-terminal-1');
INSERT INTO device_twin(device_id,tenant_id,desired_revision,reported_revision)
VALUES('$deviceInternalId'::uuid,'10000000-0000-0000-0000-000000000001',2,1);
INSERT INTO device_command(id,public_id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at,delivery_attempts)
VALUES('$commandInternalId'::uuid,'$commandId'::uuid,'10000000-0000-0000-0000-000000000001','$deviceInternalId'::uuid,'diagnostics','{}','DELIVERED','10000000-0000-0000-0000-000000000101','telemetry-smoke',repeat('5',64),now()+interval '1 hour',1);
COMMIT;
"@
Invoke-Database $fixture | Out-Null
$baseTime = [DateTime]::UtcNow
$bodyObject = @{ events = @(
@{ event_key='smoke-status-1'; sequence_no=1; occurred_at=$baseTime.ToString('o'); event_type='status'; status=@{
firmware_version='telemetry-smoke-1.0.0'; reported_config_revision=1; network=@{type='wifi';rssi=-48};
rtc=@{joined=$false;uplink=$false;downlink=$false}; recording_queue=@{pending_segments=3;failed_segments=0}
}},
@{ event_key='smoke-error-2'; sequence_no=2; occurred_at=$baseTime.AddSeconds(1).ToString('o'); event_type='error'; error=@{code='RTC_RECONNECTING';severity='warning'} },
@{ event_key='smoke-command-3'; sequence_no=3; occurred_at=$baseTime.AddSeconds(2).ToString('o'); event_type='command_result'; command_result=@{
command_id=$commandId; status='SUCCEEDED'; result_summary=@{result_code='OK'}
}}
) }
$body = $bodyObject | ConvertTo-Json -Compress -Depth 8
$key = 'smoke-telemetry-' + [Guid]::NewGuid().ToString('N')
$response = Invoke-SignedTelemetry $body $key
Assert-Equal $response.StatusCode 200 '首次遥测批次失败'
$data = ($response.Content | ConvertFrom-Json).data
Assert-Equal $data.accepted_count 3 '首次遥测接收数量不正确'
Assert-Equal $data.duplicate_count 0 '首次遥测不应出现重复'
Assert-Equal $data.actions[0].type 'refresh_config' '设备配置落后时没有返回刷新动作'
$replay = Invoke-SignedTelemetry $body $key
Assert-Equal $replay.Headers['Idempotency-Replayed'][0] 'true' '遥测幂等重放缺少标记'
$duplicate = Invoke-SignedTelemetry $body ('smoke-telemetry-duplicate-' + [Guid]::NewGuid().ToString('N'))
$duplicateData = ($duplicate.Content | ConvertFrom-Json).data
Assert-Equal $duplicateData.accepted_count 0 '事件级重放被错误重复保存'
Assert-Equal $duplicateData.duplicate_count 3 '事件级重放没有全部判重'
$conflictObject = $body | ConvertFrom-Json
$conflictObject.events[1].error.code = 'RTC_FAILED'
try {
Invoke-SignedTelemetry ($conflictObject | ConvertTo-Json -Compress -Depth 8) ('smoke-telemetry-conflict-' + [Guid]::NewGuid().ToString('N')) | Out-Null
throw '同一遥测业务键对应不同内容不应成功。'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 409) { throw }
}
$evidence = Invoke-Database @"
SELECT dt.telemetry_sequence || '|' || dt.firmware_version || '|' || dt.last_error_code || '|' || dt.last_error_severity || '|' ||
dc.status || '|' || (SELECT count(*) FROM device_telemetry_event dte WHERE dte.device_id=dt.device_id)
FROM device_twin dt JOIN device_command dc ON dc.device_id=dt.device_id
WHERE dt.device_id='$deviceInternalId'::uuid;
"@
Assert-Equal $evidence '3|telemetry-smoke-1.0.0|RTC_RECONNECTING|warning|SUCCEEDED|3' '设备影子、错误、命令或事件落库结果不正确'
Write-Host '设备遥测冒烟通过:状态、错误、命令结果、批次幂等、事件判重、冲突回滚和配置刷新动作均正确。'
} finally {
Remove-TelemetryFixture
Write-Host '已清理遥测合成设备、命令、事件、在线状态和幂等记录。'
}
+42
View File
@@ -0,0 +1,42 @@
$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';$tenant='10000000-0000-0000-0000-000000000010';$device='10000000-0000-0000-0000-000000000511';$deviceSecret=[Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversation=$null;$refreshCommand=$null;$diagnosticDeviceInternal=[Guid]::NewGuid().ToString();$diagnosticDevice=[Guid]::NewGuid().ToString();$diagnosticJobInternal=[Guid]::NewGuid().ToString();$diagnosticJobAggregate=[Guid]::NewGuid().ToString();$createKey='diagnostic-create-'+[Guid]::NewGuid().ToString('N');$requestID='diagnostic-smoke-'+[Guid]::NewGuid().ToString('N')
$serialDigest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($diagnosticDevice))).ToLowerInvariant()
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 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'=$device;'X-Device-Timestamp'=$timestamp;'X-Device-Nonce'=$nonce;'X-Device-Content-SHA256'=$digest;'X-Device-Signature'=$signature;'Idempotency-Key'=$key}}
function Search([string]$field,[string]$value,[string]$token){((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/diagnostics/search?field=$field&value=$([Uri]::EscapeDataString($value))" -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenant}).Content|ConvertFrom-Json).data.items}
function Action([string]$path,[string]$reason,[string]$key,[string]$token){Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($api+$path) -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenant;'Idempotency-Key'=$key;'X-Request-Id'=$requestID} -ContentType 'application/json' -Body (@{reason=$reason}|ConvertTo-Json -Compress)}
try{
$owner=Token 'owner';$outsider=Token 'outsider'
try{Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/diagnostics/search?field=device_id&value=$device" -Headers @{Authorization="Bearer $outsider";'X-Tenant-Id'='20000000-0000-0000-0000-000000000010'}|Out-Null;throw '跨客户组织联调检索不应成功'}catch{if($null-eq$_.Exception.Response-or[int]$_.Exception.Response.StatusCode-ne403){throw}}
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref) VALUES('$diagnosticDeviceInternal'::uuid,'$diagnosticDevice'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'$serialDigest','diagnostic-smoke','registered','local://diagnostic-smoke'); INSERT INTO device_twin(device_id,tenant_id,desired_revision) VALUES('$diagnosticDeviceInternal'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,1); INSERT INTO outbox_event(id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,last_error_code,dead_at,payload_hash) VALUES('$diagnosticJobInternal'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'platform','$diagnosticJobAggregate'::uuid,'platform.noop','{}'::jsonb,'dead',8,'TEMPORARY_DEPENDENCY_FAILURE',now(),repeat('0',64));"|Out-Null
$jobPublic=(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT public_id FROM outbox_event WHERE id='$diagnosticJobInternal'::uuid"|Out-String).Trim()
$jobs=((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/diagnostics/jobs" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenant}).Content|ConvertFrom-Json).data.items
if(@($jobs|Where-Object{$_.id-eq$jobPublic-and$_.last_error_code-eq'TEMPORARY_DEPENDENCY_FAILURE'}).Count-ne1){throw '死信任务脱敏列表不正确'}
$retryKey='diagnostic-retry-'+[Guid]::NewGuid().ToString('N');$retry=(Action "/api/v1/ops/diagnostics/jobs/$jobPublic/retry" '依赖已恢复,执行一次受控人工重试' $retryKey $owner).Content|ConvertFrom-Json
if(-not $retry.data.queued-or$retry.data.manual_retry_count-ne1){throw '死信任务未按规则重置为待执行'}
$createBody=@{device_time=[DateTime]::UtcNow.ToString('o');monotonic_ms=24680;capabilities=@{rtc=$true;audio_tracks=@('near','far');upload_resume=$true};network=@{type='wifi'};requested_goal='open_conversation'}|ConvertTo-Json -Compress -Depth 8
$conversation=[string](((SignedPost '/device/v2/conversations' $createBody $createKey).Content|ConvertFrom-Json).data.conversation_id)
$refs=(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT rt.room_id||'|'||rt.provider_task_ref FROM rtc_task rt JOIN conversation c ON c.id=rt.conversation_id WHERE c.public_id='$conversation'::uuid"|Out-String).Trim().Split('|');$room=$refs[0];$task=$refs[1]
foreach($pair in @(@('device_id',$device),@('conversation_id',$conversation),@('room_id',$room),@('provider_task_ref',$task))){$items=@(Search $pair[0] $pair[1] $owner);$matched=@($items|Where-Object{$_.device_id-eq$device-and$_.conversation_id-eq$conversation});if($matched.Count-ne1){throw "检索字段$($pair[0])结果不正确"}}
$reconcileKey='diagnostic-reconcile-'+[Guid]::NewGuid().ToString('N');$reconcile=(Action "/api/v1/ops/diagnostics/conversations/$conversation/reconcile" '确认房间已下发状态无需供应商对账' $reconcileKey $owner).Content|ConvertFrom-Json;if($reconcile.data.queued-ne$false){throw '稳定状态对账不应错误排队'}
$refreshKey='diagnostic-refresh-'+[Guid]::NewGuid().ToString('N');$refresh=Action "/api/v1/ops/diagnostics/devices/$device/config-refresh" '要求测试设备重新应用当前配置' $refreshKey $owner;$refreshData=($refresh.Content|ConvertFrom-Json).data;$refreshCommand=[string]$refreshData.command_id;if($refresh.StatusCode-ne201-or -not $refreshData.queued){throw '配置刷新命令未排队'}
$stopKey='diagnostic-stop-'+[Guid]::NewGuid().ToString('N');$stop=Action "/api/v1/ops/diagnostics/conversations/$conversation/stop" '联调完成,释放尚未启动的房间' $stopKey $owner;$stopData=($stop.Content|ConvertFrom-Json).data;if($stopData.conversation_status-ne'EXPIRED'-or$stopData.queued-ne$false){throw '未启动通话没有安全收敛'}
$byRequest=@(Search 'request_id' $requestID $owner);if($byRequest.Count-ne1-or$byRequest[0].conversation_id-ne$conversation){throw '请求关联编号无法回查通话'}
$bundleKey='diagnostic-bundle-'+[Guid]::NewGuid().ToString('N');$bundleResponse=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/bundles" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenant;'Idempotency-Key'=$bundleKey;'X-Request-Id'=$requestID} -ContentType 'application/json' -Body (@{field='conversation_id';value=$conversation;reason='导出脱敏联调证据'}|ConvertTo-Json -Compress);$bundleData=($bundleResponse.Content|ConvertFrom-Json).data
if($bundleResponse.StatusCode-ne201-or$bundleData.redaction_policy-ne'ops-diagnostic-minimal-v1'-or@($bundleData.items).Count-ne1){throw '脱敏诊断包生成不正确'}
if($bundleResponse.Content-match'(?i)transcript|audio_url|rtc_token|credential_ref|payload'){throw '诊断包包含禁止导出的敏感字段'}
$revokeKey='diagnostic-revoke-'+[Guid]::NewGuid().ToString('N');$revoke=(Action "/api/v1/ops/diagnostics/devices/$diagnosticDevice/revoke" '吊销仅用于烟测的合成设备' $revokeKey $owner).Content|ConvertFrom-Json
if($revoke.data.action-ne'revoke_device'-or$revoke.data.queued-ne$false){throw '合成设备未完成管理员吊销'}
$revokedState=(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT status||'|'||(revoked_at IS NOT NULL)::text FROM device WHERE id='$diagnosticDeviceInternal'::uuid"|Out-String).Trim();if($revokedState-ne'revoked|true'){throw "设备吊销落库不正确:$revokedState"}
Write-Host '联调诊断烟测通过:五种精确检索、跨租户拒绝、安全停止、状态对账、配置刷新、死信重试、脱敏诊断包、设备吊销和审计关联均正确。'
}finally{
if($conversation-match'^[0-9a-fA-F-]{36}$'){docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; CREATE TEMP TABLE diag_conv AS SELECT id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversation'::uuid; DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM diag_conv); DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM diag_conv); DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM diag_conv); DELETE FROM audit_event WHERE request_id='$requestID' OR resource_id IN(SELECT id FROM diag_conv); DELETE FROM conversation WHERE id IN(SELECT id FROM diag_conv); DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM diag_conv); DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM diag_conv); COMMIT;"|Out-Null}
if($refreshCommand-match'^[0-9a-fA-F-]{36}$'){docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM device_command WHERE public_id='$refreshCommand'::uuid;"|Out-Null}
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM outbox_event WHERE id='$diagnosticJobInternal'::uuid; DELETE FROM device_twin WHERE device_id='$diagnosticDeviceInternal'::uuid; DELETE FROM device WHERE id='$diagnosticDeviceInternal'::uuid;"|Out-Null
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM idempotency_record WHERE actor_key='ops:10000000-0000-0000-0000-000000000111' AND scope LIKE 'POST /api/v1/ops/diagnostics/%';"|Out-Null
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM idempotency_record WHERE actor_key='device:$device' AND idempotency_key='$createKey';"|Out-Null
Write-Host '已清理联调诊断合成通话、命令、审计和幂等记录。'
}
+111
View File
@@ -0,0 +1,111 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$subjectId = '10000000-0000-0000-0000-000000000411'
$exportId = $null
$objectRef = $null
$tempDirectory = Join-Path ([IO.Path]::GetTempPath()) ('voice-export-smoke-' + [Guid]::NewGuid().ToString('N'))
function Get-LocalAccessToken([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 Assert-Equal([object]$Actual,[object]$Expected,[string]$Message) {
if ([string]$Actual -ne [string]$Expected) { throw "$Message。实际值:$Actual;预期值:$Expected" }
}
function Invoke-ExpectedStatus([scriptblock]$Action,[int]$ExpectedStatus) {
try { $response=& $Action; Assert-Equal $response.StatusCode $ExpectedStatus 'HTTP状态不正确' }
catch { if ($_.Exception.Response.StatusCode.value__ -ne $ExpectedStatus) { throw } }
}
function Invoke-ScalarQuery([string]$Sql) {
$value=docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql
if ($LASTEXITCODE -ne 0) { throw '数据库断言查询失败。' }
return ($value|Out-String).Trim()
}
function Remove-SmokeExport([string]$PublicId) {
if ($PublicId -notmatch '^[0-9a-fA-F-]{36}$') { throw '拒绝清理格式不正确的导出任务。' }
$sql=@"
BEGIN;
CREATE TEMP TABLE smoke_export_cleanup AS SELECT id,public_id FROM export_task WHERE public_id='$PublicId'::uuid;
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_export_cleanup);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_export_cleanup);
DELETE FROM export_task WHERE id IN (SELECT id FROM smoke_export_cleanup);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null
if ($LASTEXITCODE -ne 0) { throw '导出任务清理失败。' }
}
try {
New-Item -ItemType Directory -Path $tempDirectory | Out-Null
Assert-Equal (Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/health/ready").StatusCode 200 'API就绪检查失败'
$ownerToken=Get-LocalAccessToken 'owner'
$editorToken=Get-LocalAccessToken 'editor'
$key='smoke-export-'+[Guid]::NewGuid().ToString('N')
$body=@{scope=@{categories=@('profile','consents','device_bindings')}}|ConvertTo-Json -Compress -Depth 4
$headers=@{Authorization="Bearer $ownerToken";'Idempotency-Key'=$key}
$created=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/exports" -Headers $headers -ContentType 'application/json' -Body $body
Assert-Equal $created.StatusCode 202 '导出任务未进入后台队列'
$exportId=[string](($created.Content|ConvertFrom-Json).data.id)
if ($created.Content -match 'object_ref') { throw 'API响应泄露了对象存储内部路径。' }
$replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/exports" -Headers $headers -ContentType 'application/json' -Body $body
Assert-Equal $replay.StatusCode 200 '导出幂等重放状态不正确'
Assert-Equal $replay.Headers['Idempotency-Replayed'][0] 'true' '导出幂等重放缺少标记'
Assert-Equal (($replay.Content|ConvertFrom-Json).data.id) $exportId '导出幂等重放创建了新任务'
Invoke-ExpectedStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/exports" -Headers @{Authorization="Bearer $editorToken";'Idempotency-Key'=('editor-export-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $body } 403
$deadline=[DateTime]::UtcNow.AddSeconds(25)
do { Start-Sleep -Milliseconds 500; $response=Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/api/v1/exports/$exportId" -Headers @{Authorization="Bearer $ownerToken"}; $task=($response.Content|ConvertFrom-Json).data } while ($task.status -notin @('READY','FAILED','EXPIRED') -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $task.status 'READY' '私有导出文件未就绪'
if (-not $task.download_url -or $task.download_url -notmatch '^http://localhost:59000/') { throw '未返回浏览器可用的MinIO短期下载地址。' }
if ($response.Headers['Cache-Control'][0] -ne 'no-store') { throw '导出详情响应未禁止缓存。' }
$objectRef=Invoke-ScalarQuery "SELECT object_ref FROM export_task WHERE public_id='$exportId'::uuid"
if ($objectRef -notmatch '^tenants/[0-9a-f]{32}/exports/[0-9a-f-]{36}/v1\.json$') { throw "对象路径未使用租户摘要边界:$objectRef" }
Invoke-ExpectedStatus { Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:59000/private-exports/$objectRef" } 403
$downloadPath=Join-Path $tempDirectory 'export.json'
Invoke-WebRequest -UseBasicParsing -Uri $task.download_url -OutFile $downloadPath
$bytes=[IO.File]::ReadAllBytes($downloadPath)
$digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
Assert-Equal $digest $task.content_digest '下载文件校验值不匹配'
$document=Get-Content -LiteralPath $downloadPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert-Equal $document.schema_version 'subject-export-v1' '导出文件模式版本不正确'
Assert-Equal @($document.data.PSObject.Properties).Count 3 '导出文件没有按请求范围生成'
Assert-Equal $document.data.profile.id $subjectId '导出文件的长者边界不正确'
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c "UPDATE export_task SET expires_at=now()-interval '1 second' WHERE public_id='$exportId'::uuid" | Out-Null
$deadline=[DateTime]::UtcNow.AddSeconds(15)
do { Start-Sleep -Milliseconds 500; $status=Invoke-ScalarQuery "SELECT status FROM export_task WHERE public_id='$exportId'::uuid" } while ($status -ne 'EXPIRED' -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $status 'EXPIRED' '过期扫描器没有收敛导出任务'
docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password | Out-Null
$nativePreference=$PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference=$false
docker compose exec -T minio mc stat "smoke/private-exports/$objectRef" 2>$null | Out-Null
$statExitCode=$LASTEXITCODE
$PSNativeCommandUseErrorActionPreference=$nativePreference
if ($statExitCode -eq 0) { throw '导出过期后对象仍存在。' }
Write-Host "私有导出端到端冒烟通过:$exportId"
Write-Host '私有桶、范围限定、摘要校验、短期签名、幂等/权限和过期删除均已验证。'
} finally {
if ($objectRef) {
$nativePreference=$PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference=$false
docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password 2>$null | Out-Null
docker compose exec -T minio mc rm --force "smoke/private-exports/$objectRef" 2>$null | Out-Null
$PSNativeCommandUseErrorActionPreference=$nativePreference
}
if ($exportId) { Remove-SmokeExport $exportId; Write-Host "已清理本次导出冒烟数据:$exportId" }
if (Test-Path -LiteralPath $tempDirectory) { Remove-Item -LiteralPath $tempDirectory -Recurse -Force }
}
+274
View File
@@ -0,0 +1,274 @@
$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'
$householdId = '10000000-0000-0000-0000-000000000211'
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('firmware-smoke-' + [Guid]::NewGuid().ToString('N') + '.bin')
$downloadFile = $tempFile + '.download'
$keyIds = @()
$objectRefs = @()
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 Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
}
finally {
$sha.Dispose()
}
}
function Invoke-DeviceRequest([string]$Method, [string]$Path, [string]$Body = '') {
$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 = New-Object Security.Cryptography.HMACSHA256
$hmac.Key = $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
}
$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 Register-SigningKey([string]$KeyId, [string]$PublicKey, [hashtable]$Headers) {
$body = @{ key_id = $KeyId; public_key_base64 = $PublicKey } | ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $Headers -ContentType 'application/json' -Body $body | Out-Null
$script:keyIds += $KeyId
}
function Prepare-Release([object]$Signed, [string]$Version, [string]$KeyId, [hashtable]$Headers, [string]$Signature) {
$body = @{
model = 'simulated-terminal'
hardware_revision = 'sim-rev-a'
version = $Version
expected_size = [int64]$Signed.size
expected_digest = $Signed.sha256
signature = $Signature
signing_key_id = $KeyId
minimum_security_version = '1.0.0'
release_notes = 'synthetic smoke firmware'
} | ConvertTo-Json -Compress
$response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $Headers -ContentType 'application/json' -Body $body
$prepared = $response.Content | ConvertFrom-Json
if (-not $prepared.data.upload_url) {
throw 'Firmware upload session was not created'
}
$preflight = Invoke-WebRequest -UseBasicParsing -Method Options -Uri $prepared.data.upload_url -Headers @{
Origin = 'http://localhost:3100'
'Access-Control-Request-Method' = 'PUT'
'Access-Control-Request-Headers' = 'content-type'
}
if (-not $preflight.Headers['Access-Control-Allow-Origin']) {
throw 'Object storage did not allow the web origin for direct firmware upload'
}
Invoke-WebRequest -UseBasicParsing -Method Put -Uri $prepared.data.upload_url -InFile $tempFile -ContentType 'application/octet-stream' | Out-Null
return $prepared.data
}
function Finalize-Release([string]$ReleaseId, [hashtable]$Headers) {
$response = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$ReleaseId/finalize" -Headers $Headers
return ($response.Content | ConvertFrom-Json).data
}
function Assert-AssignmentProgress([string]$RolloutId, [hashtable]$Headers, [string]$ExpectedStatus, [int]$ExpectedProgress) {
$response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/firmware-rollouts/$RolloutId" -Headers $Headers
$detail = ($response.Content | ConvertFrom-Json).data
$assignment = $detail.assignments | Where-Object { $_.device_id -eq $deviceId } | Select-Object -First 1
if (-not $assignment) {
throw "Rollout $RolloutId did not expose the target assignment"
}
if ($assignment.status -ne $ExpectedStatus -or [int]$assignment.progress_percent -ne $ExpectedProgress) {
throw "Rollout assignment was $($assignment.status)/$($assignment.progress_percent), expected $ExpectedStatus/$ExpectedProgress"
}
}
try {
[IO.File]::WriteAllBytes($tempFile, [Text.Encoding]::UTF8.GetBytes('synthetic-firmware-' + [Guid]::NewGuid().ToString('N')))
$ownerToken = Get-AccessToken 'owner'
$editorToken = Get-AccessToken 'editor'
$ownerHeaders = @{ Authorization = "Bearer $ownerToken" }
$editorHeaders = @{ Authorization = "Bearer $editorToken" }
$newVersion = '9.9.' + (Get-Random -Minimum 1000 -Maximum 9999)
$newSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $newVersion $tempFile | ConvertFrom-Json
$newKeyId = 'smoke-new-' + [Guid]::NewGuid().ToString('N')
$newKeyBody = @{ key_id = $newKeyId; public_key_base64 = $newSigned.public_key_base64 } | ConvertTo-Json -Compress
Assert-HttpStatus {
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $editorHeaders -ContentType 'application/json' -Body $newKeyBody
} 403
Register-SigningKey $newKeyId $newSigned.public_key_base64 $ownerHeaders
$badVersion = $newVersion + '-bad-signature'
$badRelease = Prepare-Release $newSigned $badVersion $newKeyId $ownerHeaders $newSigned.signature_base64
Assert-HttpStatus {
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-releases/$($badRelease.id)/finalize" -Headers $ownerHeaders
} 409
$newRelease = Prepare-Release $newSigned $newVersion $newKeyId $ownerHeaders $newSigned.signature_base64
$newReady = Finalize-Release $newRelease.id $ownerHeaders
if ($newReady.status -ne 'READY') {
throw 'Valid signed firmware did not become READY'
}
$releaseList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $ownerHeaders).Content | ConvertFrom-Json
if (-not ($releaseList.data.items | Where-Object { $_.id -eq $newRelease.id -and $_.status -eq 'READY' })) {
throw 'Firmware release list did not expose the verified release'
}
$oldVersion = '9.8.' + (Get-Random -Minimum 1000 -Maximum 9999)
$oldSigned = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $oldVersion $tempFile | ConvertFrom-Json
$oldKeyId = 'smoke-old-' + [Guid]::NewGuid().ToString('N')
Register-SigningKey $oldKeyId $oldSigned.public_key_base64 $ownerHeaders
$oldRelease = Prepare-Release $oldSigned $oldVersion $oldKeyId $ownerHeaders $oldSigned.signature_base64
$oldReady = Finalize-Release $oldRelease.id $ownerHeaders
if ($oldReady.status -ne 'READY') {
throw 'Rollback firmware did not become READY'
}
$rolloutBody = @{
release_id = $newRelease.id
name = 'synthetic canary'
target_percentage = 100
device_ids = @($deviceId)
} | ConvertTo-Json -Compress
$rolloutResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders -ContentType 'application/json' -Body $rolloutBody
$rollout = ($rolloutResponse.Content | ConvertFrom-Json).data
$rolloutList = (Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $ownerHeaders).Content | ConvertFrom-Json
if (-not ($rolloutList.data.items | Where-Object { $_.id -eq $rollout.id -and $_.status -eq 'RUNNING' })) {
throw 'Firmware rollout list did not expose the running rollout'
}
$pausedResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/pause" -Headers $ownerHeaders
$paused = ($pausedResponse.Content | ConvertFrom-Json).data
if ($paused.status -ne 'PAUSED') {
throw 'Firmware rollout did not pause'
}
$cancelledState = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT dc.status FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$($rollout.id)'::uuid" | Out-String).Trim()
if ($cancelledState -ne 'CANCELLED') {
throw "Queued firmware command was not cancelled: $cancelledState"
}
Assert-HttpStatus {
Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($newRelease.id)/download"
} 403
$rollbackBody = @{ rollback_release_id = $oldRelease.id } | ConvertTo-Json -Compress
$rollbackResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/firmware-rollouts/$($rollout.id)/rollback" -Headers $ownerHeaders -ContentType 'application/json' -Body $rollbackBody
$rollback = ($rollbackResponse.Content | ConvertFrom-Json).data
$pulledResponse = Invoke-DeviceRequest 'GET' '/device/v2/commands?limit=20'
$pulled = $pulledResponse.Content | ConvertFrom-Json
$command = $pulled.data.items | Where-Object { $_.type -eq 'firmware_update' -and $_.payload.release_id -eq $oldRelease.id }
if (-not $command) {
throw 'Device did not receive the rollback firmware command'
}
$downloadResponse = Invoke-DeviceRequest 'GET' "/device/v2/firmware-releases/$($oldRelease.id)/download"
$download = $downloadResponse.Content | ConvertFrom-Json
Invoke-WebRequest -UseBasicParsing -Uri $download.data.download_url -OutFile $downloadFile
$actualDigest = (Get-FileHash -LiteralPath $downloadFile -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualDigest -ne $oldSigned.sha256) {
throw 'Downloaded rollback firmware digest does not match'
}
$downloadAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'downloading'; progress_percent = 25 } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $downloadAck | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'DOWNLOADING' 25
$installAck = @{ status = 'ACKNOWLEDGED'; result_summary = @{ phase = 'installing'; progress_percent = 80 } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $installAck | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'INSTALLING' 80
$ackBody = @{ status = 'SUCCEEDED'; result_summary = @{ installed_version = $oldVersion } } | ConvertTo-Json -Compress
Invoke-DeviceRequest 'POST' "/device/v2/commands/$($command.id)/ack" $ackBody | Out-Null
Assert-AssignmentProgress $rollback.id $ownerHeaders 'CONFIRMED' 100
Start-Sleep -Milliseconds 300
$state = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT ro.status||'|'||fa.status FROM firmware_rollout ro JOIN firmware_assignment fa ON fa.rollout_id=ro.id WHERE ro.public_id='$($rollback.id)'::uuid" | Out-String).Trim()
if ($state -ne 'COMPLETED|CONFIRMED') {
throw "Rollback rollout did not converge: $state"
}
Write-Host "Firmware signature, rejection, pause, and rollback smoke passed: $($rollout.id) -> $($rollback.id)"
}
finally {
if ($keyIds.Count -gt 0) {
$keyList = ($keyIds | ForEach-Object { "'$_'" }) -join ','
$refs = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT object_ref FROM firmware_release WHERE signing_key_id IN ($keyList)" | Out-String
$objectRefs = $refs -split "`r?`n" | Where-Object { $_.Trim() }
$sql = @"
BEGIN;
CREATE TEMP TABLE smoke_releases AS SELECT id,public_id FROM firmware_release WHERE signing_key_id IN($keyList);
CREATE TEMP TABLE smoke_rollouts AS SELECT id,public_id FROM firmware_rollout WHERE release_id IN(SELECT id FROM smoke_releases);
CREATE TEMP TABLE smoke_commands AS SELECT dc.id,dc.public_id FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id WHERE fa.rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM smoke_releases UNION SELECT public_id FROM smoke_rollouts UNION SELECT public_id FROM smoke_commands);
DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM smoke_commands);
DELETE FROM firmware_assignment WHERE rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM firmware_rollout WHERE id IN(SELECT id FROM smoke_rollouts);
DELETE FROM device_command WHERE id IN(SELECT id FROM smoke_commands);
DELETE FROM firmware_release WHERE id IN(SELECT id FROM smoke_releases);
DELETE FROM firmware_signing_key WHERE key_id IN($keyList);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | 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
}
}
if (Test-Path -LiteralPath $downloadFile) {
Remove-Item -LiteralPath $downloadFile -Force
}
if (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
}
@@ -0,0 +1,57 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('infrastructure-conformance-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'capture.json'
$reportPath = Join-Path $temporaryRoot 'report.json'
function Write-JsonUtf8([string]$path, $value) {
$json = $value | ConvertTo-Json -Depth 12
[System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
}
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$null = Invoke-RestMethod -Uri 'http://localhost:58080/health/ready'
$jwks = Invoke-RestMethod -Uri 'http://localhost:58180/realms/voice-local/protocol/openid-connect/certs'
if (@($jwks.keys).Count -lt 1) { throw '本地OIDC JWKS不可用' }
if ((docker compose exec -T postgres pg_isready -U voice -d voice_platform) -notmatch 'accepting connections') { throw '本地PostgreSQL不可用' }
if ((docker compose exec -T redis redis-cli ping).Trim() -ne 'PONG') { throw '本地Redis不可用' }
$null = Invoke-WebRequest -UseBasicParsing -Uri 'http://localhost:59000/minio/health/ready'
$null = Invoke-WebRequest -UseBasicParsing -Uri 'http://localhost:59190/-/ready'
foreach ($path in @('deploy/kubernetes/base/network-policies.yaml','deploy/prometheus/prometheus.yml','deploy/prometheus/rules/platform-alerts.yml')) {
if (-not (Test-Path -LiteralPath $path)) { throw "缺少本地配置引用:$path" }
}
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($imageDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API镜像摘要' }
$observedAt = (Get-Date).ToUniversalTime().AddMinutes(-1)
$collectedAt = (Get-Date).ToUniversalTime()
$definitions = @(
@('DOMAIN_TLS','http://localhost:58080','LOCAL_HTTP_REACHABLE','probe:api-ready','compose.yaml'),
@('OIDC','http://localhost:58180/realms/voice-local','JWKS_AVAILABLE','probe:keycloak-jwks','deploy/keycloak/voice-local-realm.json'),
@('POSTGRES','compose:postgres','LOCAL_REACHABLE','probe:pg-isready','compose.yaml'),
@('REDIS','compose:redis','LOCAL_REACHABLE','probe:redis-ping','compose.yaml'),
@('OBJECT_STORE','compose:minio','LOCAL_REACHABLE','probe:minio-ready','compose.yaml'),
@('NETWORK_ISOLATION','kubernetes:network-policy','STATIC_RENDER_PRESENT','probe:file-present','deploy/kubernetes/base/network-policies.yaml'),
@('MONITORING','compose:prometheus','LOCAL_REACHABLE','probe:prometheus-ready','deploy/prometheus/prometheus.yml')
)
$components = @($definitions | ForEach-Object {
@{ component_type = $_[0]; resource_ref = $_[1]; status = 'VERIFIED'; observed_at = $observedAt.ToString('o'); verified_controls = @($_[2]); probe_ref = $_[3]; configuration_ref = $_[4]; audit_ref = 'local-smoke:no-external-audit' }
})
$capture = @{
schema_version = 'production-infrastructure-capture-v1'; evidence_source = 'LOCAL'; candidate_version = '0.63.0-rc.local'; build_digest = $imageDigest
environment = 'LOCAL'; region_ref = 'local-machine'; cluster_ref = 'docker-compose'; change_ticket_ref = 'local-smoke'
attested_by_ref = 'local-smoke-script'; attested_at = $observedAt.AddSeconds(30).ToString('o'); collected_at = $collectedAt.ToString('o'); components = $components
}
Write-JsonUtf8 $capturePath $capture
go run ./cmd/infrastructure-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest $imageDigest -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_INFRASTRUCTURE' -or -not $report.external_validation_required -or $report.component_count -ne 7) { throw '本地基础设施报告边界不正确' }
Write-Host '本地基础设施纵向冒烟通过:7类组件可达或配置存在;报告固定为LOCAL_INFRASTRUCTURE,生产门禁继续待验。'
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '已清理本地基础设施冒烟临时文件。'
}
+81
View File
@@ -0,0 +1,81 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$device = '10000000-0000-0000-0000-000000000511'
$key = 'integration-issue-smoke-' + [Guid]::NewGuid().ToString('N')
$requestPrefix = 'integration-issue-smoke-' + [Guid]::NewGuid().ToString('N')
$issue = $null
function Get-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 Invoke-IssuePatch([string]$id, [int]$version, [hashtable]$body, [string]$token, [string]$suffix) {
Start-Sleep -Milliseconds 550
Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/ops/integration-issues/$id" -Headers @{
Authorization = "Bearer $token"; 'X-Tenant-Id' = $tenant; 'If-Match' = '"v' + $version + '"'; 'X-Request-Id' = $requestPrefix + '-' + $suffix
} -ContentType 'application/json' -Body ($body | ConvertTo-Json -Compress)
}
try {
$owner = Get-Token 'owner'
$outsider = Get-Token 'outsider'
$baselineSummary = ((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/integration-issues" -Headers @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant}).Content | ConvertFrom-Json).data.summary
$body = @{
phenomenon = '模拟设备已入房,但供应商任务未在预期时间进入活动状态'
severity = 'HIGH'
device_id = $device
occurred_at = [DateTime]::UtcNow.AddMinutes(-1).ToString('o')
request_id = $requestPrefix + '-source'
responsible_party = 'Agent研发组'
} | ConvertTo-Json -Compress
$headers = @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $key; 'X-Request-Id' = $requestPrefix + '-create'}
$createdResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/integration-issues" -Headers $headers -ContentType 'application/json' -Body $body
$issue = ($createdResponse.Content | ConvertFrom-Json).data
if ($createdResponse.StatusCode -ne 201 -or $createdResponse.Headers.ETag -ne '"v1"' -or $issue.status -ne 'OPEN' -or $issue.severity -ne 'HIGH') { throw '联调问题创建结果不正确' }
$replayResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/integration-issues" -Headers $headers -ContentType 'application/json' -Body $body
if ($replayResponse.StatusCode -ne 200 -or $replayResponse.Headers.'Idempotency-Replayed' -ne 'true' -or ($replayResponse.Content | ConvertFrom-Json).data.id -ne $issue.id) { throw '联调问题幂等重放不正确' }
$listData = ((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/integration-issues?status=OPEN" -Headers @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant}).Content | ConvertFrom-Json).data
$listed = $listData.items
if (@($listed | Where-Object {$_.id -eq $issue.id}).Count -ne 1) { throw '联调问题状态筛选未返回新记录' }
if ($listData.summary.release_blocking -ne ($baselineSummary.release_blocking + 1) -or $listData.summary.release_gate_satisfied) { throw '高严重度问题未进入发布阻断汇总' }
try {
Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/integration-issues" -Headers @{Authorization = "Bearer $outsider"; 'X-Tenant-Id' = $tenant} | Out-Null
throw '跨客户组织读取不应成功'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 403) { throw }
}
$taken = (Invoke-IssuePatch $issue.id 1 @{status='IN_PROGRESS';responsible_party='Agent研发组'} $owner 'take').Content | ConvertFrom-Json
$resolved = (Invoke-IssuePatch $issue.id 2 @{status='RESOLVED';resolution_result='修复供应商回调路由并重启模拟任务';root_cause='回调路由误用了旧供应商事件类型';fixed_version='0.49.0-rc.1+build.36'} $owner 'resolve').Content | ConvertFrom-Json
$verified = (Invoke-IssuePatch $issue.id 3 @{status='VERIFIED';regression_conclusion='连续三次模拟通话均进入ACTIVE且无遗留任务';regression_evidence=($requestPrefix + '-regression;脱敏诊断包 artifact-36;重复3次')} $owner 'verify').Content | ConvertFrom-Json
$closed = (Invoke-IssuePatch $issue.id 4 @{status='CLOSED'} $owner 'close').Content | ConvertFrom-Json
if ($taken.data.version -ne 2 -or $resolved.data.version -ne 3 -or $verified.data.version -ne 4 -or $closed.data.version -ne 5 -or $closed.data.status -ne 'CLOSED' -or -not $verified.data.regression_evidence) { throw '联调问题闭环状态或版本不正确' }
Start-Sleep -Milliseconds 550
$closedSummary = ((Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/integration-issues" -Headers @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant}).Content | ConvertFrom-Json).data.summary
if ($closedSummary.release_blocking -ne $baselineSummary.release_blocking -or $closedSummary.release_gate_satisfied -ne $baselineSummary.release_gate_satisfied) { throw '关闭高严重度问题后发布阻断汇总未恢复' }
try {
Invoke-IssuePatch $issue.id 1 @{status='IN_PROGRESS'} $owner 'stale' | Out-Null
throw '旧ETag更新不应成功'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 412) { throw }
}
Write-Host '联调问题清单烟测通过:严重度阻断、根因、修复版本、回归证据、闭环汇总、租户隔离和旧ETag拒绝均正确。'
} finally {
if ($null -ne $issue -and $issue.id -match '^[0-9a-fA-F-]{36}$') {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; CREATE TEMP TABLE issue_cleanup AS SELECT id FROM integration_issue WHERE public_id='$($issue.id)'::uuid; DELETE FROM audit_event WHERE request_id LIKE '$requestPrefix%' OR (resource_type='integration_issue' AND resource_id IN(SELECT id FROM issue_cleanup)); DELETE FROM integration_issue WHERE id IN(SELECT id FROM issue_cleanup); DELETE FROM idempotency_record WHERE idempotency_key='$key'; COMMIT;" | Out-Null
}
Write-Host '已清理联调问题烟测记录、审计和幂等数据。'
}
+257
View File
@@ -0,0 +1,257 @@
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 }
}
+94
View File
@@ -0,0 +1,94 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000010'
$householdId = $null
function Get-LocalAccessToken([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 Invoke-JSONRequest([string]$Method, [string]$Uri, [string]$Token, [hashtable]$Headers = @{}, [object]$Body = $null) {
$requestHeaders = @{ Authorization = "Bearer $Token" }
foreach ($key in $Headers.Keys) { $requestHeaders[$key] = $Headers[$key] }
$arguments = @{ Method = $Method; Uri = $Uri; Headers = $requestHeaders }
if ($null -ne $Body) {
$arguments.ContentType = 'application/json; charset=utf-8'
$arguments.Body = $Body | ConvertTo-Json -Depth 10 -Compress
}
return Invoke-WebRequest @arguments
}
function Assert-Status([scriptblock]$Operation, [int]$ExpectedStatus) {
try { & $Operation | Out-Null; throw "请求不应成功,预期HTTP $ExpectedStatus。" }
catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne $ExpectedStatus) { throw } }
}
function Convert-Response($Response) { return $Response.Content | ConvertFrom-Json }
try {
$ownerToken = Get-LocalAccessToken 'owner'
$editorToken = Get-LocalAccessToken 'editor'
$outsiderToken = Get-LocalAccessToken 'outsider'
$createdResponse = Invoke-JSONRequest Post "$apiBase/api/v1/households" $ownerToken @{ 'X-Tenant-Id' = $tenantId } @{ name = '成员管理纵向烟测家庭' }
$household = (Convert-Response $createdResponse).data
$householdId = $household.id
$householdETag = [string]($createdResponse.Headers.ETag | Select-Object -First 1)
Assert-Status { Invoke-JSONRequest Get "$apiBase/api/v1/households/$householdId/members" $outsiderToken } 403
Assert-Status { Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/invitations" $editorToken @{} @{ invitee_hint = '禁止'; role = 'viewer' } } 403
$invitationResponse = Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/invitations" $ownerToken @{} @{
invitee_hint = '跨客户组织受邀账号'; role = 'editor'; expires_in_hours = 2
}
$invitation = (Convert-Response $invitationResponse).data
if ([string]::IsNullOrWhiteSpace($invitation.token)) { throw '创建邀请未返回一次性凭据。' }
$storedDigest = docker compose exec -T postgres psql -U voice -d voice_platform -Atc "SELECT token_digest FROM member_invitation WHERE public_id='$($invitation.id)'::uuid;"
if ($storedDigest.Trim() -eq $invitation.token -or $storedDigest.Trim().Length -ne 64) { throw '数据库未仅保存邀请SHA-256摘要。' }
$accepted = Convert-Response (Invoke-JSONRequest Post "$apiBase/api/v1/member-invitations/accept" $outsiderToken @{} @{ token = $invitation.token })
if ($accepted.data.role -ne 'editor') { throw '接受邀请后的角色不正确。' }
Assert-Status { Invoke-JSONRequest Post "$apiBase/api/v1/member-invitations/accept" $outsiderToken @{} @{ token = $invitation.token } } 409
$members = (Convert-Response (Invoke-JSONRequest Get "$apiBase/api/v1/households/$householdId/members" $ownerToken)).data.items
$ownerMember = $members | Where-Object is_owner -eq $true
$editorMember = $members | Where-Object user_id -eq $accepted.data.user_id
if ($members.Count -ne 2) { throw "成员数量不正确:$($members.Count)" }
Assert-Status { Invoke-JSONRequest Delete "$apiBase/api/v1/households/$householdId/members/$($ownerMember.id)" $ownerToken @{ 'If-Match' = '"v1"' } } 409
$roleResponse = Invoke-JSONRequest Patch "$apiBase/api/v1/households/$householdId/members/$($editorMember.id)" $ownerToken @{ 'If-Match' = '"v1"' } @{ role = 'admin' }
$editorMember = (Convert-Response $roleResponse).data
Assert-Status { Invoke-JSONRequest Patch "$apiBase/api/v1/households/$householdId/members/$($editorMember.id)" $ownerToken @{ 'If-Match' = '"v1"' } @{ role = 'viewer' } } 412
$revokable = (Convert-Response (Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/invitations" $outsiderToken @{} @{ invitee_hint = '待撤销'; role = 'viewer' })).data
$revoked = (Convert-Response (Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/invitations/$($revokable.id)/revoke" $outsiderToken)).data
if ($revoked.status -ne 'revoked') { throw '管理员撤销邀请失败。' }
$transferResponse = Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/ownership" $ownerToken @{ 'If-Match' = $householdETag } @{ member_id = $editorMember.id }
$householdETag = [string]($transferResponse.Headers.ETag | Select-Object -First 1)
$members = (Convert-Response (Invoke-JSONRequest Get "$apiBase/api/v1/households/$householdId/members" $outsiderToken)).data.items
$formerOwner = $members | Where-Object user_id -eq $ownerMember.user_id
$newOwner = $members | Where-Object user_id -eq $editorMember.user_id
if (-not $newOwner.is_owner -or $formerOwner.role -ne 'admin') { throw '首次所有权转移后的角色不正确。' }
$transferBack = Invoke-JSONRequest Post "$apiBase/api/v1/households/$householdId/ownership" $outsiderToken @{ 'If-Match' = $householdETag } @{ member_id = $formerOwner.id }
$members = (Convert-Response (Invoke-JSONRequest Get "$apiBase/api/v1/households/$householdId/members" $ownerToken)).data.items
$editorMember = $members | Where-Object user_id -eq $editorMember.user_id
$removed = (Convert-Response (Invoke-JSONRequest Delete "$apiBase/api/v1/households/$householdId/members/$($editorMember.id)" $ownerToken @{ 'If-Match' = "`"v$($editorMember.version)`"" })).data
if ($removed.status -ne 'removed') { throw '成员移除状态不正确。' }
Write-Host "成员管理烟测通过:家庭 $householdId,邀请摘要安全、复用拒绝、角色并发、所有权转移与成员移除均通过。"
} finally {
if ($householdId) {
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U voice -d voice_platform -c `
"BEGIN; DELETE FROM audit_event WHERE request_id LIKE 'req-%' AND (resource_id='$householdId'::uuid OR detail->>'household_id'='$householdId'); DELETE FROM member_invitation WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid); DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid); DELETE FROM household WHERE public_id='$householdId'::uuid; DELETE FROM user_account WHERE tenant_id='10000000-0000-0000-0000-000000000001'::uuid AND oidc_subject='00000000-0000-0000-0000-000000000103'; COMMIT;" | Out-Null
}
}
+112
View File
@@ -0,0 +1,112 @@
$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}
}
+27
View File
@@ -0,0 +1,27 @@
$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';$tenant='10000000-0000-0000-0000-000000000010';$device='10000000-0000-0000-0000-000000000511';$deviceSecret=[Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$conversation=$null;$accessID=$null;$createKey='ops-access-conversation-'+[Guid]::NewGuid().ToString('N');$requestID='ops-access-smoke-'+[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 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'=$device;'X-Device-Timestamp'=$timestamp;'X-Device-Nonce'=$nonce;'X-Device-Content-SHA256'=$digest;'X-Device-Signature'=$signature;'Idempotency-Key'=$key}}
function OpsPost([string]$path,[object]$body,[string]$key,[string]$token){Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($api+$path) -Headers @{Authorization="Bearer $token";'X-Tenant-Id'=$tenant;'Idempotency-Key'=$key;'X-Request-Id'=$requestID} -ContentType 'application/json' -Body ($body|ConvertTo-Json -Compress -Depth 5)}
try{
$owner=Token 'owner';$approver=Token 'editor'
$createBody=@{device_time=[DateTime]::UtcNow.ToString('o');monotonic_ms=35791;capabilities=@{rtc=$true;audio_tracks=@('near','far');upload_resume=$true};network=@{type='wifi'};requested_goal='open_conversation'}|ConvertTo-Json -Compress -Depth 8
$conversation=[string](((SignedPost '/device/v2/conversations' $createBody $createKey).Content|ConvertFrom-Json).data.conversation_id)
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) SELECT c.tenant_id,c.subject_id,c.id,'ops-access-smoke-turn',1,'subject',now()-interval '2 seconds',now()-interval '1 second','仅在双人批准后返回的烟测字幕','provider' FROM conversation c WHERE c.public_id='$conversation'::uuid;"|Out-Null
$expires=[DateTime]::UtcNow.AddHours(1).ToString('o');$create=OpsPost '/api/v1/ops/sensitive-access-requests' @{conversation_id=$conversation;capabilities=@('TRANSCRIPT_READ');reason='核对指定通话的最终转写异常';expires_at=$expires} ('ops-access-request-'+[Guid]::NewGuid().ToString('N')) $owner
$accessID=[string](($create.Content|ConvertFrom-Json).data.id);if($create.StatusCode-ne201-or$accessID-notmatch'^[0-9a-fA-F-]{36}$'){throw '临时访问申请未创建'}
try{OpsPost "/api/v1/ops/sensitive-access-requests/$accessID/review" @{decision='APPROVE';reason='申请人不得自批'} ('ops-access-self-'+[Guid]::NewGuid().ToString('N')) $owner|Out-Null;throw '申请人自批不应成功'}catch{if($null-eq$_.Exception.Response-or[int]$_.Exception.Response.StatusCode-ne409){throw}}
$review=OpsPost "/api/v1/ops/sensitive-access-requests/$accessID/review" @{decision='APPROVE';reason='审批人复核问题范围与最小能力后批准'} ('ops-access-review-'+[Guid]::NewGuid().ToString('N')) $approver;if((($review.Content|ConvertFrom-Json).data.status)-ne'APPROVED'){throw '双人审批未生效'}
try{Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/sensitive-conversations/$conversation" -Headers @{Authorization="Bearer $approver";'X-Tenant-Id'=$tenant;'X-Ops-Access-Grant'=$accessID;'X-Request-Id'=$requestID}|Out-Null;throw '审批人不应使用申请人的临时权限'}catch{if($null-eq$_.Exception.Response-or[int]$_.Exception.Response.StatusCode-ne403){throw}}
$sensitive=Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/sensitive-conversations/$conversation" -Headers @{Authorization="Bearer $owner";'X-Tenant-Id'=$tenant;'X-Ops-Access-Grant'=$accessID;'X-Request-Id'=$requestID};$data=($sensitive.Content|ConvertFrom-Json).data
if(@($data.turns).Count-ne1-or$data.turns[0].final_text-ne'仅在双人批准后返回的烟测字幕'){throw '批准后的字幕读取不正确'}
$counts=(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 ops_sensitive_access_log WHERE access_request_id=(SELECT id FROM ops_sensitive_access_request WHERE public_id='$accessID'::uuid)"|Out-String).Trim();if($counts-ne'1|1'){throw "逐次访问日志不正确:$counts"}
Write-Host '临时敏感数据访问烟测通过:申请、自批拒绝、双人审批、申请人绑定、字幕读取及成功/拒绝逐次审计均正确。'
}finally{
if($conversation-match'^[0-9a-fA-F-]{36}$'){docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; CREATE TEMP TABLE ops_access_conv AS SELECT id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversation'::uuid; DELETE FROM audit_event WHERE request_id='$requestID' OR resource_id=NULLIF('$accessID','')::uuid; DELETE FROM ops_sensitive_access_log WHERE conversation_id IN(SELECT id FROM ops_access_conv); DELETE FROM ops_sensitive_access_request WHERE conversation_id IN(SELECT id FROM ops_access_conv); DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM ops_access_conv); DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM ops_access_conv); DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM ops_access_conv); DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM ops_access_conv); DELETE FROM conversation WHERE id IN(SELECT id FROM ops_access_conv); DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM ops_access_conv); DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM ops_access_conv); COMMIT;"|Out-Null}
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "DELETE FROM idempotency_record WHERE (actor_key LIKE 'ops:%' AND scope LIKE 'POST /api/v1/ops/sensitive-access-requests%') OR (actor_key='device:$device' AND idempotency_key='$createKey');"|Out-Null
Write-Host '已清理临时提权烟测的通话、申请、访问日志、审计和幂等记录。'
}
+54
View File
@@ -0,0 +1,54 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$key = 'pilot-daily-smoke-' + [Guid]::NewGuid().ToString('N')
$requestID = 'pilot-daily-smoke-' + [Guid]::NewGuid().ToString('N')
function Get-Token {
(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
try {
$owner = Get-Token
$buildDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
$body = @{
candidate_version = '0.55.0-rc.local'
build_digest = $buildDigest
local_date = Get-Date -Format 'yyyy-MM-dd'
timezone = 'Asia/Shanghai'
reason = 'Verify pilot daily snapshot aggregation, redaction, and download output'
} | ConvertTo-Json -Compress
$headers = @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $key; 'X-Request-Id' = $requestID}
$createdResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-daily-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
$snapshot = ($createdResponse.Content | ConvertFrom-Json).data
if ($createdResponse.StatusCode -ne 201 -or $snapshot.redaction_policy -ne 'ops-pilot-daily-minimal-v1') { throw 'Initial pilot daily snapshot is invalid' }
if ($snapshot.candidate_version -ne '0.55.0-rc.local' -or $snapshot.build_digest -ne $buildDigest -or $snapshot.timezone -ne 'Asia/Shanghai' -or $snapshot.local_date -ne (Get-Date -Format 'yyyy-MM-dd')) { throw 'Pilot daily candidate, date, or timezone is invalid' }
if (@($snapshot.conversations.statuses).Count -ne 8) { throw 'Conversation status distribution is incomplete' }
$conversationTotal = ($snapshot.conversations.statuses | Measure-Object -Property count -Sum).Sum
if ([long]$conversationTotal -ne [long]$snapshot.conversations.total) { throw 'Conversation total does not match the status distribution' }
if ([long]$snapshot.devices.online + [long]$snapshot.devices.offline -ne [long]$snapshot.devices.eligible) { throw 'Device online and offline counts do not match eligible total' }
if (($createdResponse.Content -match 'provider_task_ref|final_text|access_url|recording|subject_name')) { throw 'Pilot daily snapshot contains a prohibited sensitive field' }
$replayResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-daily-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
if ($replayResponse.StatusCode -ne 200 -or $replayResponse.Headers.'Idempotency-Replayed' -ne 'true' -or ($replayResponse.Content | ConvertFrom-Json).data.id -ne $snapshot.id) { throw 'Pilot daily idempotent replay is invalid' }
try {
$badBody = @{candidate_version = '0.55.0-rc.local'; build_digest = $buildDigest; local_date = Get-Date -Format 'yyyy-MM-dd'; timezone = 'Mars/Base'; reason = 'Verify invalid timezone validation'} | ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-daily-snapshots" -Headers @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = ('bad-' + $key)} -ContentType 'application/json' -Body $badBody | Out-Null
throw 'An invalid IANA timezone must not be accepted'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 400) { throw }
}
Write-Host "Pilot daily smoke passed: real OIDC, natural-day aggregation, fixed status distribution, device invariants, redaction, replay, and timezone validation; snapshot=$($snapshot.id)."
} finally {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; DELETE FROM audit_event WHERE request_id='$requestID'; DELETE FROM idempotency_record WHERE idempotency_key IN ('$key','bad-$key'); COMMIT;" | Out-Null
Write-Host 'Pilot daily smoke audit and idempotency records removed; snapshots are transient.'
}
+61
View File
@@ -0,0 +1,61 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$key = 'pilot-weekly-smoke-' + [Guid]::NewGuid().ToString('N')
$requestID = 'pilot-weekly-smoke-' + [Guid]::NewGuid().ToString('N')
$weekdayOffset = (([int](Get-Date).DayOfWeek) + 6) % 7
$weekStart = (Get-Date).Date.AddDays(-$weekdayOffset).ToString('yyyy-MM-dd')
function Get-Token {
(Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
try {
$owner = Get-Token
$buildDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
$body = @{
candidate_version = '0.55.0-rc.local'
build_digest = $buildDigest
week_start = $weekStart
timezone = 'Asia/Shanghai'
reason = 'Verify pilot weekly review aggregation, redaction, and evidence gaps'
} | ConvertTo-Json -Compress
$headers = @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $key; 'X-Request-Id' = $requestID}
$createdResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-weekly-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
$snapshot = ($createdResponse.Content | ConvertFrom-Json).data
if ($createdResponse.StatusCode -ne 201 -or $snapshot.redaction_policy -ne 'ops-pilot-weekly-minimal-v1') { throw 'Initial pilot weekly snapshot is invalid' }
if ($snapshot.candidate_version -ne '0.55.0-rc.local' -or $snapshot.build_digest -ne $buildDigest -or $snapshot.week_start -ne $weekStart -or $snapshot.timezone -ne 'Asia/Shanghai') { throw 'Pilot weekly candidate, date, or timezone is invalid' }
if (@($snapshot.privacy.by_type).Count -ne 4 -or @($snapshot.privacy.by_status).Count -ne 5) { throw 'Privacy distributions are incomplete' }
if ([long]$snapshot.config.aligned + [long]$snapshot.config.drifted + [long]$snapshot.config.unreported -ne [long]$snapshot.config.eligible_devices) { throw 'Configuration counts do not match eligible devices' }
$firmwareDevices = (@($snapshot.firmware_versions) | Measure-Object -Property device_count -Sum).Sum
if ([long]$firmwareDevices -ne [long]$snapshot.config.eligible_devices) { throw 'Firmware distribution does not match eligible devices' }
if ($snapshot.content_review.manual_revision_rate_available -ne ($snapshot.content_review.stories_review_touched -gt 0)) { throw 'Manual revision rate availability is invalid' }
if ($snapshot.content_review.story_confirmation_rate_available -ne (($snapshot.content_review.stories_confirmed + $snapshot.content_review.stories_rejected) -gt 0)) { throw 'Story confirmation rate availability is invalid' }
if ($snapshot.content_review.manual_revision_rate_basis_points -lt 0 -or $snapshot.content_review.manual_revision_rate_basis_points -gt 10000 -or $snapshot.content_review.story_confirmation_rate_basis_points -lt 0 -or $snapshot.content_review.story_confirmation_rate_basis_points -gt 10000) { throw 'Weekly rate is outside 0..10000 basis points' }
if (@($snapshot.external_evidence).Count -ne 1 -or $snapshot.external_evidence[0].code -ne 'complaints' -or $snapshot.external_evidence[0].status -ne 'REQUIRED') { throw 'Complaint evidence gap was not preserved' }
if (($createdResponse.Content -match 'final_text|provider_task_ref|access_url|credential_ref|story_title|subject_name')) { throw 'Pilot weekly snapshot contains a prohibited sensitive field' }
$replayResponse = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-weekly-snapshots" -Headers $headers -ContentType 'application/json' -Body $body
if ($replayResponse.StatusCode -ne 200 -or $replayResponse.Headers.'Idempotency-Replayed' -ne 'true' -or ($replayResponse.Content | ConvertFrom-Json).data.id -ne $snapshot.id) { throw 'Pilot weekly idempotent replay is invalid' }
try {
$tuesday = (Get-Date $weekStart).AddDays(1).ToString('yyyy-MM-dd')
$badBody = @{candidate_version = '0.55.0-rc.local'; build_digest = $buildDigest; week_start = $tuesday; timezone = 'Asia/Shanghai'; reason = 'Verify non-Monday validation'} | ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-weekly-snapshots" -Headers @{Authorization = "Bearer $owner"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = ('bad-' + $key)} -ContentType 'application/json' -Body $badBody | Out-Null
throw 'A non-Monday week start must not be accepted'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 400) { throw }
}
Write-Host "Pilot weekly smoke passed: real OIDC, calendar-week aggregation, rate semantics, privacy distributions, device invariants, external evidence gap, redaction, replay, and Monday validation; snapshot=$($snapshot.id)."
} finally {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; DELETE FROM audit_event WHERE request_id='$requestID'; DELETE FROM idempotency_record WHERE idempotency_key IN ('$key','bad-$key'); COMMIT;" | Out-Null
Write-Host 'Pilot weekly smoke audit and idempotency records removed; snapshots are transient.'
}
+164
View File
@@ -0,0 +1,164 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000010'
$householdId = $null
$subjectId = $null
$privacyRequestId = $null
$exportId = $null
$objectRef = $null
$subjectName = '删除烟测长者-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)
$sentinel = 'privacy-delete-sentinel-' + [Guid]::NewGuid().ToString('N')
function Get-LocalAccessToken([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 Invoke-JSON([string]$Method,[string]$Uri,[string]$Token,[object]$Body=$null,[hashtable]$ExtraHeaders=@{}) {
$headers=@{Authorization="Bearer $Token"}
foreach($key in $ExtraHeaders.Keys){$headers[$key]=$ExtraHeaders[$key]}
$arguments=@{UseBasicParsing=$true;Method=$Method;Uri=$Uri;Headers=$headers}
if($null-ne $Body){$arguments.ContentType='application/json';$arguments.Body=$Body|ConvertTo-Json -Compress -Depth 8}
return Invoke-WebRequest @arguments
}
function Assert-Equal([object]$Actual,[object]$Expected,[string]$Message) {
if([string]$Actual-ne[string]$Expected){throw "$Message。实际值:$Actual;预期值:$Expected"}
}
function Invoke-ExpectedStatus([scriptblock]$Action,[int]$ExpectedStatus) {
try{$response=&$Action;Assert-Equal $response.StatusCode $ExpectedStatus 'HTTP状态不正确'}
catch{if($_.Exception.Response.StatusCode.value__-ne$ExpectedStatus){throw}}
}
function Invoke-Scalar([string]$Sql) {
$value=docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql
if($LASTEXITCODE-ne 0){throw '数据库断言查询失败。'}
return ($value|Out-String).Trim()
}
function Remove-SmokeData {
if($subjectId-notmatch '^[0-9a-fA-F-]{36}$'){return}
$sql=@"
BEGIN;
CREATE TEMP TABLE cleanup_subject AS SELECT id,public_id,household_id FROM subject_profile WHERE public_id='$subjectId'::uuid;
CREATE TEMP TABLE cleanup_privacy AS SELECT id,public_id FROM privacy_request WHERE subject_id IN (SELECT id FROM cleanup_subject);
CREATE TEMP TABLE cleanup_exports AS SELECT id,public_id FROM export_task WHERE subject_id IN (SELECT id FROM cleanup_subject);
CREATE TEMP TABLE cleanup_conversations AS SELECT id FROM conversation WHERE subject_id IN (SELECT id FROM cleanup_subject);
DELETE FROM tombstone WHERE deletion_request_id IN (SELECT id FROM cleanup_privacy);
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM cleanup_privacy UNION SELECT id FROM cleanup_exports);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM cleanup_subject UNION SELECT public_id FROM cleanup_privacy UNION SELECT public_id FROM cleanup_exports);
DELETE FROM privacy_request WHERE id IN (SELECT id FROM cleanup_privacy);
DELETE FROM export_task WHERE id IN (SELECT id FROM cleanup_exports);
DELETE FROM consent_record WHERE subject_id IN (SELECT id FROM cleanup_subject);
DELETE FROM subject_fact WHERE subject_id IN (SELECT id FROM cleanup_subject);
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM cleanup_conversations);
DELETE FROM rtc_task WHERE conversation_id IN (SELECT id FROM cleanup_conversations);
DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM cleanup_conversations);
DELETE FROM conversation WHERE id IN (SELECT id FROM cleanup_conversations);
DELETE FROM context_snapshot WHERE subject_id IN (SELECT id FROM cleanup_subject);
DELETE FROM consent_snapshot WHERE subject_id IN (SELECT id FROM cleanup_subject);
DELETE FROM subject_profile WHERE id IN (SELECT id FROM cleanup_subject);
DELETE FROM household_member WHERE household_id IN (SELECT household_id FROM cleanup_subject);
DELETE FROM household WHERE id IN (SELECT household_id FROM cleanup_subject);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null
}
try {
Assert-Equal (Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/health/ready").StatusCode 200 'API就绪检查失败'
$ownerToken=Get-LocalAccessToken 'owner'
$editorToken=Get-LocalAccessToken 'editor'
$household=(Invoke-JSON 'POST' "$apiBase/api/v1/households" $ownerToken @{name='隐私删除烟测家庭'} @{'X-Tenant-Id'=$tenantId}).Content|ConvertFrom-Json
$householdId=[string]$household.data.id
$subject=(Invoke-JSON 'POST' "$apiBase/api/v1/households/$householdId/subjects" $ownerToken @{preferred_name=$subjectName;language='zh-CN';timezone='Asia/Shanghai';profile_data=@{sentinel=$sentinel};conversation_preferences=@{note=$sentinel}}).Content|ConvertFrom-Json
$subjectId=[string]$subject.data.id
Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/facts" $ownerToken @{fact_type='privacy_smoke';content=$sentinel;source_type='family_entry';source_ref=$sentinel;confirmation_status='confirmed';sensitivity='normal'} | Out-Null
$exportKey='pre-delete-export-'+[Guid]::NewGuid().ToString('N')
$export=(Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/exports" $ownerToken @{scope=@{categories=@('profile')}} @{'Idempotency-Key'=$exportKey}).Content|ConvertFrom-Json
$exportId=[string]$export.data.id
$deadline=[DateTime]::UtcNow.AddSeconds(20)
do{Start-Sleep -Milliseconds 400;$exportDetail=(Invoke-JSON 'GET' "$apiBase/api/v1/exports/$exportId" $ownerToken).Content|ConvertFrom-Json}while($exportDetail.data.status-notin@('READY','FAILED','EXPIRED')-and[DateTime]::UtcNow-lt$deadline)
Assert-Equal $exportDetail.data.status 'READY' '删除前测试导出未就绪'
$objectRef=Invoke-Scalar "SELECT object_ref FROM export_task WHERE public_id='$exportId'::uuid"
$deleteKey='privacy-delete-'+[Guid]::NewGuid().ToString('N')
$body=@{request_type='DELETE';scope=@{};reason=$sentinel;confirmation=$subjectName}
Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken @{request_type='DELETE';scope=@{};reason='test';confirmation='错误称呼'} @{'Idempotency-Key'=('bad-confirm-'+[Guid]::NewGuid().ToString('N'))}} 409
Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $editorToken $body @{'Idempotency-Key'=('editor-delete-'+[Guid]::NewGuid().ToString('N'))}} 403
$activeRef='privacy-delete-active-'+[Guid]::NewGuid().ToString('N')
$activeSql=@"
WITH target AS (
SELECT sp.id subject_id,sp.tenant_id,sp.household_id,
(SELECT d.id FROM device d WHERE d.tenant_id=sp.tenant_id AND NOT EXISTS (SELECT 1 FROM conversation c WHERE c.device_id=d.id AND c.status NOT IN ('COMPLETED','FAILED','EXPIRED')) LIMIT 1) device_id
FROM subject_profile sp WHERE sp.public_id='$subjectId'::uuid
), consent AS (
INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents) SELECT tenant_id,subject_id,1,'{}'::jsonb FROM target RETURNING id,tenant_id,subject_id
), context AS (
INSERT INTO context_snapshot(tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest)
SELECT c.tenant_id,c.subject_id,1,c.id,'privacy-delete-smoke','s2s_hybrid',repeat('0',64) FROM consent c RETURNING id,tenant_id,subject_id
)
INSERT INTO conversation(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)
SELECT t.tenant_id,t.household_id,t.subject_id,t.device_id,c.id,x.id,'$activeRef','s2s_hybrid','none','ACTIVE',now()+interval '5 minutes' FROM target t JOIN consent c ON c.subject_id=t.subject_id JOIN context x ON x.subject_id=t.subject_id;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $activeSql | Out-Null
Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=('active-conflict-'+[Guid]::NewGuid().ToString('N'))}} 409
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c "WITH removed AS (DELETE FROM conversation WHERE provider_task_ref='$activeRef' RETURNING context_snapshot_id,consent_snapshot_id) DELETE FROM context_snapshot WHERE id IN (SELECT context_snapshot_id FROM removed); DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id='$subjectId'::uuid) AND NOT EXISTS (SELECT 1 FROM conversation WHERE consent_snapshot_id=consent_snapshot.id);" | Out-Null
$created=Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=$deleteKey}
Assert-Equal $created.StatusCode 202 '删除申请未进入后台队列'
$privacyRequestId=[string](($created.Content|ConvertFrom-Json).data.id)
Invoke-ExpectedStatus {Invoke-JSON 'GET' "$apiBase/api/v1/subjects/$subjectId" $ownerToken} 403
Invoke-ExpectedStatus {Invoke-JSON 'GET' "$apiBase/api/v1/exports/$exportId" $ownerToken} 403
Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/exports" $ownerToken @{scope=@{}} @{'Idempotency-Key'=('blocked-export-'+[Guid]::NewGuid().ToString('N'))}} 403
$replay=Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=$deleteKey}
Assert-Equal $replay.StatusCode 200 '冻结后的删除幂等重放失败'
Assert-Equal (($replay.Content|ConvertFrom-Json).data.id) $privacyRequestId '删除重放创建了第二个请求'
$deadline=[DateTime]::UtcNow.AddSeconds(30)
do{Start-Sleep -Milliseconds 500;$detail=(Invoke-JSON 'GET' "$apiBase/api/v1/privacy-requests/$privacyRequestId" $ownerToken).Content|ConvertFrom-Json}while($detail.data.status-notin@('COMPLETED','PARTIAL_FAILED')-and[DateTime]::UtcNow-lt$deadline)
if ($detail.data.status -ne 'COMPLETED') {
$subtaskEvidence = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT ps.subsystem,ps.status,COALESCE(ps.last_error,'') FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id WHERE pr.public_id='$privacyRequestId'::uuid ORDER BY ps.subsystem" | Out-String).Trim()
throw "主体删除对账没有完成。实际值:$($detail.data.status);子任务:$subtaskEvidence"
}
Assert-Equal $detail.data.subtasks.Count 6 '主体删除子任务数量不正确'
if(@($detail.data.subtasks|Where-Object{$_.status-notin@('COMPLETED','SKIPPED')}).Count-ne 0){throw '存在未收敛的删除子任务。'}
Assert-Equal $detail.data.result_summary.content_included $false '删除结果不应包含个人正文'
Assert-Equal (Invoke-Scalar "SELECT status||'|'||preferred_name||'|'||(profile_data='{}'::jsonb)||'|'||(conversation_preferences='{}'::jsonb) FROM subject_profile WHERE public_id='$subjectId'::uuid") 'deleted|[已删除]|true|true' '主体档案未完成脱敏'
Assert-Equal (Invoke-Scalar "SELECT content||'|'||(source_ref IS NULL) FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id='$subjectId'::uuid)") '[已按隐私请求删除]|true' '主体事实未完成脱敏'
Assert-Equal (Invoke-Scalar "SELECT status||'|'||(object_ref IS NULL) FROM export_task WHERE public_id='$exportId'::uuid") 'EXPIRED|true' '私有导出任务未失效'
Assert-Equal (Invoke-Scalar "SELECT count(*) FROM tombstone WHERE deletion_request_id=(SELECT id FROM privacy_request WHERE public_id='$privacyRequestId'::uuid)") 1 '没有生成最小删除证明'
Assert-Equal (Invoke-Scalar "SELECT count(*) FROM privacy_request WHERE public_id='$privacyRequestId'::uuid AND scope::text LIKE '%$sentinel%'") 0 '删除原因正文被持久化'
docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password | Out-Null
$nativePreference=$PSNativeCommandUseErrorActionPreference;$PSNativeCommandUseErrorActionPreference=$false
docker compose exec -T minio mc stat "smoke/private-exports/$objectRef" 2>$null | Out-Null
$statExit=$LASTEXITCODE;$PSNativeCommandUseErrorActionPreference=$nativePreference
if($statExit-eq 0){throw '主体删除完成后私有导出对象仍存在。'}
Write-Host "主体隐私删除端到端冒烟通过:$privacyRequestId"
Write-Host '立即冻结、活动通话保护、权限/确认、幂等、六子系统对账、正文脱敏、导出对象删除和最小墓碑均已验证。'
} finally {
if($objectRef){
$nativePreference=$PSNativeCommandUseErrorActionPreference;$PSNativeCommandUseErrorActionPreference=$false
docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password 2>$null | Out-Null
docker compose exec -T minio mc rm --force "smoke/private-exports/$objectRef" 2>$null | Out-Null
$PSNativeCommandUseErrorActionPreference=$nativePreference
}
Remove-SmokeData
if($subjectId){Write-Host "已清理主体删除冒烟数据:$subjectId"}
}
+94
View File
@@ -0,0 +1,94 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$subjectId = '10000000-0000-0000-0000-000000000411'
$privacyRequestId = $null
function Get-LocalAccessToken([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 Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) {
if ([string]$Actual -ne [string]$Expected) {
throw "$Message。实际值:$Actual;预期值:$Expected"
}
}
function Invoke-ExpectedStatus([scriptblock]$Action, [int]$ExpectedStatus) {
try {
$response = & $Action
Assert-Equal $response.StatusCode $ExpectedStatus 'HTTP状态不正确'
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne $ExpectedStatus) { throw }
}
}
function Remove-PrivacyRequest([string]$PublicId) {
if ($PublicId -notmatch '^[0-9a-fA-F-]{36}$') { throw '拒绝清理格式不正确的隐私请求编号。' }
$sql = @"
BEGIN;
CREATE TEMP TABLE smoke_privacy_cleanup AS SELECT id,public_id FROM privacy_request WHERE public_id='$PublicId'::uuid;
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_privacy_cleanup);
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_privacy_cleanup);
DELETE FROM privacy_request WHERE id IN (SELECT id FROM smoke_privacy_cleanup);
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null
if ($LASTEXITCODE -ne 0) { throw '隐私请求冒烟数据清理失败。' }
}
try {
$health = Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/health/ready"
Assert-Equal $health.StatusCode 200 'API就绪检查失败'
$ownerToken = Get-LocalAccessToken 'owner'
$editorToken = Get-LocalAccessToken 'editor'
$key = 'smoke-privacy-' + [Guid]::NewGuid().ToString('N')
$body = @{ request_type = 'ACCESS'; scope = @{ categories = @('profile', 'conversations', 'memories') } } | ConvertTo-Json -Compress -Depth 4
$headers = @{ Authorization = "Bearer $ownerToken"; 'Idempotency-Key' = $key }
$created = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/privacy-requests" -Headers $headers -ContentType 'application/json' -Body $body
Assert-Equal $created.StatusCode 202 '隐私访问请求未进入后台队列'
$privacyRequestId = [string](($created.Content | ConvertFrom-Json).data.id)
if ($privacyRequestId -notmatch '^[0-9a-fA-F-]{36}$') { throw '隐私请求编号不正确。' }
$replay = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/privacy-requests" -Headers $headers -ContentType 'application/json' -Body $body
Assert-Equal $replay.StatusCode 200 '幂等重放没有返回原请求'
Assert-Equal $replay.Headers['Idempotency-Replayed'][0] 'true' '幂等重放缺少响应标记'
Assert-Equal (($replay.Content | ConvertFrom-Json).data.id) $privacyRequestId '幂等重放创建了第二个请求'
$conflictingBody = @{ request_type = 'ACCESS'; scope = @{ categories = @('profile') } } | ConvertTo-Json -Compress -Depth 4
Invoke-ExpectedStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/privacy-requests" -Headers $headers -ContentType 'application/json' -Body $conflictingBody } 409
Invoke-ExpectedStatus { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/privacy-requests" -Headers @{ Authorization = "Bearer $editorToken"; 'Idempotency-Key' = ('editor-denied-' + [Guid]::NewGuid().ToString('N')) } -ContentType 'application/json' -Body $body } 403
$deadline = [DateTime]::UtcNow.AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$detailResponse = Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/api/v1/privacy-requests/$privacyRequestId" -Headers @{ Authorization = "Bearer $ownerToken" }
$detail = ($detailResponse.Content | ConvertFrom-Json).data
} while ($detail.status -notin @('COMPLETED', 'PARTIAL_FAILED') -and [DateTime]::UtcNow -lt $deadline)
Assert-Equal $detail.status 'COMPLETED' '隐私数据盘点没有完成'
Assert-Equal $detail.subtasks.Count 1 '隐私请求子任务数量不正确'
Assert-Equal $detail.subtasks[0].status 'COMPLETED' '隐私数据库盘点子任务未完成'
Assert-Equal $detail.result_summary.content_included $false '隐私盘点结果不应包含正文'
Assert-Equal @($detail.result_summary.inventory_counts.PSObject.Properties).Count 3 '隐私盘点没有按请求范围过滤'
if ($detail.result_summary.inventory_counts.profile -lt 1) { throw '隐私盘点没有返回长者档案记录。' }
$list = (Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/api/v1/subjects/$subjectId/privacy-requests" -Headers @{ Authorization = "Bearer $ownerToken" }).Content | ConvertFrom-Json
if (-not ($list.data.items | Where-Object id -eq $privacyRequestId)) { throw '隐私请求列表没有返回刚创建的请求。' }
Write-Host "隐私请求端到端冒烟通过:$privacyRequestId"
Write-Host '权限拒绝、幂等重放/冲突、后台子任务、范围过滤和无正文盘点验证通过。'
} finally {
if ($privacyRequestId) {
Remove-PrivacyRequest $privacyRequestId
Write-Host "已清理本次隐私请求冒烟数据:$privacyRequestId"
}
}
+100
View File
@@ -0,0 +1,100 @@
$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'
$tenant = '10000000-0000-0000-0000-000000000010'
$candidate = '0.54.0-rc.local'
$key = 'release-evidence-smoke-' + [Guid]::NewGuid().ToString('N')
$idempotencyKeys = @($key)
$requestID = 'release-evidence-smoke-' + [Guid]::NewGuid().ToString('N')
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('voice-release-evidence-' + [Guid]::NewGuid().ToString('N'))
$snapshotPath = Join-Path $temporaryRoot 'acceptance.json'
$indexPath = Join-Path $temporaryRoot 'evidence-index.json'
$bundlePath = Join-Path $temporaryRoot 'candidate-evidence.zip'
$expandedPath = Join-Path $temporaryRoot 'expanded'
$weekdayOffset = (([int](Get-Date).DayOfWeek) + 6) % 7
$completedWeekStart = (Get-Date).Date.AddDays(-$weekdayOffset - 7)
function Write-JsonUtf8([string]$path, $value, [int]$depth = 20) {
$json = $value | ConvertTo-Json -Depth $depth
[System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
}
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$token = (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
$buildDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($buildDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API候选镜像摘要' }
$body = @{
candidate_version = $candidate; build_digest = $buildDigest; environment = 'LAB'; run_type = 'PILOT'
scope = '本地PILOT候选证据包纵向验证'; required_dependencies = @('oidc_jwks')
reason = '验证技术验收快照与阶段8外部证据索引可形成带摘要的候选交付包'
} | ConvertTo-Json -Compress
$response = Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/diagnostics/acceptance-snapshots" -Headers @{
Authorization = "Bearer $token"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $key; 'X-Request-Id' = $requestID
} -ContentType 'application/json' -Body $body
if (-not $response.data.overall_gate_satisfied) { throw '当前技术验收快照门禁未通过,不能生成候选证据包' }
Write-JsonUtf8 $snapshotPath $response.data
$dailyPaths = @()
for ($day = 0; $day -lt 7; $day++) {
$localDate = $completedWeekStart.AddDays($day).ToString('yyyy-MM-dd')
$dailyKey = 'release-evidence-daily-' + [Guid]::NewGuid().ToString('N')
$idempotencyKeys += $dailyKey
$dailyBody = @{candidate_version = $candidate; build_digest = $buildDigest; local_date = $localDate; timezone = 'Asia/Shanghai'; reason = '生成已结束试点自然周的候选证据包日报'} | ConvertTo-Json -Compress
$dailyResponse = Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-daily-snapshots" -Headers @{
Authorization = "Bearer $token"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $dailyKey; 'X-Request-Id' = $requestID
} -ContentType 'application/json' -Body $dailyBody
$dailyPath = Join-Path $temporaryRoot "pilot-daily-$localDate.json"
Write-JsonUtf8 $dailyPath $dailyResponse.data
$dailyPaths += $dailyPath
}
$weeklyKey = 'release-evidence-weekly-' + [Guid]::NewGuid().ToString('N')
$idempotencyKeys += $weeklyKey
$weekStartText = $completedWeekStart.ToString('yyyy-MM-dd')
$weeklyBody = @{candidate_version = $candidate; build_digest = $buildDigest; week_start = $weekStartText; timezone = 'Asia/Shanghai'; reason = '生成已结束试点自然周的候选证据包周报'} | ConvertTo-Json -Compress
$weeklyResponse = Invoke-RestMethod -Method Post -Uri "$api/api/v1/ops/diagnostics/pilot-weekly-snapshots" -Headers @{
Authorization = "Bearer $token"; 'X-Tenant-Id' = $tenant; 'Idempotency-Key' = $weeklyKey; 'X-Request-Id' = $requestID
} -ContentType 'application/json' -Body $weeklyBody
if (-not $weeklyResponse.data.is_complete) { throw '已结束自然周不应生成周中快照' }
$weeklyPath = Join-Path $temporaryRoot "pilot-weekly-$weekStartText.json"
Write-JsonUtf8 $weeklyPath $weeklyResponse.data
$codes = @('REMOTE_CI_SUPPLY_CHAIN','REAL_DEVICE_RTC','REAL_DEVICE_FIRMWARE','REAL_AGENT_MODELS','PRODUCTION_INFRASTRUCTURE','PRODUCTION_RECOVERY','KMS_ROTATION','REAL_ALERT_RECEIVER','SAFARI_COMPATIBILITY','BUSINESS_APPROVAL')
$index = @{
schema_version = 'release-evidence-index-v2'; candidate_version = $candidate; build_digest = $buildDigest
items = @($codes | ForEach-Object { @{code = $_; status = 'PENDING'; owner = '待外部责任人确认'; note = '本地证据包保留此外部验收缺口,不以模拟结果代替'; files = @()} })
}
Write-JsonUtf8 $indexPath $index 8
$bundleArguments = @('run','./cmd/release-evidence','-root','.', '-candidate-version',$candidate,'-build-digest',$buildDigest,'-source-commit','local-uncommitted','-acceptance-snapshot',$snapshotPath,'-evidence-index',$indexPath,'-pilot-weekly-snapshot',$weeklyPath,'-out',$bundlePath)
foreach ($dailyPath in $dailyPaths) { $bundleArguments += @('-pilot-daily-snapshot',$dailyPath) }
$resultLine = & go @bundleArguments
$result = $resultLine | ConvertFrom-Json
if ($result.status -ne 'EXTERNAL_VALIDATION_PENDING' -or $result.pending -ne 10 -or $result.verified -ne 0 -or $result.sha256 -notmatch '^sha256:[a-f0-9]{64}$') { throw '证据包生成结果不正确' }
Expand-Archive -LiteralPath $bundlePath -DestinationPath $expandedPath
$manifest = Get-Content -Raw -LiteralPath (Join-Path $expandedPath 'release-manifest.json') | ConvertFrom-Json
if (-not $manifest.technical_gate_satisfied -or $manifest.evidence_status -ne 'EXTERNAL_VALIDATION_PENDING' -or $manifest.pending_count -ne 10 -or $manifest.openapi_version -ne '0.64.0' -or $manifest.schema_version -ne 'release-candidate-evidence-bundle-v3' -or -not $manifest.pilot_evidence_included -or $manifest.pilot_daily_count -ne 7 -or $manifest.pilot_week_start -ne $weekStartText -or $manifest.pilot_timezone -ne 'Asia/Shanghai') { throw '证据包清单内容不正确' }
if ($manifest.firmware_validation.status -ne 'PENDING' -or $manifest.firmware_validation.coverage_complete -or $manifest.firmware_validation.required_device_count -ne 3 -or @($manifest.firmware_validation.required_scenarios).Count -ne 4 -or @($manifest.firmware_validation.gaps).Count -ne 5) { throw '真机固件场景缺口未在证据包中保留' }
if ($manifest.alert_receiver_validation.status -ne 'PENDING' -or $manifest.alert_receiver_validation.coverage_complete -or $manifest.alert_receiver_validation.report_present -or @($manifest.alert_receiver_validation.gaps).Count -ne 2) { throw '真实告警接收器与正式演练缺口未在证据包中保留' }
if ($manifest.kms_rotation_validation.status -ne 'PENDING' -or $manifest.kms_rotation_validation.coverage_complete -or $manifest.kms_rotation_validation.report_present -or @($manifest.kms_rotation_validation.gaps).Count -ne 3) { throw '真实KMS/HSM两类轮换缺口未在证据包中保留' }
if ($manifest.production_recovery_validation.status -ne 'PENDING' -or $manifest.production_recovery_validation.coverage_complete -or $manifest.production_recovery_validation.report_present -or @($manifest.production_recovery_validation.gaps).Count -ne 5) { throw '生产等价四类恢复演练缺口未在证据包中保留' }
if ($manifest.production_infrastructure_validation.status -ne 'PENDING' -or $manifest.production_infrastructure_validation.coverage_complete -or $manifest.production_infrastructure_validation.report_present -or @($manifest.production_infrastructure_validation.gaps).Count -ne 8) { throw '生产基础设施七类组件缺口未在证据包中保留' }
if ($manifest.safari_compatibility_validation.status -ne 'PENDING' -or $manifest.safari_compatibility_validation.coverage_complete -or $manifest.safari_compatibility_validation.report_present -or @($manifest.safari_compatibility_validation.gaps).Count -ne 3) { throw '最近两个真实Safari版本缺口未在证据包中保留' }
if ($manifest.real_device_rtc_validation.status -ne 'PENDING' -or $manifest.real_device_rtc_validation.coverage_complete -or $manifest.real_device_rtc_validation.report_present -or @($manifest.real_device_rtc_validation.gaps).Count -ne 10) { throw '真实终端、三模式和四类RTC场景缺口未在证据包中保留' }
if ($manifest.real_agent_models_validation.status -ne 'PENDING' -or $manifest.real_agent_models_validation.coverage_complete -or $manifest.real_agent_models_validation.report_present -or @($manifest.real_agent_models_validation.gaps).Count -ne 16) { throw '真实生成、Embedding和内容安全质量缺口未在证据包中保留' }
if ($manifest.remote_ci_supply_chain_validation.status -ne 'PENDING' -or $manifest.remote_ci_supply_chain_validation.coverage_complete -or $manifest.remote_ci_supply_chain_validation.report_present -or @($manifest.remote_ci_supply_chain_validation.gaps).Count -ne 20) { throw '远程CI、镜像摘要和SBOM缺口未在证据包中保留' }
foreach ($required in @('evidence-index.json','acceptance/technical-acceptance-snapshot.json','baseline/openapi/openapi.yaml','baseline/docs/external-acceptance-checklist.md',"pilot/weekly/$weekStartText.json",("pilot/daily/" + $completedWeekStart.AddDays(6).ToString('yyyy-MM-dd') + '.json'))) {
if (-not (Test-Path -LiteralPath (Join-Path $expandedPath $required))) { throw "证据包缺少文件:$required" }
}
Write-Host "发布候选证据包烟测通过:文件=$($result.files),PILOT日报=7,周报=$weekStartText,外部待验=$($result.pending),远程CI、真机RTC/固件、Agent模型、告警、KMS、恢复、基础设施和Safari保持PENDING,包摘要=$($result.sha256)。"
} finally {
$quotedKeys = ($idempotencyKeys | ForEach-Object { "'$_'" }) -join ','
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c "BEGIN; DELETE FROM audit_event WHERE request_id='$requestID'; DELETE FROM idempotency_record WHERE idempotency_key IN ($quotedKeys); COMMIT;" | Out-Null
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '已清理证据包烟测的临时文件、审计和幂等记录。'
}
@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('rtc-conformance-toolchain-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'capture.json'
$reportPath = Join-Path $temporaryRoot 'report.json'
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($imageDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API镜像摘要' }
$startedAt = (Get-Date).ToUniversalTime().AddMinutes(-2)
$endedAt = $startedAt.AddMinutes(1)
$capture = @{
schema_version = 'real-device-rtc-capture-v1'; evidence_source = 'PROTOCOL_SIMULATION'; candidate_version = '0.63.0-rc.local'; build_digest = $imageDigest; environment = 'LOCAL'
rtc_provider_ref = 'mock'; voice_provider_ref = 'mock'; rtc_application_ref = 'local-app'; region_ref = 'local'; change_ticket_ref = 'local-smoke'
executed_by_ref = 'local-toolchain'; attested_by_ref = 'local-toolchain'; attested_at = $endedAt.ToString('o'); collected_at = (Get-Date).ToUniversalTime().ToString('o')
devices = @(@{ device_id = 'sim-device'; hardware_model = 'simulator'; hardware_revision = 'local'; firmware_version = 'simulated'; identity_ref = 'local-device-secret'; audio_profile_ref = 'simulated-audio' })
sessions = @(@{
conversation_id = 'simulated-conversation'; device_id = 'sim-device'; engine_mode = 's2s_hybrid'; scenario = 'NORMAL_COMPLETION'; room_ref = 'local-room'; device_rtc_user_ref = 'local-device-user'; agent_rtc_user_ref = 'local-agent-user'; provider_task_ref = 'local-task'
started_at = $startedAt.ToString('o'); ended_at = $endedAt.ToString('o'); lifecycle = @('ROOM_ISSUED','STARTING_AGENT','ACTIVE','STOPPING','COMPLETED')
rtc_join_succeeded = $true; uplink_succeeded = $true; downlink_succeeded = $true; near_audio_published = $true; far_audio_played = $true; credential_renewed = $false; network_recovered = $false
provider_start_accepted = $true; provider_stop_confirmed = $true; final_transcript_received = $true; usage_recorded = $true; quality_result = 'PASS'; no_duplicate_provider_task = $true; resources_released = $true
cloud_evidence_ref = 'local-schema-smoke'; device_evidence_ref = 'local-schema-smoke'; provider_evidence_ref = 'local-schema-smoke'; quality_evidence_ref = 'local-schema-smoke'; usage_evidence_ref = 'local-schema-smoke'
})
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 12), [System.Text.UTF8Encoding]::new($false))
go run ./cmd/rtc-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest $imageDigest -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'PROTOCOL_SIMULATION' -or -not $report.external_validation_required -or $report.device_count -ne 1 -or $report.session_count -ne 1) { throw 'RTC协议工具链报告边界不正确' }
Write-Host 'RTC证据工具链冒烟通过:1台模拟终端1通结构结果;报告固定为PROTOCOL_SIMULATION,真实RTC门禁继续待验。'
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '已清理RTC工具链冒烟临时文件。'
}
+145
View File
@@ -0,0 +1,145 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBase = 'http://localhost:58080'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000010'
$householdId = $null
function Get-LocalAccessToken([string]$Username) {
$response = 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'
}
return $response.access_token
}
function Invoke-JSONRequest(
[string]$Method,
[string]$Uri,
[string]$Token,
[hashtable]$Headers = @{},
[object]$Body = $null
) {
$requestHeaders = @{ Authorization = "Bearer $Token" }
foreach ($key in $Headers.Keys) { $requestHeaders[$key] = $Headers[$key] }
$arguments = @{
Method = $Method
Uri = $Uri
Headers = $requestHeaders
}
if ($null -ne $Body) {
$arguments.ContentType = 'application/json; charset=utf-8'
$arguments.Body = $Body | ConvertTo-Json -Depth 10 -Compress
}
return Invoke-WebRequest @arguments
}
function Assert-Status([scriptblock]$Operation, [int]$ExpectedStatus) {
try {
& $Operation | Out-Null
throw "请求不应成功,预期HTTP $ExpectedStatus。"
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne $ExpectedStatus) { throw }
}
}
function Convert-Response($Response) {
return $Response.Content | ConvertFrom-Json
}
try {
$ownerToken = Get-LocalAccessToken -Username 'owner'
$editorToken = Get-LocalAccessToken -Username 'editor'
$outsiderToken = Get-LocalAccessToken -Username 'outsider'
$createdResponse = Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/households" `
-Token $ownerToken -Headers @{ 'X-Tenant-Id' = $tenantId } -Body @{ name = 'SaaS纵向烟测家庭' }
if ([int]$createdResponse.StatusCode -ne 201) { throw '创建家庭未返回201。' }
$created = Convert-Response $createdResponse
$householdId = $created.data.id
$householdETag = [string]$createdResponse.Headers.ETag
if ([string]::IsNullOrWhiteSpace($householdETag)) { throw '创建家庭未返回ETag。' }
Assert-Status { Invoke-JSONRequest -Method Get -Uri "$apiBase/api/v1/households/$householdId" -Token $outsiderToken } 403
Assert-Status { Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/households/$householdId" -Token $editorToken -Headers @{ 'If-Match' = $householdETag } -Body @{ name = '禁止修改' } } 403
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c `
"INSERT INTO household_member (tenant_id,household_id,user_id,role,status) SELECT h.tenant_id,h.id,ua.id,'editor','active' FROM household h JOIN user_account ua ON ua.tenant_id=h.tenant_id WHERE h.public_id='$householdId'::uuid AND ua.oidc_subject='00000000-0000-0000-0000-000000000102';" | Out-Null
$patchedResponse = Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/households/$householdId" `
-Token $ownerToken -Headers @{ 'If-Match' = $householdETag } -Body @{ name = 'SaaS纵向烟测家庭-已修改' }
if ([int]$patchedResponse.StatusCode -ne 200) { throw '修改家庭未返回200。' }
Assert-Status { Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/households/$householdId" -Token $ownerToken -Headers @{ 'If-Match' = $householdETag } -Body @{ name = '过期写入' } } 412
$subjectResponse = Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/households/$householdId/subjects" `
-Token $editorToken -Body @{
preferred_name = '烟测长者'
language = 'zh-CN'
timezone = 'Asia/Shanghai'
profile_data = @{ synthetic = $true }
conversation_preferences = @{ pace = 'slow' }
}
if ([int]$subjectResponse.StatusCode -ne 201) { throw '创建长者未返回201。' }
$subject = Convert-Response $subjectResponse
$subjectId = $subject.data.id
$subjectETag = [string]$subjectResponse.Headers.ETag
Assert-Status { Invoke-JSONRequest -Method Get -Uri "$apiBase/api/v1/subjects/$subjectId" -Token $outsiderToken } 403
$subjectPatched = Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/subjects/$subjectId" `
-Token $editorToken -Headers @{ 'If-Match' = $subjectETag } -Body @{ preferred_name = '烟测长者-已修改' }
if ([int]$subjectPatched.StatusCode -ne 200) { throw '修改长者未返回200。' }
$factResponse = Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/facts" -Token $editorToken -Body @{
fact_type = 'hometown'
content = '合成城市'
source_type = 'model_candidate'
source_ref = 'synthetic://conversation/turn/1'
confirmation_status = 'pending'
sensitivity = 'normal'
}
$fact = Convert-Response $factResponse
$factId = $fact.data.id
$factETag = [string]$factResponse.Headers.ETag
$confirmedFact = Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/subjects/$subjectId/facts/$factId" `
-Token $editorToken -Headers @{ 'If-Match' = $factETag } -Body @{ confirmation_status = 'confirmed' }
if ([int]$confirmedFact.StatusCode -ne 200) { throw '确认稳定事实未返回200。' }
Assert-Status { Invoke-JSONRequest -Method Patch -Uri "$apiBase/api/v1/subjects/$subjectId/facts/$factId" -Token $editorToken -Headers @{ 'If-Match' = $factETag } -Body @{ content = '过期写入' } } 412
$consentResponse = Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/consents" `
-Token $editorToken -Body @{
type = 'story_extraction'
scope = @{ scope = 'future_conversations' }
policy_version = 'local-v1'
notice_digest = ('c' * 64)
evidence_ref = 'synthetic://http-smoke'
}
if ([int]$consentResponse.StatusCode -ne 201) { throw '授予授权未返回201。' }
$consent = Convert-Response $consentResponse
$consentId = $consent.data.id
Assert-Status { Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/consents" -Token $editorToken -Body @{
type = 'story_extraction'; scope = @{}; policy_version = 'local-v1'; notice_digest = ('d' * 64)
} } 409
$withdrawn = Convert-Response (Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/consents/$consentId/withdraw" -Token $editorToken)
$withdrawnReplay = Convert-Response (Invoke-JSONRequest -Method Post -Uri "$apiBase/api/v1/subjects/$subjectId/consents/$consentId/withdraw" -Token $editorToken)
if ($null -eq $withdrawn.data.withdrawn_at -or $withdrawn.data.version -ne $withdrawnReplay.data.version) {
throw '授权撤回或重复撤回结果不正确。'
}
$snapshotCount = docker compose exec -T postgres psql -U voice -d voice_platform -Atc `
"SELECT count(*) FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id='$subjectId'::uuid);"
if ([int]$snapshotCount -ne 2) { throw "授权快照数量是$snapshotCount,预期为2。" }
Write-Host "SaaS纵向烟测通过:家庭 $householdId,长者 $subjectId,授权 $consentId。"
} finally {
if (-not [string]::IsNullOrWhiteSpace($householdId)) {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c `
"BEGIN; DELETE FROM audit_event WHERE resource_id='$householdId'::uuid OR resource_id IN (SELECT s.public_id FROM subject_profile s JOIN household h ON h.id=s.household_id WHERE h.public_id='$householdId'::uuid UNION SELECT c.public_id FROM consent_record c JOIN subject_profile s ON s.id=c.subject_id JOIN household h ON h.id=s.household_id WHERE h.public_id='$householdId'::uuid UNION SELECT f.public_id FROM subject_fact f JOIN subject_profile s ON s.id=f.subject_id JOIN household h ON h.id=s.household_id WHERE h.public_id='$householdId'::uuid); DELETE FROM consent_snapshot WHERE subject_id IN (SELECT id FROM subject_profile WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid)); DELETE FROM consent_record WHERE subject_id IN (SELECT id FROM subject_profile WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid)); DELETE FROM subject_fact WHERE subject_id IN (SELECT id FROM subject_profile WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid)); DELETE FROM subject_profile WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid); DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id='$householdId'::uuid); DELETE FROM household WHERE public_id='$householdId'::uuid; COMMIT;" | Out-Null
}
}
@@ -0,0 +1,38 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('safari-conformance-toolchain-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'capture.json'
$reportPath = Join-Path $temporaryRoot 'report.json'
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
if ($imageDigest -notmatch '^sha256:[a-f0-9]{64}$') { throw '无法取得API镜像摘要' }
$startedAt = (Get-Date).ToUniversalTime().AddMinutes(-2)
$endedAt = $startedAt.AddMinutes(1)
$workflows = @('AUTH_SESSION','FAMILY_WORKSPACE','OPERATIONS_WORKSPACES','REALTIME_NAVIGATION','MEDIA_FILE_CONTROLS','CONTROLLED_ACTIONS')
$viewports = @('DESKTOP_1440X1000','TABLET_1024X900')
$results = @()
foreach ($workflow in $workflows) {
foreach ($viewport in $viewports) {
$results += @{ workflow = $workflow; viewport = $viewport; status = 'PASS'; console_error_count = 0; unexpected_failed_request_count = 0; horizontal_overflow_count = 0; evidence_ref = "local-schema-smoke:${workflow}:${viewport}" }
}
}
$capture = @{
schema_version = 'safari-compatibility-capture-v1'; evidence_source = 'NON_SAFARI_TOOLCHAIN'; candidate_version = '0.63.0-rc.local'; build_digest = $imageDigest
environment = 'LOCAL'; release_reference_ref = 'not-applicable-local'; change_ticket_ref = 'local-smoke'; executed_by_ref = 'local-toolchain'; attested_by_ref = 'local-toolchain'
attested_at = $endedAt.ToString('o'); collected_at = (Get-Date).ToUniversalTime().ToString('o')
runs = @(@{ release_rank = 'CURRENT'; browser_name = 'NON_SAFARI'; browser_version = 'local-toolchain'; macos_version = 'not-applicable'; machine_ref = 'local-windows'; execution_mode = 'SCHEMA_SMOKE'; started_at = $startedAt.ToString('o'); ended_at = $endedAt.ToString('o'); results = $results })
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 12), [System.Text.UTF8Encoding]::new($false))
go run ./cmd/safari-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest $imageDigest -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'NON_SAFARI_TOOLCHAIN' -or -not $report.external_validation_required -or $report.result_count -ne 12) { throw '非Safari工具链报告边界不正确' }
Write-Host 'Safari证据工具链冒烟通过:结构化12项本地结果;报告固定为NON_SAFARI_TOOLCHAIN,真实Safari门禁继续待验。'
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '已清理Safari工具链冒烟临时文件。'
}
+113
View File
@@ -0,0 +1,113 @@
$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'
$tenantId='10000000-0000-0000-0000-000000000001'
$subjectPublicId='10000000-0000-0000-0000-000000000411'
$editorPublicId='10000000-0000-0000-0000-000000000112'
$outsiderPublicId='20000000-0000-0000-0000-000000000113'
$conversationPublicId=[Guid]::NewGuid().ToString()
$contextId=[Guid]::NewGuid().ToString()
$storyPublicId=[Guid]::NewGuid().ToString()
$providerRef='sharing-smoke-'+[Guid]::NewGuid().ToString('N')
$key1='sharing-smoke-'+[Guid]::NewGuid().ToString('N')
$key2='sharing-smoke-'+[Guid]::NewGuid().ToString('N')
$key3='sharing-smoke-'+[Guid]::NewGuid().ToString('N')
$key4='sharing-smoke-'+[Guid]::NewGuid().ToString('N')
$grantIds=@()
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{if($_.Exception.Response.StatusCode.value__-ne$status){throw}}}
try{
$setup=@"
INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
VALUES('$contextId'::uuid,'$tenantId'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000702'::uuid,'[]'::jsonb,'sharing-smoke','s2s_hybrid',repeat('0',64),'合成分享烟测');
WITH inserted AS (
INSERT INTO conversation(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('$conversationPublicId'::uuid,'$tenantId'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000702'::uuid,'$contextId'::uuid,'$providerRef','s2s_hybrid','transcript_only','COMPLETED',now()+interval '1 minute',now()-interval '2 minutes',now()) RETURNING id
)
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
SELECT '$tenantId'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,id,'sharing-turn-1',1,'subject',now()-interval '2 minutes',now()-interval '119 seconds','这是仅用于分享权限验证的合成文字。','provider' FROM inserted;
INSERT INTO story_draft(public_id,tenant_id,subject_id,conversation_id,title,status,confidence,sensitivity,generation_version)
SELECT '$storyPublicId'::uuid,'$tenantId'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,id,'合成分享故事','IN_REVIEW',0.9,'normal',1 FROM conversation WHERE public_id='$conversationPublicId'::uuid;
WITH revision AS (
INSERT INTO story_revision(story_id,revision_no,content,author_type,model_ref,prompt_version)
SELECT id,1,'这是仅用于分享权限验证的合成故事正文。','model','sharing-smoke','v1' FROM story_draft WHERE public_id='$storyPublicId'::uuid RETURNING id,story_id
)
UPDATE story_draft sd SET current_revision_id=revision.id,version=version+1,updated_at=now() FROM revision WHERE sd.id=revision.story_id;
"@
$setup|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null
$owner=Token 'owner';$editor=Token 'editor';$outsider=Token 'outsider'
$body=@{resource_type='STORY';resource_id=$storyPublicId;grantee_user_id=$editorPublicId;permission='VIEW_TEXT';purpose='家庭定向分享烟测';expires_at=[DateTime]::UtcNow.AddMinutes(10).ToString('o');login_required=$true}|ConvertTo-Json -Compress
$created=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key1} -ContentType 'application/json' -Body $body
if($created.StatusCode-ne201){throw 'Sharing grant was not created'}
$grantId=[string](($created.Content|ConvertFrom-Json).data.id);$grantIds+=$grantId
$replay=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key1} -ContentType 'application/json' -Body $body
if($replay.StatusCode-ne200-or[string]$replay.Headers.'Idempotency-Replayed'-ne'true'){throw 'Sharing grant idempotency replay failed'}
$shared=(Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants/$grantId/resource" -Headers @{Authorization="Bearer $editor"}).Content|ConvertFrom-Json
if($shared.data.text-ne'这是仅用于分享权限验证的合成故事正文。'-or$shared.data.permission-ne'VIEW_TEXT'){throw 'Recipient did not receive the shared story'}
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants/$grantId/resource" -Headers @{Authorization="Bearer $owner"}} 403
$listed=(Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants?subject_id=$subjectPublicId" -Headers @{Authorization="Bearer $owner"}).Content|ConvertFrom-Json
if(@($listed.data.items|Where-Object id -eq $grantId).Count-ne1){throw 'Grant was missing from owner history'}
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants?subject_id=$subjectPublicId" -Headers @{Authorization="Bearer $editor"}} 403
$badBody=@{resource_type='STORY';resource_id=$storyPublicId;grantee_user_id=$editorPublicId;permission='LISTEN';purpose='invalid';expires_at=[DateTime]::UtcNow.AddMinutes(10).ToString('o');login_required=$true}|ConvertTo-Json -Compress
Expected {Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=('sharing-invalid-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $badBody} 400
$crossBody=@{resource_type='STORY';resource_id=$storyPublicId;grantee_user_id=$outsiderPublicId;permission='VIEW_TEXT';purpose='cross tenant';expires_at=[DateTime]::UtcNow.AddMinutes(10).ToString('o');login_required=$true}|ConvertTo-Json -Compress
Expected {Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=('sharing-cross-'+[Guid]::NewGuid().ToString('N'))} -ContentType 'application/json' -Body $crossBody} 403
Invoke-WebRequest -UseBasicParsing -Method Delete -Uri "$api/api/v1/sharing-grants/$grantId" -Headers @{Authorization="Bearer $owner"}|Out-Null
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants/$grantId/resource" -Headers @{Authorization="Bearer $editor"}} 410
$second=(Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key2} -ContentType 'application/json' -Body $body).Content|ConvertFrom-Json
$expiredId=[string]$second.data.id;$grantIds+=$expiredId
"UPDATE sharing_grant SET granted_at=now()-interval '10 minutes',expires_at=now()-interval '1 minute' WHERE public_id='$expiredId'::uuid;"|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants/$expiredId/resource" -Headers @{Authorization="Bearer $editor"}} 410
$editBody=@{resource_type='STORY';resource_id=$storyPublicId;grantee_user_id=$editorPublicId;permission='EDIT';purpose='共同修订故事';expires_at=[DateTime]::UtcNow.AddMinutes(10).ToString('o');login_required=$true}|ConvertTo-Json -Compress
$editGrant=(Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key3} -ContentType 'application/json' -Body $editBody).Content|ConvertFrom-Json
$editGrantId=[string]$editGrant.data.id;$grantIds+=$editGrantId
$before=(Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/sharing-grants/$editGrantId/resource" -Headers @{Authorization="Bearer $editor"}).Content|ConvertFrom-Json
$editPayload=@{content='接收人通过共享编辑形成的新故事修订。';reason='修正合成故事表述'}|ConvertTo-Json -Compress
$edited=Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/sharing-grants/$editGrantId/resource" -Headers @{Authorization="Bearer $editor";'If-Match'=('"v'+$before.data.version+'"')} -ContentType 'application/json' -Body $editPayload
$editedData=($edited.Content|ConvertFrom-Json).data
if($editedData.text-ne'接收人通过共享编辑形成的新故事修订。'-or$editedData.revision_no-ne($before.data.revision_no+1)){throw 'Shared story edit did not create a new revision'}
Expected {Invoke-WebRequest -UseBasicParsing -Method Patch -Uri "$api/api/v1/sharing-grants/$editGrantId/resource" -Headers @{Authorization="Bearer $editor";'If-Match'=('"v'+$before.data.version+'"')} -ContentType 'application/json' -Body $editPayload} 412
$anonymousBody=@{resource_type='STORY';resource_id=$storyPublicId;grantee_user_id=$editorPublicId;permission='VIEW_TEXT';purpose='匿名口令分享';expires_at=[DateTime]::UtcNow.AddMinutes(10).ToString('o');login_required=$false}|ConvertTo-Json -Compress
$anonymousResponse=Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key4} -ContentType 'application/json' -Body $anonymousBody
$anonymous=($anonymousResponse.Content|ConvertFrom-Json).data;$anonymousId=[string]$anonymous.id;$shareToken=[string]$anonymous.access_token;$grantIds+=$anonymousId
if($shareToken.Length-lt32-or$anonymous.login_required-ne$false){throw 'Anonymous share token was not returned'}
$anonymousReplay=(Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/sharing-grants" -Headers @{Authorization="Bearer $owner";'Idempotency-Key'=$key4} -ContentType 'application/json' -Body $anonymousBody).Content|ConvertFrom-Json
if([string]$anonymousReplay.data.access_token-ne$shareToken){throw 'Anonymous idempotency replay did not derive the same token'}
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/public/shares/$anonymousId" -Headers @{'X-Share-Token'=('x'*43)}} 403
$publicShared=(Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/public/shares/$anonymousId" -Headers @{'X-Share-Token'=$shareToken}).Content|ConvertFrom-Json
if($publicShared.data.text-ne'接收人通过共享编辑形成的新故事修订。'){throw 'Anonymous token did not return the current story'}
$storedDigest=(docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT access_token_digest FROM sharing_grant WHERE public_id='$anonymousId'::uuid"|Out-String).Trim()
if($storedDigest.Length-ne64-or$storedDigest-eq$shareToken){throw 'Raw anonymous token was stored in PostgreSQL'}
Invoke-WebRequest -UseBasicParsing -Method Delete -Uri "$api/api/v1/sharing-grants/$anonymousId" -Headers @{Authorization="Bearer $owner"}|Out-Null
Expected {Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/public/shares/$anonymousId" -Headers @{'X-Share-Token'=$shareToken}} 410
$counts=(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 sharing_access_log WHERE sharing_grant_id IN(SELECT id FROM sharing_grant WHERE public_id IN('$grantId'::uuid,'$expiredId'::uuid))"|Out-String).Trim()
if($counts-ne'1|3'){throw "Unexpected sharing audit counts: $counts"}
Write-Host "Resource sharing smoke passed: authenticated and anonymous access, deterministic token replay, story edit concurrency, isolation, revoke, expiry and access audit: $grantId"
}
finally{
$ids=($grantIds|Where-Object{$_-match'^[0-9a-fA-F-]{36}$'}|ForEach-Object{"'$_'::uuid"})-join','
if(!$ids){$ids="'00000000-0000-0000-0000-000000000000'::uuid"}
$cleanup=@"
BEGIN;
DELETE FROM idempotency_record WHERE scope='sharing-grant.create' AND (resource_id IN($ids) OR idempotency_key IN('$key1','$key2','$key3','$key4'));
DELETE FROM audit_event WHERE resource_id IN($ids) OR resource_id='$storyPublicId'::uuid;
DELETE FROM sharing_grant WHERE public_id IN($ids);
DELETE FROM web_event WHERE resource_public_id IN('$storyPublicId'::uuid,'$conversationPublicId'::uuid);
UPDATE story_draft SET current_revision_id=NULL WHERE public_id='$storyPublicId'::uuid;
DELETE FROM story_revision WHERE story_id=(SELECT id FROM story_draft WHERE public_id='$storyPublicId'::uuid);
DELETE FROM story_draft WHERE public_id='$storyPublicId'::uuid;
DELETE FROM conversation_turn WHERE conversation_id=(SELECT id FROM conversation WHERE public_id='$conversationPublicId'::uuid);
DELETE FROM conversation WHERE public_id='$conversationPublicId'::uuid;
DELETE FROM context_snapshot WHERE id='$contextId'::uuid;
COMMIT;
"@
$cleanup|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q|Out-Null
}
+237
View File
@@ -0,0 +1,237 @@
param(
[ValidateSet('RecoverySuccess', 'ABRollback')]
[string]$Mode = 'RecoverySuccess'
)
$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'
$householdId = '10000000-0000-0000-0000-000000000211'
$deviceId = '10000000-0000-0000-0000-000000000511'
$keyId = 'smoke-sim-' + [Guid]::NewGuid().ToString('N')
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('firmware-simulator-' + [Guid]::NewGuid().ToString('N') + '.bin')
$stateFile = $tempFile + '.state.json'
$evidenceFile = $tempFile + '.device-evidence.json'
$cloudFile = $tempFile + '.cloud-rollout.json'
$conformanceFile = $tempFile + '.conformance-report.json'
$releaseId = $null
$rolloutId = $null
$objectRefs = @()
$environmentNames = @('SIM_API_URL', 'SIM_DEVICE_ID', 'SIM_DEVICE_SECRET', 'SIM_SCENARIO', 'SIM_FIRMWARE_FAULT', 'SIM_FIRMWARE_STEP_DELAY', 'SIM_FIRMWARE_TRUSTED_KEYS', 'SIM_FIRMWARE_AB_UPDATE', 'SIM_FIRMWARE_STATE_FILE', 'SIM_FIRMWARE_BOOT_HEALTH_WINDOW', 'SIM_FIRMWARE_ACTIVE_SLOT', 'SIM_FIRMWARE_CURRENT_VERSION', 'SIM_FIRMWARE_EVIDENCE_FILE', 'SIM_FIRMWARE_EVIDENCE_SCENARIO', 'SIM_CANDIDATE_VERSION', 'SIM_BUILD_DIGEST', 'SIM_BOOTLOADER_VERSION', 'SIM_PARTITION_TABLE_SHA256', 'SIM_SECURE_BOOT_ENABLED')
$previousEnvironment = @{}
foreach ($name in $environmentNames) {
$item = Get-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
$previousEnvironment[$name] = if ($null -eq $item) { $null } else { $item.Value }
}
function Get-AccessToken {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'
client_id = 'voice-platform-web'
username = 'owner'
password = 'LocalOnly123!'
scope = 'openid'
}).access_token
}
function Invoke-DatabaseScalar([string]$Sql) {
return (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -v ON_ERROR_STOP=1 -c $Sql | Out-String).Trim()
}
function Get-RolloutAssignment([hashtable]$Headers) {
$response = Invoke-RestMethod -Method Get -Uri "$api/api/v1/firmware-rollouts/$rolloutId" -Headers $Headers
return $response.data.assignments | Where-Object { $_.device_id -eq $deviceId } | Select-Object -First 1
}
try {
Invoke-RestMethod -Method Get -Uri "$api/health/ready" | Out-Null
$activeCommands = Invoke-DatabaseScalar "SELECT count(*) FROM device_command dc JOIN device d ON d.id=dc.device_id WHERE d.public_id='$deviceId'::uuid AND dc.status IN ('QUEUED','DELIVERED','ACKNOWLEDGED')"
if ([int]$activeCommands -ne 0) {
throw "Simulator smoke requires no pre-existing active command for $deviceId; found $activeCommands"
}
[IO.File]::WriteAllBytes($tempFile, [Text.Encoding]::UTF8.GetBytes('simulator-firmware-' + [Guid]::NewGuid().ToString('N')))
$headers = @{ Authorization = 'Bearer ' + (Get-AccessToken) }
$version = '9.7.' + (Get-Random -Minimum 10000 -Maximum 99999)
$signed = go run ./cmd/firmware-sign simulated-terminal sim-rev-a $version $tempFile | ConvertFrom-Json
$keyBody = @{ key_id = $keyId; public_key_base64 = $signed.public_key_base64 } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "$api/api/v1/households/$householdId/firmware-signing-keys" -Headers $headers -ContentType 'application/json' -Body $keyBody | Out-Null
$releaseBody = @{
model = 'simulated-terminal'
hardware_revision = 'sim-rev-a'
version = $version
expected_size = [int64]$signed.size
expected_digest = $signed.sha256
signature = $signed.signature_base64
signing_key_id = $keyId
minimum_security_version = '1.0.0'
release_notes = 'simulator vertical smoke fixture'
} | ConvertTo-Json -Compress
$prepared = Invoke-RestMethod -Method Post -Uri "$api/api/v1/households/$householdId/firmware-releases" -Headers $headers -ContentType 'application/json' -Body $releaseBody
$releaseId = $prepared.data.id
Invoke-WebRequest -UseBasicParsing -Method Put -Uri $prepared.data.upload_url -InFile $tempFile -ContentType 'application/octet-stream' | Out-Null
$ready = Invoke-RestMethod -Method Post -Uri "$api/api/v1/firmware-releases/$releaseId/finalize" -Headers $headers
if ($ready.data.status -ne 'READY') {
throw "Firmware release did not become READY: $($ready.data.status)"
}
$rolloutBody = @{
release_id = $releaseId
name = if ($Mode -eq 'ABRollback') { 'simulator A-B rollback smoke' } else { 'simulator interruption recovery smoke' }
target_percentage = 100
device_ids = @($deviceId)
} | ConvertTo-Json -Compress
$rollout = Invoke-RestMethod -Method Post -Uri "$api/api/v1/households/$householdId/firmware-rollouts" -Headers $headers -ContentType 'application/json' -Body $rolloutBody
$rolloutId = $rollout.data.id
$env:SIM_API_URL = $api
$env:SIM_DEVICE_ID = $deviceId
$env:SIM_DEVICE_SECRET = 'local-simulated-device-secret-32-bytes-minimum'
$env:SIM_SCENARIO = 'commands'
$env:SIM_FIRMWARE_STEP_DELAY = '0s'
$env:SIM_FIRMWARE_TRUSTED_KEYS = @{ $keyId = $signed.public_key_base64 } | ConvertTo-Json -Compress
if ($Mode -eq 'ABRollback') {
$env:SIM_FIRMWARE_AB_UPDATE = 'true'
$env:SIM_FIRMWARE_STATE_FILE = $stateFile
$env:SIM_FIRMWARE_BOOT_HEALTH_WINDOW = '30s'
$env:SIM_FIRMWARE_ACTIVE_SLOT = 'A'
$env:SIM_FIRMWARE_CURRENT_VERSION = '0.2.0-sim'
$env:SIM_FIRMWARE_EVIDENCE_FILE = $evidenceFile
$env:SIM_FIRMWARE_EVIDENCE_SCENARIO = 'AB_ROLLBACK'
$env:SIM_CANDIDATE_VERSION = 'local-ab-conformance-smoke'
$env:SIM_BUILD_DIGEST = 'sha256:' + $signed.sha256
$env:SIM_BOOTLOADER_VERSION = 'sim-bootloader-1'
$env:SIM_PARTITION_TABLE_SHA256 = $signed.sha256
$env:SIM_SECURE_BOOT_ENABLED = 'false'
$env:SIM_FIRMWARE_FAULT = 'none'
go run ./cmd/sim-device
$armedState = Invoke-DatabaseScalar "SELECT dc.status||'|'||fa.status||'|'||fa.progress_percent||'|'||COALESCE(dc.result_summary->>'result_code','') FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$rolloutId'::uuid"
if ($armedState -ne 'ACKNOWLEDGED|INSTALLING|90|REBOOT_ARMED') {
throw "A/B firmware update did not reach the reboot boundary: $armedState"
}
$localArmed = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json
if ($localArmed.phase -ne 'REBOOT_PENDING' -or $localArmed.active_slot -ne 'A' -or $localArmed.target_slot -ne 'B' -or -not $localArmed.boot_attempt_id) {
throw 'Local A/B journal did not durably arm the inactive slot'
}
$env:SIM_FIRMWARE_FAULT = 'boot_health_failed'
go run ./cmd/sim-device
$assignment = Get-RolloutAssignment $headers
if (-not $assignment -or $assignment.status -ne 'FAILED' -or [int]$assignment.progress_percent -ne 95 -or -not [bool]$assignment.last_result.rollback_applied -or $assignment.last_result.rollback_version -ne '0.2.0-sim' -or $assignment.events.Count -ne 7) {
throw 'Failed A/B boot did not expose an applied rollback to the prior version'
}
$eventPhases = ($assignment.events | ForEach-Object { $_.phase }) -join ','
if ($eventPhases -ne 'downloading,downloading,installing,staging,reboot_pending,boot_verifying,rolled_back') {
throw "Cloud firmware event timeline was incomplete or out of order: $eventPhases"
}
$terminalState = Invoke-DatabaseScalar "SELECT dc.status||'|'||fa.status||'|'||fa.progress_percent||'|'||COALESCE(dc.result_summary->>'result_code','')||'|'||dc.delivery_attempts FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$rolloutId'::uuid"
if ($terminalState -ne 'FAILED|FAILED|95|BOOT_HEALTH_FAILED_ROLLED_BACK|2') {
throw "A/B rollback did not converge to the expected terminal state: $terminalState"
}
$localRolledBack = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json
if ($localRolledBack.phase -ne 'ROLLED_BACK' -or $localRolledBack.active_slot -ne 'A' -or $localRolledBack.target_version -ne '0.2.0-sim') {
throw 'Local A/B journal did not return to the prior bootable slot'
}
$impact = Invoke-RestMethod -Method Get -Uri "$api/api/v1/firmware-releases/$releaseId/impact" -Headers $headers
if ([int]$impact.data.devices.failed -ne 1 -or [int]$impact.data.devices.observing -ne 0 -or [int]$impact.data.commands.failed -ne 1 -or [int]$impact.data.rollouts.paused -ne 1) {
throw 'Firmware impact summary did not converge after automatic A/B rollback'
}
$rolloutDetail = Invoke-RestMethod -Method Get -Uri "$api/api/v1/firmware-rollouts/$rolloutId" -Headers $headers
$rolloutDetail | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $cloudFile -Encoding utf8NoBOM
go run ./cmd/firmware-conformance -cloud $cloudFile -device $evidenceFile -output $conformanceFile
$conformance = Get-Content -LiteralPath $conformanceFile -Raw | ConvertFrom-Json
$failedChecks = @($conformance.checks | Where-Object { $_.status -ne 'PASS' })
if ($conformance.result -ne 'PASS' -or $conformance.evidence_scope -ne 'PROTOCOL_SIMULATION' -or -not [bool]$conformance.external_validation_required -or $failedChecks.Count -ne 0) {
throw 'Firmware conformance report did not pass or incorrectly claimed external validation'
}
Write-Host "Simulated A/B firmware boot failure rollback smoke passed: release $releaseId, rollout $rolloutId"
}
else {
$env:SIM_FIRMWARE_AB_UPDATE = 'false'
$env:SIM_FIRMWARE_FAULT = 'interrupt_after_download'
go run ./cmd/sim-device
$interruptedState = Invoke-DatabaseScalar "SELECT dc.status||'|'||fa.status||'|'||fa.progress_percent||'|'||COALESCE(dc.result_summary->>'result_code','') FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$rolloutId'::uuid"
if ($interruptedState -ne 'ACKNOWLEDGED|DOWNLOADING|100|ARTIFACT_VERIFIED') {
throw "Firmware interruption state was not recoverable: $interruptedState"
}
$env:SIM_FIRMWARE_FAULT = 'none'
go run ./cmd/sim-device
$assignment = Get-RolloutAssignment $headers
if (-not $assignment -or $assignment.status -ne 'CONFIRMED' -or [int]$assignment.progress_percent -ne 100) {
throw "Recovered firmware assignment did not converge to CONFIRMED/100: $($assignment.status)/$($assignment.progress_percent)"
}
$terminalState = Invoke-DatabaseScalar "SELECT dc.status||'|'||fa.status||'|'||fa.progress_percent||'|'||COALESCE(dc.result_summary->>'result_code','')||'|'||dc.delivery_attempts FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id JOIN firmware_rollout ro ON ro.id=fa.rollout_id WHERE ro.public_id='$rolloutId'::uuid"
if ($terminalState -ne 'SUCCEEDED|CONFIRMED|100|SIMULATED_OK|2') {
throw "Recovered firmware command did not reach the expected terminal state: $terminalState"
}
$impact = Invoke-RestMethod -Method Get -Uri "$api/api/v1/firmware-releases/$releaseId/impact" -Headers $headers
if ([int]$impact.data.devices.confirmed -ne 1 -or [int]$impact.data.devices.observing -ne 0 -or [int]$impact.data.commands.succeeded -ne 1 -or [int]$impact.data.rollouts.completed -ne 1) {
throw 'Firmware impact summary did not converge after simulator recovery'
}
Write-Host "Simulated firmware interruption recovery smoke passed: release $releaseId, rollout $rolloutId"
}
}
finally {
if ($releaseId) {
$refs = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT object_ref FROM firmware_release WHERE public_id='$releaseId'::uuid" | Out-String
$objectRefs = $refs -split "`r?`n" | Where-Object { $_.Trim() }
}
if ($releaseId) {
$cleanupSql = @"
BEGIN;
CREATE TEMP TABLE smoke_releases AS SELECT id,public_id FROM firmware_release WHERE public_id='$releaseId'::uuid;
CREATE TEMP TABLE smoke_rollouts AS SELECT id,public_id FROM firmware_rollout WHERE release_id IN(SELECT id FROM smoke_releases);
CREATE TEMP TABLE smoke_commands AS SELECT dc.id,dc.public_id FROM device_command dc JOIN firmware_assignment fa ON fa.command_id=dc.id WHERE fa.rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM smoke_releases UNION SELECT public_id FROM smoke_rollouts UNION SELECT public_id FROM smoke_commands);
DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM smoke_commands);
DELETE FROM firmware_assignment WHERE rollout_id IN(SELECT id FROM smoke_rollouts);
DELETE FROM firmware_rollout WHERE id IN(SELECT id FROM smoke_rollouts);
DELETE FROM device_command WHERE id IN(SELECT id FROM smoke_commands);
DELETE FROM firmware_release WHERE id IN(SELECT id FROM smoke_releases);
DELETE FROM audit_event WHERE resource_type='firmware_signing_key' AND detail->>'key_id'='$keyId';
DELETE FROM firmware_signing_key WHERE key_id='$keyId';
COMMIT;
"@
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $cleanupSql | Out-Null
}
else {
docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c "DELETE FROM audit_event WHERE resource_type='firmware_signing_key' AND detail->>'key_id'='$keyId'; DELETE FROM firmware_signing_key WHERE key_id='$keyId';" | 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
}
}
if (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
if (Test-Path -LiteralPath $stateFile) {
Remove-Item -LiteralPath $stateFile -Force
}
foreach ($artifact in @($evidenceFile, $cloudFile, $conformanceFile)) {
if (Test-Path -LiteralPath $artifact) {
Remove-Item -LiteralPath $artifact -Force
}
}
foreach ($name in $environmentNames) {
if ($null -eq $previousEnvironment[$name]) {
Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
}
else {
Set-Item -LiteralPath "Env:$name" -Value $previousEnvironment[$name]
}
}
}
+87
View File
@@ -0,0 +1,87 @@
$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'
$deviceId = '10000000-0000-0000-0000-000000000501'
$ownerId = '10000000-0000-0000-0000-000000000101'
$commandPublicId = [Guid]::NewGuid().ToString()
$idempotencyKey = 'event-smoke-' + [Guid]::NewGuid().ToString('N')
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 Get-EventStream([string]$Token, [long]$LastEventId) {
$previousPreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
try {
return (& curl.exe -sS -N --max-time 2 -H "Accept: text/event-stream" -H "Authorization: Bearer $Token" -H "Last-Event-ID: $LastEventId" "$api/api/v1/events" 2>$null | Out-String)
} finally {
$PSNativeCommandUseErrorActionPreference = $previousPreference
}
}
$ownerToken = Get-AccessToken 'owner'
$editorToken = Get-AccessToken 'editor'
$outsiderToken = Get-AccessToken 'outsider'
try {
$insertSql = @"
INSERT INTO device_command(public_id,tenant_id,device_id,command_type,payload,status,result_summary,requested_by,idempotency_key,request_digest,expires_at)
VALUES('$commandPublicId'::uuid,'$tenantId'::uuid,'$deviceId'::uuid,'diagnostics','{}'::jsonb,'QUEUED','{}'::jsonb,'$ownerId'::uuid,'$idempotencyKey',repeat('a',64),now()+interval '5 minutes');
UPDATE device_command SET status='SUCCEEDED',completed_at=now(),version=version+1,updated_at=now() WHERE public_id='$commandPublicId'::uuid;
"@
$insertSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
$rows = docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT id,event_type,status,resource_public_id FROM web_event WHERE resource_public_id='$commandPublicId'::uuid ORDER BY id" | Out-String
$events = @($rows.Trim() -split "`r?`n")
if ($events.Count -ne 2) { throw "Expected two command events, received $($events.Count): $rows" }
$first = $events[0] -split '\|'
$second = $events[1] -split '\|'
if ($first[1] -ne 'device.command.updated' -or $first[2] -ne 'QUEUED' -or $second[2] -ne 'SUCCEEDED') { throw "Trigger event sequence was unexpected: $rows" }
$ownerStream = Get-EventStream $ownerToken ([long]$first[0])
if ($ownerStream -notmatch "id: $($second[0])" -or $ownerStream -notmatch 'event: device.command.updated' -or $ownerStream -notmatch '"status":"SUCCEEDED"') { throw "Owner did not receive the missing event: $ownerStream" }
if ($ownerStream -match 'payload|result_summary|idempotency|requested_by') { throw "Sensitive command fields leaked into SSE: $ownerStream" }
$editorStream = Get-EventStream $editorToken ([long]$first[0])
if ($editorStream -notmatch "id: $($second[0])") { throw 'Active member did not receive the household event' }
$replayed = Get-EventStream $ownerToken ([long]$second[0])
if ($replayed -match 'data:') { throw "Reconnect replayed an already processed event: $replayed" }
try {
Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/events" -Headers @{ Authorization = "Bearer $outsiderToken"; 'Last-Event-ID' = $first[0] } | Out-Null
throw 'Cross-tenant event cursor was accepted'
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 410) { throw }
}
try {
Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/events" -Headers @{ Authorization = "Bearer $ownerToken"; 'Last-Event-ID' = 'not-a-number' } | Out-Null
throw 'Malformed event cursor was accepted'
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 400) { throw }
}
"UPDATE web_event SET expires_at=now()-interval '1 second' WHERE id=$($first[0]);" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
try {
Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/events" -Headers @{ Authorization = "Bearer $ownerToken"; 'Last-Event-ID' = $first[0] } | Out-Null
throw 'Expired event cursor was accepted'
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 410) { throw }
}
Write-Host "SSE transaction event, resume cursor, duplicate suppression and household isolation smoke passed: $commandPublicId"
}
finally {
$cleanupSql = @"
BEGIN;
DELETE FROM web_event WHERE resource_public_id='$commandPublicId'::uuid;
DELETE FROM device_command WHERE public_id='$commandPublicId'::uuid;
COMMIT;
"@
$cleanupSql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
}
+178
View File
@@ -0,0 +1,178 @@
[CmdletBinding()]
param(
[string]$EvidenceIndex = 'testdata/release-evidence-index.example.v2.json',
[string]$OutputPath = '',
[switch]$Strict
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$requiredCodes = @(
'REMOTE_CI_SUPPLY_CHAIN',
'REAL_DEVICE_RTC',
'REAL_DEVICE_FIRMWARE',
'REAL_AGENT_MODELS',
'PRODUCTION_INFRASTRUCTURE',
'PRODUCTION_RECOVERY',
'KMS_ROTATION',
'REAL_ALERT_RECEIVER',
'SAFARI_COMPATIBILITY',
'BUSINESS_APPROVAL'
)
function Invoke-GitText {
param([Parameter(Mandatory)][string[]]$Arguments)
$output = @(& git @Arguments 2>$null)
if ($LASTEXITCODE -ne 0) {
return @()
}
return @($output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })
}
function Test-PlaceholderDigest {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
return $Value -eq ('sha256:' + ('0' * 64))
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'Git is required for the stage 8 preflight.'
}
$resolvedIndex = (Resolve-Path -LiteralPath $EvidenceIndex).Path
$index = Get-Content -Raw -LiteralPath $resolvedIndex | ConvertFrom-Json
if ($index.schema_version -ne 'release-evidence-index-v2') {
throw "Unsupported evidence index schema: $($index.schema_version)"
}
$commitLines = @(Invoke-GitText -Arguments @('rev-parse', '--verify', 'HEAD'))
$commit = if ($commitLines.Count -gt 0) { $commitLines[0] } else { $null }
$branchLines = @(Invoke-GitText -Arguments @('branch', '--show-current'))
$branch = if ($branchLines.Count -gt 0) { $branchLines[0] } else { $null }
$remoteNames = @(Invoke-GitText -Arguments @('remote'))
$remotes = @()
foreach ($remoteName in $remoteNames) {
$remoteURLs = @(Invoke-GitText -Arguments @('remote', 'get-url', $remoteName))
$remotes += [ordered]@{
name = $remoteName
url_configured = $remoteURLs.Count -gt 0
}
}
$items = @($index.items)
$duplicateCodes = @($items | Group-Object code | Where-Object Count -gt 1 | ForEach-Object Name)
$actualCodes = @($items | ForEach-Object { $_.code })
$missingCodes = @($requiredCodes | Where-Object { $_ -notin $actualCodes })
$unexpectedCodes = @($actualCodes | Where-Object { $_ -notin $requiredCodes })
$validStatuses = @('PENDING', 'VERIFIED', 'WAIVED')
$invalidStatusCodes = @($items | Where-Object { $_.status -notin $validStatuses } | ForEach-Object code)
$gates = @($requiredCodes | ForEach-Object {
$code = $_
$item = @($items | Where-Object code -eq $code | Select-Object -First 1)
if ($item.Count -eq 0) {
[ordered]@{ code = $code; status = 'MISSING'; owner = ''; file_count = 0 }
} else {
[ordered]@{
code = $code
status = $item[0].status
owner = $item[0].owner
file_count = @($item[0].files).Count
}
}
})
$pendingCount = @($gates | Where-Object status -eq 'PENDING').Count
$verifiedCount = @($gates | Where-Object status -eq 'VERIFIED').Count
$waivedCount = @($gates | Where-Object status -eq 'WAIVED').Count
$requiredLocalServices = @('api', 'callbacks', 'worker', 'web', 'postgres', 'redis', 'minio', 'keycloak')
$runningServices = @()
$dockerAvailable = [bool](Get-Command docker -ErrorAction SilentlyContinue)
if ($dockerAvailable) {
$runningServices = @(& docker compose ps --services --status running 2>$null)
if ($LASTEXITCODE -ne 0) { $runningServices = @() }
}
$missingLocalServices = @($requiredLocalServices | Where-Object { $_ -notin $runningServices })
$externalConfigPrefixes = @(
'GITHUB_', 'CI_', 'KUBECONFIG', 'ARK_', 'VOLC_', 'CONTENT_SAFETY_',
'AWS_', 'AZURE_', 'GOOGLE_'
)
$externalConfigNames = @(Get-ChildItem Env: | Where-Object {
$name = $_.Name
@($externalConfigPrefixes | Where-Object { $name.StartsWith($_, [System.StringComparison]::OrdinalIgnoreCase) }).Count -gt 0
} | Sort-Object Name | ForEach-Object Name)
$indexValid = $duplicateCodes.Count -eq 0 -and $missingCodes.Count -eq 0 -and $unexpectedCodes.Count -eq 0 -and $invalidStatusCodes.Count -eq 0
$sourceControlReady = -not [string]::IsNullOrWhiteSpace($commit) -and $remotes.Count -gt 0
$entryReady = $sourceControlReady -and $indexValid -and -not (Test-PlaceholderDigest -Value $index.build_digest)
$blockers = @()
if ([string]::IsNullOrWhiteSpace($commit)) { $blockers += 'Git repository has no candidate commit.' }
if ($remotes.Count -eq 0) { $blockers += 'Git repository has no configured remote.' }
if (Test-PlaceholderDigest -Value $index.build_digest) { $blockers += 'Evidence index still uses a placeholder build digest.' }
if (-not $indexValid) { $blockers += 'Evidence index structure or statuses are invalid.' }
if ($pendingCount -gt 0) { $blockers += "$pendingCount external evidence gate(s) remain PENDING." }
$report = [ordered]@{
schema_version = 'stage8-external-preflight-v1'
generated_at = (Get-Date).ToUniversalTime().ToString('o')
repository = [ordered]@{
branch = $branch
commit = $commit
remotes = $remotes
source_control_ready = $sourceControlReady
}
local_runtime = [ordered]@{
docker_available = $dockerAvailable
required_services = $requiredLocalServices
running_service_count = $runningServices.Count
missing_required_services = $missingLocalServices
ready = $missingLocalServices.Count -eq 0
}
external_configuration = [ordered]@{
detected_variable_names = $externalConfigNames
secret_values_read = $false
note = 'Variable names are hints only and never count as acceptance evidence.'
}
evidence = [ordered]@{
index_path = $resolvedIndex
candidate_version = $index.candidate_version
build_digest = $index.build_digest
structure_valid = $indexValid
duplicate_codes = $duplicateCodes
missing_codes = $missingCodes
unexpected_codes = $unexpectedCodes
invalid_status_codes = $invalidStatusCodes
pending = $pendingCount
verified = $verifiedCount
waived = $waivedCount
gates = $gates
}
readiness = [ordered]@{
external_execution_entry_ready = $entryReady
authoritative_release_validation_required = $true
note = 'This preflight does not replace conformance reports or the release-evidence command.'
}
blockers = $blockers
}
$json = $report | ConvertTo-Json -Depth 12
if (-not [string]::IsNullOrWhiteSpace($OutputPath)) {
$parent = Split-Path -Parent $OutputPath
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent | Out-Null
}
[System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false))
}
Write-Host "Stage 8 preflight: source_control_ready=$sourceControlReady, local_runtime_ready=$($missingLocalServices.Count -eq 0), pending_evidence=$pendingCount, entry_ready=$entryReady"
foreach ($blocker in $blockers) { Write-Host "[BLOCKED] $blocker" }
if ($OutputPath) { Write-Host "Report written to $OutputPath" }
if ($Strict -and -not $entryReady) { exit 1 }
+98
View File
@@ -0,0 +1,98 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$apiBaseUrl = 'http://localhost:58080'
$deviceId = '10000000-0000-0000-0000-000000000511'
$oldSecret = 'local-simulated-device-secret-32-bytes-minimum'
$drillContainer = 'voice-key-rotation-drill'
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('kms-rotation-conformance-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'rotation-capture.json'
$reportPath = Join-Path $temporaryRoot 'rotation-report.json'
$newSecret = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLowerInvariant()
$originalImage = ((docker compose images -q api | Select-Object -First 1).Trim() -replace '^sha256:', '')
$rotationStartedAt = [DateTime]::UtcNow
function Wait-Ready([int]$Seconds = 60) {
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
do {
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "$apiBaseUrl/health/ready" -TimeoutSec 2
if ($response.StatusCode -eq 200) { return }
} catch {}
Start-Sleep -Milliseconds 500
} while ([DateTime]::UtcNow -lt $deadline)
throw 'API未在限定时间内恢复就绪。'
}
function Invoke-SignedConfig([string]$Secret) {
$path = '/device/v2/config'
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$nonce = [Guid]::NewGuid().ToString('N')
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([byte[]]::new(0))).ToLowerInvariant()
$canonical = "GET`n$path`n$timestamp`n$nonce`n$digest"
$hmac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($Secret))
try {
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
} finally {
$hmac.Dispose()
}
return Invoke-WebRequest -UseBasicParsing -Uri ($apiBaseUrl + $path) -Headers @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
}
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
Wait-Ready
if ((Invoke-SignedConfig $oldSecret).StatusCode -ne 200) { throw '轮换前基线签名验证失败。' }
docker compose stop api | Out-Null
$env:DEVICE_LOCAL_SECRET = $newSecret
docker compose run -d --name $drillContainer -p 58080:8080 -e DEVICE_LOCAL_SECRET api | Out-Null
Wait-Ready
if ((Invoke-SignedConfig $newSecret).StatusCode -ne 200) { throw '新密钥注入后签名验证失败。' }
try {
Invoke-SignedConfig $oldSecret | Out-Null
throw '旧密钥在轮换后不应继续通过。'
} catch {
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 401) { throw }
}
$drillImage = ((docker inspect --format '{{.Image}}' $drillContainer).Trim() -replace '^sha256:', '')
if ($drillImage -ne $originalImage) { throw '轮换演练意外改变了容器镜像。' }
$now = [DateTime]::UtcNow
$capture = @{
schema_version = 'kms-rotation-capture-v1'; evidence_source = 'LOCAL'; candidate_version = '0.63.0-rc.local'
build_digest = ('sha256:' + $originalImage); environment = 'LOCAL'; provider_ref = 'docker-compose:runtime-environment'
workload_identity_ref = ''; provider_audit_ref = ''; attested_by_ref = 'local-smoke:automated'
attested_at = $now.ToString('o'); collected_at = $now.ToString('o')
rotations = @(@{
secret_ref = 'device-local-signing-secret'; consumer_ref = 'api'; consumption_mode = 'ROLLING_RESTART'
old_version_ref = 'local-version:baseline'; new_version_ref = 'local-version:drill'
started_at = $rotationStartedAt.ToString('o'); completed_at = $now.ToString('o')
new_credential_verified = $true; old_credential_rejected = $true; business_continuity_verified = $true; secret_value_not_exported = $true
pre_rotation_probe_ref = 'http-probe:baseline-200'; new_credential_probe_ref = 'http-probe:new-key-200'
old_rejection_probe_ref = 'http-probe:old-key-401'; continuity_probe_ref = 'health-ready:200'; access_audit_ref = 'local-container-log:masked'
})
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 10), [System.Text.UTF8Encoding]::new($false))
go run ./cmd/kms-rotation-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest ('sha256:' + $originalImage) -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_SECRET' -or -not $report.external_validation_required -or $report.rotation_count -ne 1) { throw '本地轮换报告错误地改变了云KMS/HSM验收边界。' }
Write-Host '长期密钥本地演练通过:新密钥生效、旧密钥失效、镜像未变化;报告范围为LOCAL_SECRET,真实KMS/HSM仍待验。'
} finally {
Remove-Item Env:DEVICE_LOCAL_SECRET -ErrorAction SilentlyContinue
docker rm -f $drillContainer 2>$null | Out-Null
docker compose up -d api | Out-Null
Wait-Ready
if ((Invoke-SignedConfig $oldSecret).StatusCode -ne 200) { throw '演练回退后原本地密钥未恢复。' }
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
Write-Host '演练容器已删除,Compose API已恢复并通过原密钥验证。'
}
+32
View File
@@ -0,0 +1,32 @@
$ErrorActionPreference='Stop'
$PSNativeCommandUseErrorActionPreference=$true
$root=Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$suffix=[Guid]::NewGuid().ToString('N')
$restoreDatabase="voice_restore_verify_$suffix"
$dumpPath="/tmp/$restoreDatabase.dump"
if($restoreDatabase-notmatch'^voice_restore_verify_[a-f0-9]{32}$'){throw 'Unsafe restore verification database name'}
$started=[DateTime]::UtcNow
function Pg([Parameter(ValueFromRemainingArguments=$true)][string[]]$arguments){
$output=docker compose exec -T postgres @arguments
if($LASTEXITCODE-ne0){throw "PostgreSQL command failed: $($arguments -join ' ')"}
return ($output|Out-String).Trim()
}
try{
Pg @('pg_dump','-U','voice','-d','voice_platform','-Fc','--no-owner','--no-acl','-f',$dumpPath)|Out-Null
Pg @('createdb','-U','voice',$restoreDatabase)|Out-Null
Pg @('pg_restore','-U','voice','-d',$restoreDatabase,'--no-owner','--no-acl',$dumpPath)|Out-Null
$source=Pg @('psql','-U','voice','-d','voice_platform','-t','-A','-c',"SELECT version||'|'||dirty||'|'||(SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account) FROM schema_migrations")
$restored=Pg @('psql','-U','voice','-d',$restoreDatabase,'-t','-A','-c',"SELECT version||'|'||dirty||'|'||(SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account) FROM schema_migrations")
if($restored-ne$source){throw "Restored evidence mismatch. source=$source restored=$restored"}
$tableEvidence=Pg @('psql','-U','voice','-d',$restoreDatabase,'-t','-A','-c',"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'; SELECT count(*) FROM information_schema.table_constraints WHERE constraint_type='FOREIGN KEY';")
$parts=@($tableEvidence-split"`n"|ForEach-Object{$_.Trim()}|Where-Object{$_})
if($parts.Count-ne2-or[int]$parts[0]-lt40-or[int]$parts[1]-lt30){throw "Restored schema inventory is incomplete: $tableEvidence"}
$elapsed=[Math]::Round(([DateTime]::UtcNow-$started).TotalSeconds,2)
Write-Host "PostgreSQL isolated restore passed: source/restored=$source, public_tables=$($parts[0]), foreign_keys=$($parts[1]), local_elapsed_seconds=$elapsed, database=$restoreDatabase"
}finally{
try{Pg @('dropdb','-U','voice','--if-exists',$restoreDatabase)|Out-Null}catch{Write-Warning $_}
try{Pg @('rm','-f',$dumpPath)|Out-Null}catch{Write-Warning $_}
}
+125
View File
@@ -0,0 +1,125 @@
$ErrorActionPreference='Stop'
$PSNativeCommandUseErrorActionPreference=$true
$root=Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root
$run=[Guid]::NewGuid().ToString('N')
$prefix="provider-outage-$run"
$tenant='10000000-0000-0000-0000-000000000001'
$household='10000000-0000-0000-0000-000000000201'
$subject='10000000-0000-0000-0000-000000000401'
$providerHeaders=@{'X-Mock-Provider-Key'='local-mock-provider-key'}
$conversationIds=@()
function Sql([string]$statement){
$value=$statement|docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if($LASTEXITCODE-ne0){throw 'Provider outage SQL failed'}
return ($value|Out-String).Trim()
}
function New-StartFixture([string]$suffix){
$task="$prefix-$suffix"
$sql=@"
WITH d AS (
INSERT INTO device(tenant_id,serial_digest,model,hardware_revision,credential_ref,minimum_security_version)
VALUES('$tenant'::uuid,encode(digest('$task','sha256'),'hex'),'$prefix','fault','local-dev://simulated-terminal-1','local') RETURNING id
), cs AS (
INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
VALUES('$tenant'::uuid,'$subject'::uuid,1,'{}'::jsonb,jsonb_build_array('$task')) RETURNING id
), cx AS (
INSERT INTO context_snapshot(tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
SELECT '$tenant'::uuid,'$subject'::uuid,1,cs.id,'[]'::jsonb,'$prefix','s2s_hybrid',encode(digest('$task','sha256'),'hex'),'供应商故障合成夹具' FROM cs RETURNING id,consent_snapshot_id
), c AS (
INSERT INTO conversation(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,lease_expires_at)
SELECT '$tenant'::uuid,'$household'::uuid,'$subject'::uuid,d.id,cx.consent_snapshot_id,cx.id,'$task','s2s_hybrid','none','STARTING_AGENT',now()+interval '15 minutes',now()+interval '15 minutes' FROM d CROSS JOIN cx RETURNING id,public_id
), rt AS (
INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status,lease_expires_at)
SELECT id,'mock','$task','room-$task','device-$suffix','agent-$suffix','STARTING',now()+interval '15 minutes' FROM c
), history AS (
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
SELECT id,'ROOM_ISSUED','STARTING_AGENT','provider outage verification fixture','platform' FROM c
), queued AS (
INSERT INTO outbox_event(tenant_id,aggregate_type,aggregate_id,event_type,deduplication_key,payload)
SELECT '$tenant'::uuid,'conversation',id,'conversation.agent_start_requested','provider-outage-start:'||id,'{}'::jsonb FROM c
)
SELECT public_id::text FROM c;
"@
$id=((Sql $sql)-split"`n"|Select-Object -Last 1).Trim()
if($id-notmatch'^[0-9a-fA-F-]{36}$'){throw "Invalid outage fixture id: $id"}
$script:conversationIds+=$id
return $id
}
function Wait-Sql([string]$statement,[string]$expected,[int]$seconds=30){
$deadline=[DateTime]::UtcNow.AddSeconds($seconds)
do{
$value=Sql $statement
if($value-eq$expected){return $value}
Start-Sleep -Milliseconds 500
}while([DateTime]::UtcNow-lt$deadline)
throw "Timed out waiting for '$expected', last='$value'"
}
function Set-Provider([bool]$available){
$body=@{available=$available}|ConvertTo-Json -Compress
Invoke-WebRequest -UseBasicParsing -Method Post -Uri 'http://localhost:59200/mock/v1/admin/availability' -Headers $providerHeaders -ContentType 'application/json' -Body $body|Out-Null
}
try{
Invoke-WebRequest -UseBasicParsing -Method Post -Uri 'http://localhost:59200/mock/v1/admin/reset' -Headers $providerHeaders|Out-Null
$active=New-StartFixture 'active'
Wait-Sql "SELECT status FROM conversation WHERE public_id='$active'::uuid" 'ACTIVE' 20|Out-Null
Set-Provider $false
$failing=New-StartFixture 'failing'
Wait-Sql "SELECT status FROM provider_dependency_state WHERE tenant_id='$tenant'::uuid AND provider='mock'" 'UNAVAILABLE' 30|Out-Null
$state=(Invoke-RestMethod -Method Get -Uri 'http://localhost:59200/mock/v1/admin/state' -Headers $providerHeaders)
$callsBefore=[int]$state.start_calls
$paused=New-StartFixture 'paused'
Start-Sleep -Seconds 3
$state=(Invoke-RestMethod -Method Get -Uri 'http://localhost:59200/mock/v1/admin/state' -Headers $providerHeaders)
if([int]$state.start_calls-ne$callsBefore){throw "New start reached unavailable provider after circuit opened: before=$callsBefore after=$($state.start_calls)"}
$pausedEvidence=Sql "SELECT c.status||'|'||rt.start_attempts FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id='$paused'::uuid"
if($pausedEvidence-ne'STARTING_AGENT|0'){throw "Paused start evidence mismatch: $pausedEvidence"}
Sql "WITH c AS (UPDATE conversation SET status='STOPPING',stop_reason='provider_outage_probe',lease_expires_at=NULL,updated_at=now() WHERE public_id='$active'::uuid RETURNING id) UPDATE rtc_task SET status='STOPPING',lease_expires_at=NULL,updated_at=now() WHERE conversation_id IN(SELECT id FROM c); INSERT INTO outbox_event(tenant_id,aggregate_type,aggregate_id,event_type,deduplication_key,payload) SELECT '$tenant'::uuid,'conversation',id,'conversation.agent_stop_requested','provider-outage-stop:'||id,'{}'::jsonb FROM conversation WHERE public_id='$active'::uuid;"|Out-Null
Wait-Sql "SELECT (stop_attempts>0)::text FROM rtc_task rt JOIN conversation c ON c.id=rt.conversation_id WHERE c.public_id='$active'::uuid" 'true' 15|Out-Null
$token=(Invoke-RestMethod -Method Post -Uri 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token' -ContentType 'application/x-www-form-urlencoded' -Body @{grant_type='password';client_id='voice-platform-web';username='owner';password='LocalOnly123!';scope='openid'}).access_token
$history=Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:58080/api/v1/conversations/$active" -Headers @{Authorization="Bearer $token"}
if($history.StatusCode-ne200){throw 'Historical conversation became unavailable with provider'}
Wait-Sql "SELECT count(*)::text FROM alert_incident WHERE tenant_id='$tenant'::uuid AND rule_code='PROVIDER_UNAVAILABLE' AND fingerprint='provider-unavailable:mock' AND status<>'RESOLVED'" '1' 15|Out-Null
Set-Provider $true
Wait-Sql "SELECT status FROM conversation WHERE public_id='$active'::uuid" 'COMPLETED' 30|Out-Null
Wait-Sql "SELECT status FROM provider_dependency_state WHERE tenant_id='$tenant'::uuid AND provider='mock'" 'AVAILABLE' 15|Out-Null
Wait-Sql "SELECT count(*)::text FROM conversation WHERE public_id IN('$failing'::uuid,'$paused'::uuid) AND status='ACTIVE'" '2' 30|Out-Null
$stopAttempts=Sql "SELECT rt.stop_attempts::text FROM rtc_task rt JOIN conversation c ON c.id=rt.conversation_id WHERE c.public_id='$active'::uuid"
Write-Host "Provider outage recovery passed: circuit_opened=true, paused_start_calls=0, history_http=200, alert=PROVIDER_UNAVAILABLE, cleanup_attempts=$stopAttempts, recovered=true"
}finally{
try{Set-Provider $true}catch{}
if($conversationIds.Count-gt0){
$ids=($conversationIds|ForEach-Object{"'$_'::uuid"})-join','
$cleanup=@"
BEGIN;
CREATE TEMP TABLE outage_c ON COMMIT DROP AS SELECT id,public_id,context_snapshot_id,consent_snapshot_id,device_id FROM conversation WHERE public_id IN($ids);
DELETE FROM alert_incident WHERE tenant_id='$tenant'::uuid AND fingerprint='provider-unavailable:mock';
DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM outage_c);
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM outage_c);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM outage_c);
DELETE FROM conversation_summary WHERE conversation_id IN(SELECT id FROM outage_c);
DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM outage_c);
DELETE FROM provider_event WHERE conversation_id IN(SELECT id FROM outage_c);
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM outage_c);
DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM outage_c);
DELETE FROM conversation WHERE id IN(SELECT id FROM outage_c);
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM outage_c);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM outage_c);
DELETE FROM device WHERE id IN(SELECT device_id FROM outage_c);
DELETE FROM provider_dependency_state WHERE tenant_id='$tenant'::uuid AND provider='mock';
COMMIT;
"@
try{Sql $cleanup|Out-Null}catch{Write-Warning $_}
}
try{Invoke-WebRequest -UseBasicParsing -Method Post -Uri 'http://localhost:59200/mock/v1/admin/reset' -Headers $providerHeaders|Out-Null}catch{}
}
+141
View File
@@ -0,0 +1,141 @@
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$run = [Guid]::NewGuid().ToString('N')
$aggregateId = [Guid]::NewGuid().ToString()
$dedupeKey = "runtime-recovery-$run"
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$workerStopped = $false
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('production-recovery-conformance-' + [Guid]::NewGuid().ToString('N'))
$capturePath = Join-Path $temporaryRoot 'recovery-capture.json'
$reportPath = Join-Path $temporaryRoot 'recovery-report.json'
$recoveryStartedAt = [DateTime]::UtcNow
function Invoke-Sql([string]$Sql) {
$result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if ($LASTEXITCODE -ne 0) { throw 'Runtime recovery SQL failed' }
return ($result | Out-String).Trim()
}
function Wait-Ready([string]$Url) {
$deadline = [DateTime]::UtcNow.AddSeconds(45)
do {
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 3
if ($response.StatusCode -eq 200) { return }
} catch { }
Start-Sleep -Milliseconds 500
} while ([DateTime]::UtcNow -lt $deadline)
throw "Service did not become ready: $Url"
}
function Invoke-SignedDeviceConfig {
$path = '/device/v2/config'
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([byte[]]::new(0))).ToLowerInvariant()
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$nonce = [Guid]::NewGuid().ToString('N')
$canonical = "GET`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() }
return Invoke-WebRequest -UseBasicParsing -Method Get -Uri ("http://localhost:58080" + $path) -Headers @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
}
try {
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
$databaseBefore = Invoke-Sql "SELECT (SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account)||'|'||(SELECT count(*) FROM household)||'|'||(SELECT count(*) FROM subject_profile)"
$startedBefore = @{}
foreach ($service in @('api','callbacks','worker')) {
$container = (docker compose ps -q $service | Out-String).Trim()
if (-not $container) { throw "$service is not running before recovery drill" }
$startedBefore[$service] = (docker inspect -f '{{.State.StartedAt}}' $container | Out-String).Trim()
}
docker compose stop worker | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Cannot stop worker for pending-task recovery drill' }
$workerStopped = $true
Invoke-Sql "INSERT INTO outbox_event(aggregate_type,aggregate_id,event_type,payload,deduplication_key) VALUES('recovery_probe','$aggregateId'::uuid,'platform.noop',jsonb_build_object('probe',true),'$dedupeKey')" | Out-Null
$pending = Invoke-Sql "SELECT status FROM outbox_event WHERE aggregate_id='$aggregateId'::uuid"
if ($pending -ne 'pending') { throw "Recovery event was not held pending while worker stopped: $pending" }
docker compose restart api callbacks | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'API/callback restart failed' }
docker compose start worker | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Worker restart failed' }
$workerStopped = $false
Wait-Ready 'http://localhost:58080/health/ready'
Wait-Ready 'http://localhost:58081/health/ready'
Wait-Ready 'http://localhost:58082/health/ready'
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
$outboxStatus = Invoke-Sql "SELECT status||'|'||attempt_count||'|'||(completed_at IS NOT NULL) FROM outbox_event WHERE aggregate_id='$aggregateId'::uuid"
if ($outboxStatus -eq 'completed|1|true') { break }
Start-Sleep -Milliseconds 300
} while ([DateTime]::UtcNow -lt $deadline)
if ($outboxStatus -ne 'completed|1|true') { throw "Pending outbox did not recover after worker restart: $outboxStatus" }
$startedAfter = @{}
foreach ($service in @('api','callbacks','worker')) {
$container = (docker compose ps -q $service | Out-String).Trim()
$startedAfter[$service] = (docker inspect -f '{{.State.StartedAt}}' $container | Out-String).Trim()
if ($startedAfter[$service] -eq $startedBefore[$service]) { throw "$service start timestamp did not change" }
}
docker compose exec -T redis redis-cli SET "voice-platform:recovery:$run:lease" synthetic EX 300 | Out-Null
docker compose exec -T redis redis-cli SET "voice-platform:recovery:$run:presence" synthetic EX 300 | Out-Null
$keysBefore = [int]((docker compose exec -T redis redis-cli DBSIZE | Out-String).Trim())
if ($keysBefore -lt 2) { throw "Redis recovery fixtures were not created: $keysBefore" }
$flushResult = (docker compose exec -T redis redis-cli FLUSHDB | Out-String).Trim()
if ($flushResult -ne 'OK') { throw "Redis FLUSHDB failed: $flushResult" }
$keysAfterFlush = [int]((docker compose exec -T redis redis-cli DBSIZE | Out-String).Trim())
if ($keysAfterFlush -ne 0) { throw "Redis was not cleared: $keysAfterFlush" }
Wait-Ready 'http://localhost:58080/health/ready'
$configResponse = Invoke-SignedDeviceConfig
if ($configResponse.StatusCode -ne 200) { throw "Device config did not recover after Redis clear: $($configResponse.StatusCode)" }
$keysAfterRequest = [int]((docker compose exec -T redis redis-cli DBSIZE | Out-String).Trim())
if ($keysAfterRequest -lt 1) { throw 'Device replay protection state was not recreated after Redis clear' }
$databaseAfter = Invoke-Sql "SELECT (SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account)||'|'||(SELECT count(*) FROM household)||'|'||(SELECT count(*) FROM subject_profile)"
if ($databaseAfter -ne $databaseBefore) { throw "Authoritative PostgreSQL counts changed across runtime recovery: before=$databaseBefore after=$databaseAfter" }
$recoveryCompletedAt = [DateTime]::UtcNow
$recoverySeconds = [Math]::Max(0, [int][Math]::Ceiling(($recoveryCompletedAt - $recoveryStartedAt).TotalSeconds))
$stateDigest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($databaseAfter))).ToLowerInvariant()
$imageDigest = (docker image inspect data-terminal-cloud-api --format '{{.Id}}').Trim()
$common = @{
started_at = $recoveryStartedAt.ToString('o'); completed_at = $recoveryCompletedAt.ToString('o'); observed_recovery_seconds = $recoverySeconds
recovery_verified = $true; data_integrity_verified = $true; business_continuity_verified = $true; no_duplicate_effects = $true; cleanup_verified = $true
before_state_digest = $stateDigest; after_state_digest = $stateDigest
}
$restartDrill = $common.Clone(); $restartDrill.drill_type = 'SERVICE_ROLLING_RESTART'; $restartDrill.scenario_ref = 'local-compose:api-callbacks-worker-restart'; $restartDrill.execution_ref = 'docker-compose:restart'; $restartDrill.validation_ref = "outbox:$outboxStatus"; $restartDrill.audit_ref = 'local-smoke:process-start-times'
$redisDrill = $common.Clone(); $redisDrill.drill_type = 'REDIS_REBUILD'; $redisDrill.scenario_ref = 'local-redis:flush-and-signed-request-rebuild'; $redisDrill.execution_ref = "redis:flushdb:keys-before-$keysBefore"; $redisDrill.validation_ref = "redis:keys-after-request-$keysAfterRequest"; $redisDrill.audit_ref = 'local-smoke:postgres-authority-counts'
$capture = @{
schema_version = 'production-recovery-capture-v1'; evidence_source = 'LOCAL'; candidate_version = '0.63.0-rc.local'; build_digest = $imageDigest
environment = 'LOCAL'; infrastructure_ref = 'docker-compose:local'; failure_domain_ref = ''; change_ticket_ref = ''
attested_by_ref = 'local-smoke:automated'; attested_at = $recoveryCompletedAt.ToString('o'); collected_at = $recoveryCompletedAt.ToString('o')
drills = @($restartDrill, $redisDrill)
}
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 10), [System.Text.UTF8Encoding]::new($false))
go run ./cmd/recovery-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest $imageDigest -output $reportPath
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_RECOVERY' -or -not $report.external_validation_required -or $report.drill_count -ne 2) { throw '本地恢复报告错误地改变了生产等价恢复门禁。' }
Write-Host "Runtime recovery passed: services_restarted=true, pending_outbox=$outboxStatus, redis_after_flush=$keysAfterFlush, after_signed_request=$keysAfterRequest, report_scope=LOCAL_RECOVERY, production_equivalent_pending=true"
}
finally {
if ($workerStopped) { docker compose start worker | Out-Null }
try { Invoke-Sql "DELETE FROM outbox_event WHERE aggregate_id='$aggregateId'::uuid" | Out-Null } catch { Write-Warning "Recovery cleanup failed: $_" }
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
}
+42
View File
@@ -0,0 +1,42 @@
const path = require("path");
const fs = require("fs");
const root = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!root) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(root, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => { if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); });
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
const alertNavigation = page.getByRole("navigation", { name: "业务工作台导航" }).getByRole("button", { name: /告警处置/ });
await alertNavigation.waitFor({ state: "visible", timeout: 15000 });
await alertNavigation.click();
const workspace = page.getByRole("region", { name: "告警处置工作台" });
await workspace.getByRole("heading", { name: "告警监测与处置闭环" }).waitFor({ state: "visible", timeout: 10000 });
await workspace.scrollIntoViewIfNeeded();
const overflow = await workspace.evaluate((element) => element.scrollWidth > element.clientWidth + 1);
if (overflow) failures.push("alert workspace has horizontal overflow");
const summaryCards = await workspace.locator(".alert-summary-grid article").count();
if (summaryCards !== 4) failures.push(`alert summary cards mismatch: ${summaryCards}`);
const controls = await workspace.locator("input, select, button").count();
if (controls < 15) failures.push(`alert workspace controls were not rendered: ${controls}`);
await workspace.screenshot({ path: path.join(outputDir, "ops-alert-workspace.png") });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Alert UI browser smoke passed. Screenshot: ${outputDir}\n`);
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
+37
View File
@@ -0,0 +1,37 @@
const path = require("path");
const fs = require("fs");
const root = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!root) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(root, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => { if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); });
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
const workspace = page.getByRole("region", { name: "用量费用与额度工作台" });
await workspace.getByRole("heading", { name: "用量、费用与额度" }).waitFor({ state: "visible", timeout: 10000 });
await workspace.scrollIntoViewIfNeeded();
const overflow = await workspace.evaluate((element) => element.scrollWidth > element.clientWidth + 1);
if (overflow) failures.push("billing workspace has horizontal overflow");
const inputs = await workspace.locator("input, select, textarea, button").count();
if (inputs < 20) failures.push(`billing workspace controls were not rendered: ${inputs}`);
await workspace.screenshot({ path: path.join(outputDir, "ops-billing-workspace.png") });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Billing UI browser smoke passed. Screenshot: ${outputDir}\n`);
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
+118
View File
@@ -0,0 +1,118 @@
const path = require("path");
const fs = require("fs");
const { execFileSync } = require("child_process");
const root = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!root) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(root, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
fs.mkdirSync(outputDir, { recursive: true });
function psql(sql) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-U", "voice", "-d", "voice_platform", "-v", "ON_ERROR_STOP=1", "-q", "-c", sql], { cwd: path.resolve(__dirname, ".."), encoding: "utf8" });
}
(async () => {
let acceptanceKey;
let acceptanceID;
let pilotDailyKey;
let pilotDailyID;
let pilotWeeklyKey;
let pilotWeeklyID;
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => { if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); });
page.on("request", (request) => {
if (request.url().endsWith("/api/v1/ops/diagnostics/acceptance-snapshots")) acceptanceKey = request.headers()["idempotency-key"];
if (request.url().endsWith("/api/v1/ops/diagnostics/pilot-daily-snapshots")) pilotDailyKey = request.headers()["idempotency-key"];
if (request.url().endsWith("/api/v1/ops/diagnostics/pilot-weekly-snapshots")) pilotWeeklyKey = request.headers()["idempotency-key"];
});
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
await page.getByRole("button", { name: /^联调诊断/ }).click();
await page.getByRole("heading", { name: "设备与通话联调" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByRole("heading", { name: "发布候选验收快照" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByRole("heading", { name: "试点运行日报" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByRole("heading", { name: "试点运行周报" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByRole("heading", { name: "外部依赖预检" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByRole("heading", { name: "联调问题清单" }).waitFor({ state: "visible", timeout: 10000 });
const oidcCard = page.locator(".external-dependency-grid .diagnostic-card").filter({ hasText: "OIDC签名密钥" });
if (await oidcCard.count() !== 1) failures.push("OIDC preflight card is missing or duplicated");
else {
if (!await oidcCard.getByText("API进程", { exact: false }).isVisible()) failures.push("OIDC preflight service ownership is missing");
if (!await oidcCard.getByText("可用", { exact: true }).isVisible()) failures.push("local OIDC preflight is not available");
if (await oidcCard.getByText("结果过期", { exact: true }).count()) failures.push("fresh OIDC preflight was marked stale");
}
const issueSection = page.locator(".integration-issues");
if (!await issueSection.getByText("高严重度门禁", { exact: true }).isVisible()) failures.push("integration issue release gate summary is missing");
if (!await issueSection.getByText("通过", { exact: true }).isVisible()) failures.push("empty local issue ledger did not show a satisfied high-severity gate");
if (!await issueSection.getByLabel("严重级别").isVisible()) failures.push("integration issue severity input is missing");
const acceptanceSection = page.locator(".acceptance-snapshot");
await acceptanceSection.getByLabel("候选版本").fill("0.51.0-rc.browser");
await acceptanceSection.getByLabel("构建摘要").fill(`sha256:${"b".repeat(64)}`);
await acceptanceSection.getByText("方舟模型服务TLS", { exact: true }).locator("input").uncheck();
await acceptanceSection.getByText("火山RTC服务TLS", { exact: true }).locator("input").uncheck();
const downloadPromise = page.waitForEvent("download");
await acceptanceSection.getByRole("button", { name: "生成并下载快照" }).click();
const download = await downloadPromise;
const downloadPath = await download.path();
const acceptance = JSON.parse(fs.readFileSync(downloadPath, "utf8"));
acceptanceID = acceptance.id;
if (!acceptanceID || acceptance.candidate_version !== "0.51.0-rc.browser" || acceptance.redaction_policy !== "ops-acceptance-minimal-v1") failures.push("downloaded acceptance snapshot metadata is invalid");
const failedAcceptanceGates = acceptance.gates?.filter((gate) => !gate.satisfied) || [];
if (acceptance.gates?.length !== 6 || acceptance.external_dependencies?.length !== 1 || acceptance.overall_gate_satisfied !== (failedAcceptanceGates.length === 0)) failures.push("downloaded acceptance snapshot gates are invalid");
const blockingItems = acceptance.alerts?.blocking_items || [];
if (blockingItems.length !== Math.min(acceptance.alerts?.blocking || 0, 100)) failures.push("downloaded blocking alert evidence count is invalid");
if (blockingItems.some((item) => !item.id || !item.rule_code || !["PRESENT", "MISSING", "AGGREGATE", "UNKNOWN"].includes(item.resource_state))) failures.push("downloaded blocking alert evidence is invalid");
if (blockingItems.length && !await acceptanceSection.getByRole("heading", { name: "阻断告警定位" }).isVisible()) failures.push("blocking alert evidence is not rendered");
if (!await page.getByText("技术验收快照已下载", { exact: false }).isVisible()) failures.push("acceptance download result message is missing");
const pilotSection = page.locator(".pilot-daily-snapshot");
const pilotDownloadPromise = page.waitForEvent("download");
await pilotSection.getByRole("button", { name: "生成并下载日报" }).click();
const pilotDownload = await pilotDownloadPromise;
const pilotDownloadPath = await pilotDownload.path();
const pilotDaily = JSON.parse(fs.readFileSync(pilotDownloadPath, "utf8"));
pilotDailyID = pilotDaily.id;
if (!pilotDailyID || pilotDaily.candidate_version !== "0.51.0-rc.browser" || pilotDaily.build_digest !== `sha256:${"b".repeat(64)}` || pilotDaily.redaction_policy !== "ops-pilot-daily-minimal-v1" || pilotDaily.conversations?.statuses?.length !== 8) failures.push("downloaded pilot daily snapshot metadata is invalid");
if ((pilotDaily.conversations?.statuses || []).reduce((sum, item) => sum + item.count, 0) !== pilotDaily.conversations?.total) failures.push("downloaded pilot daily conversation distribution is invalid");
if (!await page.getByText("试点运行日报已生成并下载", { exact: false }).isVisible()) failures.push("pilot daily download result message is missing");
const pilotWeeklySection = page.locator(".pilot-weekly-snapshot");
const pilotWeeklyDownloadPromise = page.waitForEvent("download");
await pilotWeeklySection.getByRole("button", { name: "生成并下载周报" }).click();
const pilotWeeklyDownload = await pilotWeeklyDownloadPromise;
const pilotWeeklyDownloadPath = await pilotWeeklyDownload.path();
const pilotWeekly = JSON.parse(fs.readFileSync(pilotWeeklyDownloadPath, "utf8"));
pilotWeeklyID = pilotWeekly.id;
if (!pilotWeeklyID || pilotWeekly.candidate_version !== "0.51.0-rc.browser" || pilotWeekly.build_digest !== `sha256:${"b".repeat(64)}` || pilotWeekly.redaction_policy !== "ops-pilot-weekly-minimal-v1" || pilotWeekly.privacy?.by_type?.length !== 4 || pilotWeekly.privacy?.by_status?.length !== 5) failures.push("downloaded pilot weekly snapshot metadata is invalid");
if ((pilotWeekly.config?.aligned || 0) + (pilotWeekly.config?.drifted || 0) + (pilotWeekly.config?.unreported || 0) !== pilotWeekly.config?.eligible_devices) failures.push("downloaded pilot weekly config distribution is invalid");
if (pilotWeekly.external_evidence?.[0]?.code !== "complaints" || pilotWeekly.external_evidence?.[0]?.status !== "REQUIRED") failures.push("pilot weekly complaint evidence gap is missing");
if (!await pilotWeeklySection.getByText("外部证据仍需补充:投诉", { exact: true }).isVisible()) failures.push("pilot weekly complaint evidence warning is missing");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
if (overflow) failures.push("diagnostics workspace has horizontal overflow");
await page.screenshot({ path: path.join(outputDir, "ops-diagnostics-workspace.png"), fullPage: true });
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Operations diagnostics UI browser smoke passed. Screenshot: ${outputDir}\n`);
} finally {
await browser.close();
if (acceptanceKey || acceptanceID || pilotDailyKey || pilotDailyID || pilotWeeklyKey || pilotWeeklyID) {
try {
const predicates = [];
if (acceptanceID) predicates.push(`(resource_type='acceptance_snapshot' AND resource_id='${acceptanceID}'::uuid)`);
if (pilotDailyID) predicates.push(`(resource_type='pilot_daily_snapshot' AND resource_id='${pilotDailyID}'::uuid)`);
if (pilotWeeklyID) predicates.push(`(resource_type='pilot_weekly_snapshot' AND resource_id='${pilotWeeklyID}'::uuid)`);
psql(`BEGIN; DELETE FROM audit_event WHERE ${predicates.length ? predicates.join(" OR ") : "false"}; DELETE FROM idempotency_record WHERE idempotency_key IN ('${acceptanceKey || "missing"}','${pilotDailyKey || "missing"}','${pilotWeeklyKey || "missing"}'); COMMIT;`);
} catch (error) { process.stderr.write(`diagnostics evidence cleanup failed: ${error.message}\n`); }
}
}
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
+43
View File
@@ -0,0 +1,43 @@
const path = require("path");
const fs = require("fs");
const playwrightRoot = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!playwrightRoot) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(playwrightRoot, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/events");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => {
if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`);
});
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
const liveStatus = page.getByText("状态实时更新", { exact: true });
await liveStatus.waitFor({ state: "visible", timeout: 10000 });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
if (overflow) failures.push("workspace has horizontal overflow");
await liveStatus.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(outputDir, "event-stream-connected.png"), fullPage: false });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Event stream browser smoke passed. Screenshot: ${outputDir}\n`);
})().catch((error) => {
process.stderr.write(`${error.stack || error}\n`);
process.exitCode = 1;
});
+63
View File
@@ -0,0 +1,63 @@
const path = require("path");
const fs = require("fs");
const playwrightRoot = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!playwrightRoot) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(playwrightRoot, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/media");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") failures.push(`console: ${message.text()}`);
});
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
await page.getByRole("button", { name: "通话记录" }).click();
await page.locator(".conversation-row").first().click();
await page.locator(".recording-block").waitFor({ state: "visible" });
await page.getByRole("button", { name: "确认授权" }).click();
await page.getByText("播放已授权", { exact: true }).waitFor({ timeout: 10000 });
const checkOverflow = async (label) => {
const overflow = await page.evaluate(() => {
const selectors = ["html", "body", ".conversation-detail", ".recording-block", ".recording-grant-form"];
return selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)).map((element) => ({
selector,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth
}))).filter((item) => item.scrollWidth > item.clientWidth + 1);
});
if (overflow.length) failures.push(`${label} overflow: ${JSON.stringify(overflow)}`);
};
await page.locator(".recording-block").scrollIntoViewIfNeeded();
await checkOverflow("1440px");
await page.screenshot({ path: path.join(outputDir, "media-recording-1440.png"), fullPage: false });
await page.setViewportSize({ width: 1024, height: 900 });
await page.locator(".recording-block").scrollIntoViewIfNeeded();
await checkOverflow("1024px");
await page.screenshot({ path: path.join(outputDir, "media-recording-1024.png"), fullPage: false });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Media UI visual smoke passed. Screenshots: ${outputDir}\n`);
})().catch((error) => {
process.stderr.write(`${error.stack || error}\n`);
process.exitCode = 1;
});
+60
View File
@@ -0,0 +1,60 @@
const path = require("path");
const fs = require("fs");
const root = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!root) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(root, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => { if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); });
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
await page.getByRole("button", { name: /^运行配置/ }).click();
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor({ state: "visible", timeout: 10000 });
await page.getByLabel("配置类型").selectOption("AGENT_BUNDLE");
await page.locator(".ops-bundle-composer").waitFor({ state: "visible" });
const componentLabels = ["人设版本", "音色版本", "模型版本", "路由版本"];
for (const label of componentLabels) {
const selector = page.getByLabel(label);
if (await selector.count() !== 1) failures.push(`missing Agent bundle selector: ${label}`);
if (process.env.EXPECT_AGENT_BUNDLE_COMPONENTS === "1") {
const optionCount = await selector.locator("option").count();
if (optionCount < 2) failures.push(`Agent bundle selector has no published component: ${label}`);
if (await selector.inputValue() === "") failures.push(`Agent bundle selector was not automatically selected: ${label}`);
}
}
if (await page.getByLabel("配置正文(JSON)").count()) failures.push("Agent bundle still exposes raw JSON editor");
if (!await page.getByText("组合不直接保存密钥引用").isVisible()) failures.push("Agent bundle credential ownership note is missing");
if (!await page.getByText("配置投放控制", { exact: true }).isVisible()) failures.push("rollout control ledger is missing");
if (process.env.EXPECT_ROLLOUT_CONTROLS === "1" && await page.getByRole("button", { name: /^(暂停|恢复)$/ }).count() < 1) {
failures.push("active or paused rollout has no control action");
}
if (process.env.EXERCISE_ROLLOUT_CONTROLS === "1") {
const pauseButton = page.getByRole("button", { name: "暂停", exact: true }).first();
await Promise.all([page.waitForEvent("dialog").then((dialog) => dialog.accept("浏览器纵向冒烟暂停")), pauseButton.click()]);
await page.getByText("配置投放已暂停,后续新建通话不再命中该范围。").waitFor({ state: "visible", timeout: 10000 });
const resumeButton = page.getByRole("button", { name: "恢复", exact: true }).first();
await Promise.all([page.waitForEvent("dialog").then((dialog) => dialog.accept("浏览器纵向冒烟恢复")), resumeButton.click()]);
await page.getByText("配置投放已恢复,后续新建通话可再次命中该范围。").waitFor({ state: "visible", timeout: 10000 });
}
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
if (overflow) failures.push("operations workspace has horizontal overflow");
await page.screenshot({ path: path.join(outputDir, "ops-config-workspace.png"), fullPage: true });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Operations config UI browser smoke passed. Screenshot: ${outputDir}\n`);
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
+35
View File
@@ -0,0 +1,35 @@
const path = require("path");
const fs = require("fs");
const playwrightRoot = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!playwrightRoot) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(playwrightRoot, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/sharing");
fs.mkdirSync(outputDir, { recursive: true });
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => { if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); });
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
await page.getByRole("button", { name: "资源分享" }).click();
await page.getByText("创建具体资源分享", { exact: true }).waitFor({ state: "visible", timeout: 10000 });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
if (overflow) failures.push("sharing workspace has horizontal overflow");
await page.screenshot({ path: path.join(outputDir, "sharing-management.png"), fullPage: true });
await browser.close();
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`Sharing UI browser smoke passed. Screenshot: ${outputDir}\n`);
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
+85
View File
@@ -0,0 +1,85 @@
const path = require("path");
const fs = require("fs");
const { execFileSync } = require("child_process");
const { randomUUID } = require("crypto");
const dependencyRoot = process.env.PLAYWRIGHT_REQUIRE_FROM;
if (!dependencyRoot) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
const { chromium } = require(path.join(dependencyRoot, "playwright"));
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
const marker = `sse_${randomUUID().replaceAll("-", "").slice(0, 16)}`;
fs.mkdirSync(outputDir, { recursive: true });
function psql(sql) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-U", "voice", "-d", "voice_platform", "-v", "ON_ERROR_STOP=1", "-q", "-c", sql], {
cwd: path.resolve(__dirname, ".."), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
});
}
function insertEvent() {
psql(`INSERT INTO web_event(tenant_id,household_id,subject_id,event_type,resource_type,resource_public_id,status)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','10000000-0000-0000-0000-000000000401',
'device.presence.changed','device',gen_random_uuid(),'${marker}')`);
}
(async () => {
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const failures = [];
const routeCounts = new Map();
let countResponses = false;
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
page.on("response", (response) => {
if (!countResponses) return;
const url = new URL(response.url());
if (!url.pathname.startsWith("/api/v1/") || url.pathname === "/api/v1/events") return;
routeCounts.set(url.pathname, (routeCounts.get(url.pathname) || 0) + 1);
if (response.status() === 429) failures.push(`rate limited: ${url.pathname}`);
else if (response.status() >= 400) failures.push(`http ${response.status()}: ${url.pathname}`);
});
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
const login = page.getByRole("button", { name: "登录研发环境" });
if (await login.isVisible()) {
await login.click();
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
}
await page.waitForURL("http://localhost:3100/**", { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor({ state: "visible", timeout: 15000 });
await page.getByText("状态实时更新", { exact: true }).waitFor({ state: "visible", timeout: 15000 });
// Let the login/navigation burst leave the local user token bucket before
// measuring only the refreshes caused by a sustained event stream.
await page.waitForTimeout(6000);
countResponses = true;
for (let index = 0; index < 8; index += 1) {
insertEvent();
await page.waitForTimeout(650);
}
await page.waitForTimeout(2500);
countResponses = false;
const factRefreshes = [...routeCounts.entries()].filter(([route]) => route.endsWith("/facts")).reduce((sum, [, count]) => sum + count, 0);
const totalBusinessResponses = [...routeCounts.values()].reduce((sum, count) => sum + count, 0);
if (factRefreshes < 2) failures.push(`event refresh did not run enough times to verify coalescing: ${factRefreshes}`);
// The first event may refresh immediately; subsequent refreshes are capped
// at one per two seconds. The measured window includes eight Docker/psql
// inserts and therefore permits at most five batches.
if (factRefreshes > 5) failures.push(`event refresh was not coalesced: facts loaded ${factRefreshes} times`);
if (totalBusinessResponses > 90) failures.push(`event stream amplified into ${totalBusinessResponses} business requests`);
const eventStatus = page.locator(".event-stream-status");
const title = await eventStatus.getAttribute("title");
if (!title || title === "等待状态事件") failures.push("the browser did not observe the injected events");
await page.screenshot({ path: path.join(outputDir, "sse-refresh-coalescing.png"), fullPage: true });
if (failures.length) throw new Error(failures.join("\n"));
process.stdout.write(`SSE refresh coalescing passed: 8 events -> ${factRefreshes} refresh batches, ${totalBusinessResponses} business responses.\n`);
} finally {
try { psql(`DELETE FROM web_event WHERE status='${marker}'`); } catch (error) { process.stderr.write(`cleanup failed: ${error.message}\n`); }
await browser.close();
}
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });