$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 } }