Files
EIS-dotnet/scripts/smoke-dotnet-migration.ps1
T
biss 017cacc6f9 招生计划、资格、录取、分数线及报到公示
成绩单 HMAC 防伪验真
录取通知书 HMAC 防伪验真
防伪码常量时间比较
生产环境文书密钥强度检查
旧文书防伪码完全兼容
2026-07-22 19:12:38 +08:00

324 lines
15 KiB
PowerShell

[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$smokeArtifactRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'artifacts\smoke'))
$testDirectory = Join-Path $smokeArtifactRoot ("eis-migration-smoke-{0}" -f [guid]::NewGuid().ToString('N'))
$nodeProcess = $null
$dotnetProcess = $null
function Get-AvailableTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Start-TestProcess {
param(
[Parameter(Mandatory)]
[string] $FileName,
[Parameter(Mandatory)]
[string[]] $ArgumentList,
[Parameter(Mandatory)]
[hashtable] $Environment
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $FileName
$startInfo.WorkingDirectory = $repositoryRoot
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $ArgumentList) {
$startInfo.ArgumentList.Add($argument)
}
foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.Environment[$entry.Key] = [string] $entry.Value
}
return [System.Diagnostics.Process]::Start($startInfo)
}
function Wait-ForUrl {
param(
[Parameter(Mandatory)]
[uri] $Uri,
[Parameter(Mandatory)]
[System.Diagnostics.Process[]] $Processes
)
$deadline = [DateTimeOffset]::UtcNow.AddSeconds(25)
while ([DateTimeOffset]::UtcNow -lt $deadline) {
foreach ($process in $Processes) {
if ($process.HasExited) {
$standardOutput = $process.StandardOutput.ReadToEnd()
$standardError = $process.StandardError.ReadToEnd()
throw "Process $($process.Id) exited with code $($process.ExitCode) before $Uri became ready`n$standardOutput`n$standardError"
}
}
try {
$response = Invoke-WebRequest -Uri $Uri -TimeoutSec 2
if ($response.StatusCode -eq 200) {
return
}
}
catch {
Start-Sleep -Milliseconds 250
}
}
throw "Timed out waiting for $Uri"
}
try {
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$legacyPort = Get-AvailableTcpPort
$webPort = Get-AvailableTcpPort
$legacyBaseUrl = "http://127.0.0.1:$legacyPort"
$webBaseUrl = "http://127.0.0.1:$webPort"
$nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source
$dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source
$smokeDatabasePath = Join-Path $testDirectory 'smoke.sqlite'
$nodeEnvironment = @{
NODE_ENV = 'test'
HOST = '127.0.0.1'
PORT = [string] $legacyPort
DATABASE_CLIENT = 'sqlite'
SQLITE_PATH = $smokeDatabasePath
INITIAL_ADMIN_USERNAME = 'migration_admin'
INITIAL_ADMIN_PASSWORD = 'Migration123!'
INITIAL_ADMIN_DISPLAY_NAME = '迁移测试管理员'
REDIS_URL = ''
REDIS_SESSION_URL = ''
TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
}
$seedProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('scripts/import-test-data.mjs', '--sqlite') -Environment $nodeEnvironment
if (-not $seedProcess.WaitForExit(30000)) {
$seedProcess.Kill($true)
throw 'Timed out while preparing the migration smoke-test database'
}
$seedOutput = $seedProcess.StandardOutput.ReadToEnd()
$seedError = $seedProcess.StandardError.ReadToEnd()
if ($seedProcess.ExitCode -ne 0) {
throw "Failed to prepare the migration smoke-test database`n$seedOutput`n$seedError"
}
$seedProcess.Dispose()
$nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
$dotnetProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
'run',
'--project', $projectPath,
'--configuration', 'Release',
'--no-build',
'--no-launch-profile',
'--',
'--urls', $webBaseUrl
) -Environment @{
ASPNETCORE_ENVIRONMENT = 'Development'
LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl
DATABASE_CLIENT = 'sqlite'
SQLITE_PATH = $smokeDatabasePath
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
}
Wait-ForUrl -Uri "$webBaseUrl/health/live" -Processes @($nodeProcess, $dotnetProcess)
Wait-ForUrl -Uri "$webBaseUrl/health/migration" -Processes @($nodeProcess, $dotnetProcess)
$index = Invoke-WebRequest -Uri "$webBaseUrl/"
if ($index.Content -notmatch '衡准') {
throw 'ASP.NET Core did not serve the existing index.html'
}
$clientModule = Invoke-WebRequest -Uri "$webBaseUrl/src/client/api.mjs"
if ($clientModule.Content -notmatch 'export function api') {
throw 'ASP.NET Core did not serve the existing client module'
}
$homeResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/home"
if ($homeResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Public home request did not use the native ASP.NET Core endpoint'
}
$homePayload = $homeResponse.Content | ConvertFrom-Json
if ($homePayload.ok -ne $true) {
throw 'Native public API did not return a valid JSON response'
}
$legacyHomePayload = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/home"
if ($homePayload.stats.candidates -ne $legacyHomePayload.stats.candidates -or
$homePayload.stats.exams -ne $legacyHomePayload.stats.exams -or
$homePayload.stats.registrations -ne $legacyHomePayload.stats.registrations -or
$homePayload.schools.Count -ne $legacyHomePayload.schools.Count -or
$homePayload.classes.Count -ne $legacyHomePayload.classes.Count -or
$homePayload.notices.Count -ne $legacyHomePayload.notices.Count) {
throw 'Native public home aggregates do not match the legacy API'
}
foreach ($legacyExam in $legacyHomePayload.exams) {
$nativeExam = $homePayload.exams | Where-Object id -eq $legacyExam.id | Select-Object -First 1
if ($null -eq $nativeExam -or $nativeExam.subjects.Count -ne $legacyExam.subjects.Count -or
$nativeExam.totalScore -ne $legacyExam.totalScore -or $nativeExam.registrationCount -ne $legacyExam.registrationCount) {
throw "Native public exam projection does not match the legacy API for $($legacyExam.id)"
}
}
$announcementResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/announcements"
if ($announcementResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Public announcements request did not use the native ASP.NET Core endpoint'
}
$nativeAnnouncements = $announcementResponse.Content | ConvertFrom-Json
$legacyAnnouncements = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/announcements"
foreach ($section in @('plans', 'qualifications', 'admissions', 'cutoffs', 'reports')) {
$nativeIds = @($nativeAnnouncements.$section | ForEach-Object id)
$legacyIds = @($legacyAnnouncements.$section | ForEach-Object id)
if (($nativeIds -join "`0") -ne ($legacyIds -join "`0")) {
throw "Native public announcement section '$section' does not match the legacy API"
}
$nativeSectionJson = $nativeAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress
$legacySectionJson = $legacyAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress
if ($nativeSectionJson -ne $legacySectionJson) {
throw "Native public announcement payload '$section' differs from the legacy API"
}
}
$session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$loginBody = @{ username = 'admin'; password = '12345678' } | ConvertTo-Json -Compress
$login = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session
if ($login.ok -ne $true -or $login.user.username -ne 'admin') {
throw 'Legacy login API proxy did not preserve the response'
}
$currentUser = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/me" -WebSession $session
if ($currentUser.user.username -ne 'admin') {
throw 'Legacy API proxy did not preserve the session cookie'
}
$noticeBody = @{
title = 'ASP.NET Core 迁移冒烟通知'
content = '<p>原生公开读取验证</p><script>throw new Error("unsafe")</script>'
category = '系统测试'
pinned = $true
status = 'published'
} | ConvertTo-Json -Compress
$createdNotice = Invoke-RestMethod -Uri "$webBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body $noticeBody -WebSession $session
$noticeResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/notices/$($createdNotice.notice.id)"
if ($noticeResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Public notice request did not use the native ASP.NET Core endpoint'
}
$noticePayload = $noticeResponse.Content | ConvertFrom-Json
if ($noticePayload.notice.title -ne 'ASP.NET Core 迁移冒烟通知' -or $noticePayload.notice.content -match '<script') {
throw 'Native public notice endpoint did not preserve data or sanitize unsafe content'
}
$candidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$candidateLoginBody = @{ username = '2026-HZ01-F-0001'; password = '12345678' } | ConvertTo-Json -Compress
$candidateLogin = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $candidateSession
if ($candidateLogin.user.username -ne '2026-HZ01-F-0001') {
throw 'Could not sign in as the migration verification candidate'
}
$candidateResults = Invoke-RestMethod -Uri "$webBaseUrl/api/candidate/results" -WebSession $candidateSession
$scoreCode = @($candidateResults.summaries | Where-Object verificationCode | Select-Object -First 1).verificationCode
if (-not $scoreCode) {
throw 'Seed data did not provide a score-report verification code'
}
$scoreVerificationResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/verifications/$scoreCode"
if ($scoreVerificationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Document verification request did not use the native ASP.NET Core endpoint'
}
$nativeScoreVerification = $scoreVerificationResponse.Content | ConvertFrom-Json
$legacyScoreVerification = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/verifications/$scoreCode"
if ($nativeScoreVerification.document.type -ne 'score-report' -or
$nativeScoreVerification.document.examName -ne $legacyScoreVerification.document.examName -or
$nativeScoreVerification.document.totalScore -ne $legacyScoreVerification.document.totalScore -or
$nativeScoreVerification.document.subjectCount -ne $legacyScoreVerification.document.subjectCount) {
throw 'Native score-report verification does not match the legacy API'
}
$admissionCodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
'tests/helpers/create-admission-verification.mjs', $smokeDatabasePath
) -Environment $nodeEnvironment
if (-not $admissionCodeProcess.WaitForExit(10000)) {
$admissionCodeProcess.Kill($true)
throw 'Timed out while reading an admission-notice verification code'
}
$noticeCode = $admissionCodeProcess.StandardOutput.ReadToEnd().Trim()
$admissionCodeError = $admissionCodeProcess.StandardError.ReadToEnd()
if ($admissionCodeProcess.ExitCode -ne 0) {
throw "Could not read an admission-notice verification code`n$admissionCodeError"
}
$admissionCodeProcess.Dispose()
if (-not $noticeCode) {
throw "Seed data did not provide an admission-notice verification code`n$admissionCodeError"
}
$nativeNoticeVerification = Invoke-RestMethod -Uri "$webBaseUrl/api/public/verifications/$noticeCode"
$legacyNoticeVerification = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/verifications/$noticeCode"
if ($nativeNoticeVerification.document.type -ne 'admission-notice' -or
$nativeNoticeVerification.document.noticeNumber -ne $legacyNoticeVerification.document.noticeNumber -or
$nativeNoticeVerification.document.schoolName -ne $legacyNoticeVerification.document.schoolName) {
throw 'Native admission-notice verification does not match the legacy API'
}
$invalidVerification = Invoke-WebRequest -Uri "$webBaseUrl/api/public/verifications/SR-000000000000000000000000" -SkipHttpErrorCheck
if ($invalidVerification.StatusCode -ne 404 -or $invalidVerification.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native document verification did not reject an invalid code'
}
[pscustomobject]@{
AspNetCoreHost = 'passed'
StaticAssets = 'passed'
JsonProxy = 'passed'
SessionCookie = 'passed'
NativePublicApi = 'passed'
PublicParity = 'passed'
DocumentCodes = 'passed'
} | Format-List
}
finally {
foreach ($process in @($dotnetProcess, $nodeProcess)) {
if ($null -ne $process -and -not $process.HasExited) {
$process.Kill($true)
$process.WaitForExit()
}
if ($null -ne $process) {
$process.Dispose()
}
}
if (Test-Path -LiteralPath $testDirectory) {
$resolvedTestDirectory = [System.IO.Path]::GetFullPath($testDirectory)
if (-not $resolvedTestDirectory.StartsWith($smokeArtifactRoot, [StringComparison]::OrdinalIgnoreCase) -or
-not ([System.IO.Path]::GetFileName($resolvedTestDirectory)).StartsWith('eis-migration-smoke-', [StringComparison]::Ordinal)) {
throw "Refusing to remove unexpected smoke-test directory: $resolvedTestDirectory"
}
for ($attempt = 1; $attempt -le 20; $attempt++) {
try {
Remove-Item -LiteralPath $resolvedTestDirectory -Recurse -Force -ErrorAction Stop
break
}
catch {
if ($attempt -eq 20) {
throw
}
Start-Sleep -Milliseconds 200
}
}
}
}