原生成绩复议提交,工作流、分配人与审计日志同事务写入。 原生准考证下载窗口校验及 HTML 文书生成:[CandidateAdmitCardRenderer.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs:9) 三个路由已切换到 ASP.NET Core:[NativeCandidateEndpoints.cs (line 61)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:61) 更新迁移说明:[MIGRATION.md (line 50)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:50)
735 lines
38 KiB
PowerShell
735 lines
38 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
|
|
$nativeAuthProcess = $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"
|
|
}
|
|
|
|
function Assert-JsonEquivalent {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string] $Expected,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string] $Actual,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string] $Label
|
|
)
|
|
|
|
$expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected)
|
|
$actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual)
|
|
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) {
|
|
$difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$'
|
|
throw "$Label JSON payload differs from the legacy API at $difference"
|
|
}
|
|
}
|
|
|
|
function Assert-CandidateResultsEquivalent {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string] $Expected,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string] $Actual
|
|
)
|
|
|
|
$expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected)
|
|
$actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual)
|
|
foreach ($node in @($expectedNode, $actualNode)) {
|
|
foreach ($summary in $node['summaries'].AsArray()) {
|
|
$summary.AsObject().Remove('verificationQr') | Out-Null
|
|
}
|
|
}
|
|
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) {
|
|
$difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$'
|
|
throw "Candidate route 'results' JSON payload differs from the legacy API at $difference"
|
|
}
|
|
}
|
|
|
|
function Find-JsonDifference {
|
|
param(
|
|
[AllowNull()]
|
|
[System.Text.Json.Nodes.JsonNode] $Expected,
|
|
|
|
[AllowNull()]
|
|
[System.Text.Json.Nodes.JsonNode] $Actual,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string] $Path
|
|
)
|
|
|
|
if ($null -eq $Expected -or $null -eq $Actual) {
|
|
return "$Path (expected=$Expected, actual=$Actual)"
|
|
}
|
|
|
|
if ($Expected -is [System.Text.Json.Nodes.JsonObject] -and $Actual -is [System.Text.Json.Nodes.JsonObject]) {
|
|
foreach ($entry in $Expected) {
|
|
if (-not $Actual.ContainsKey($entry.Key)) {
|
|
return "$Path.$($entry.Key) (missing from actual)"
|
|
}
|
|
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($entry.Value, $Actual[$entry.Key])) {
|
|
return Find-JsonDifference -Expected $entry.Value -Actual $Actual[$entry.Key] -Path "$Path.$($entry.Key)"
|
|
}
|
|
}
|
|
foreach ($entry in $Actual) {
|
|
if (-not $Expected.ContainsKey($entry.Key)) {
|
|
return "$Path.$($entry.Key) (unexpected in actual)"
|
|
}
|
|
}
|
|
return "$Path (object values differ)"
|
|
}
|
|
|
|
if ($Expected -is [System.Text.Json.Nodes.JsonArray] -and $Actual -is [System.Text.Json.Nodes.JsonArray]) {
|
|
if ($Expected.Count -ne $Actual.Count) {
|
|
return "$Path.Count (expected=$($Expected.Count), actual=$($Actual.Count))"
|
|
}
|
|
for ($index = 0; $index -lt $Expected.Count; $index++) {
|
|
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($Expected[$index], $Actual[$index])) {
|
|
return Find-JsonDifference -Expected $Expected[$index] -Actual $Actual[$index] -Path "$Path[$index]"
|
|
}
|
|
}
|
|
return "$Path (array values differ)"
|
|
}
|
|
|
|
return "$Path (expected=$($Expected.ToJsonString()), actual=$($Actual.ToJsonString()))"
|
|
}
|
|
|
|
try {
|
|
New-Item -ItemType Directory -Path $testDirectory | Out-Null
|
|
|
|
$legacyPort = Get-AvailableTcpPort
|
|
$webPort = Get-AvailableTcpPort
|
|
$nativeAuthPort = Get-AvailableTcpPort
|
|
$legacyBaseUrl = "http://127.0.0.1:$legacyPort"
|
|
$webBaseUrl = "http://127.0.0.1:$webPort"
|
|
$nativeAuthBaseUrl = "http://127.0.0.1:$nativeAuthPort"
|
|
|
|
$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()
|
|
|
|
$registrationSwitchProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
|
'tests/helpers/enable-self-registration.mjs', $smokeDatabasePath
|
|
) -Environment $nodeEnvironment
|
|
if (-not $registrationSwitchProcess.WaitForExit(10000)) {
|
|
$registrationSwitchProcess.Kill($true)
|
|
throw 'Timed out while enabling self-registration in the smoke-test database'
|
|
}
|
|
$registrationSwitchOutput = $registrationSwitchProcess.StandardOutput.ReadToEnd()
|
|
$registrationSwitchError = $registrationSwitchProcess.StandardError.ReadToEnd()
|
|
if ($registrationSwitchProcess.ExitCode -ne 0) {
|
|
throw "Could not enable self-registration in the smoke-test database`n$registrationSwitchOutput`n$registrationSwitchError"
|
|
}
|
|
$registrationSwitchProcess.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'
|
|
}
|
|
|
|
$nativeAuthProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
|
|
'run',
|
|
'--project', $projectPath,
|
|
'--configuration', 'Release',
|
|
'--no-build',
|
|
'--no-launch-profile',
|
|
'--',
|
|
'--urls', $nativeAuthBaseUrl
|
|
) -Environment @{
|
|
ASPNETCORE_ENVIRONMENT = 'Development'
|
|
AUTH_NATIVE_ENABLED = 'true'
|
|
CANDIDATE_NATIVE_ENABLED = 'true'
|
|
CANDIDATE_NATIVE_ALLOW_MEMORY = 'true'
|
|
LegacyNode__Enabled = 'true'
|
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
|
DATABASE_CLIENT = 'sqlite'
|
|
SQLITE_PATH = $smokeDatabasePath
|
|
TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
|
|
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
|
|
REDIS_URL = ''
|
|
REDIS_SESSION_URL = ''
|
|
}
|
|
Wait-ForUrl -Uri "$nativeAuthBaseUrl/health/live" -Processes @($nodeProcess, $nativeAuthProcess)
|
|
|
|
$anonymousCandidate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -SkipHttpErrorCheck
|
|
if ($anonymousCandidate.StatusCode -ne 401 -or $anonymousCandidate.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Native candidate API did not reject an anonymous request'
|
|
}
|
|
|
|
$nativeCandidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
|
$nativeCandidateLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $nativeCandidateSession
|
|
if ($nativeCandidateLogin.user.candidateNumber -ne '2026-HZ01-F-0001') {
|
|
throw 'Native authentication could not create the candidate parity-test session'
|
|
}
|
|
foreach ($candidateRoute in @('dashboard', 'notices', 'profile', 'exams', 'registrations')) {
|
|
$legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $candidateSession
|
|
$nativeCandidateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateRoute" -WebSession $nativeCandidateSession
|
|
if ($nativeCandidateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw "Candidate route '$candidateRoute' did not use the native ASP.NET Core endpoint"
|
|
}
|
|
Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'"
|
|
}
|
|
$legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $candidateSession
|
|
$nativeCandidateResultsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results" -WebSession $nativeCandidateSession
|
|
if ($nativeCandidateResultsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw "Candidate route 'results' did not use the native ASP.NET Core endpoint"
|
|
}
|
|
Assert-CandidateResultsEquivalent -Expected $legacyCandidateResultsResponse.Content -Actual $nativeCandidateResultsResponse.Content
|
|
$nativeCandidateResults = $nativeCandidateResultsResponse.Content | ConvertFrom-Json
|
|
if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) {
|
|
throw 'Native candidate results did not include a local PNG verification QR code'
|
|
}
|
|
$appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0]
|
|
if ($null -ne $appealableResult) {
|
|
$shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
|
if ($shortAppeal.StatusCode -ne 400 -or $shortAppeal.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Native score appeal did not validate the minimum reason length'
|
|
}
|
|
$appealBody = @{ reason = '迁移冒烟验证:成绩与个人估分差异较大,请复核计分。' } | ConvertTo-Json -Compress
|
|
$appealResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body $appealBody -WebSession $nativeCandidateSession
|
|
$appeal = $appealResponse.Content | ConvertFrom-Json
|
|
if ($appealResponse.StatusCode -ne 201 -or $appealResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $appeal.workflow.status -ne 'pending') {
|
|
throw 'Native score appeal did not persist a pending workflow'
|
|
}
|
|
}
|
|
$admitProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
|
'tests/helpers/prepare-candidate-admit-smoke.mjs', $smokeDatabasePath, '2026-HZ01-F-0001'
|
|
) -Environment $nodeEnvironment
|
|
if (-not $admitProcess.WaitForExit(10000)) {
|
|
$admitProcess.Kill($true)
|
|
throw 'Timed out while preparing the candidate admit-card smoke data'
|
|
}
|
|
$admitRegistrationId = $admitProcess.StandardOutput.ReadToEnd().Trim()
|
|
$admitError = $admitProcess.StandardError.ReadToEnd()
|
|
if ($admitProcess.ExitCode -ne 0 -or -not $admitRegistrationId) {
|
|
throw "Could not prepare the candidate admit-card smoke data`n$admitError"
|
|
}
|
|
$admitProcess.Dispose()
|
|
$legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $candidateSession
|
|
$nativeAdmitResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $nativeCandidateSession
|
|
if ($nativeAdmitResponse.StatusCode -ne 200 -or
|
|
$nativeAdmitResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
|
|
$nativeAdmitResponse.Headers['Content-Disposition'] -notmatch '^attachment;' -or
|
|
$nativeAdmitResponse.Content -notmatch '考试考场序号' -or
|
|
$nativeAdmitResponse.Content -ne $legacyAdmitResponse.Content) {
|
|
throw 'Native admit-card download did not match the legacy HTML document'
|
|
}
|
|
|
|
$registrationSchool = @($homePayload.schools | Select-Object -First 1)[0]
|
|
$registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | Select-Object -First 1)[0]
|
|
if ($null -eq $registrationSchool -or $null -eq $registrationClass) {
|
|
throw 'Seed data did not provide a source school and class for native registration'
|
|
}
|
|
$registrationBody = @{
|
|
name = '原生迁移注册考生'
|
|
gender = '女'
|
|
password = 'Registration456!'
|
|
schoolId = $registrationSchool.id
|
|
classId = $registrationClass.id
|
|
} | ConvertTo-Json -Compress
|
|
$registrationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/register" -Method Post -ContentType 'application/json' -Body $registrationBody
|
|
if ($registrationResponse.StatusCode -ne 201 -or $registrationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Self-registration request did not use the native ASP.NET Core endpoint'
|
|
}
|
|
$registration = $registrationResponse.Content | ConvertFrom-Json
|
|
$registeredLoginBody = @{ username = $registration.registrationNumber; password = 'Registration456!' } | ConvertTo-Json -Compress
|
|
$registeredSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
|
$registeredLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $registeredSession
|
|
if ($registeredLogin.user.candidateNumber -ne $registration.registrationNumber) {
|
|
throw 'Native self-registration did not create a usable candidate account'
|
|
}
|
|
$incompleteDashboard = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/dashboard" -WebSession $registeredSession -SkipHttpErrorCheck
|
|
if ($incompleteDashboard.StatusCode -ne 428) {
|
|
throw 'Native candidate API did not require completion of a newly registered profile'
|
|
}
|
|
$incompleteProfile = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $registeredSession
|
|
if ($incompleteProfile.StatusCode -ne 200) {
|
|
throw 'Native candidate profile route was not available during onboarding'
|
|
}
|
|
|
|
$invalidSpecialtyBody = @{
|
|
name = '原生迁移注册考生'
|
|
gender = '女'
|
|
idNumber = 'SMOKE-INVALID-SPECIALTY'
|
|
phone = '13800000000'
|
|
email = 'candidate@example.test'
|
|
nativePlace = '江苏连云港'
|
|
address = '迁移测试路 1 号'
|
|
schoolId = $registrationSchool.id
|
|
classId = $registrationClass.id
|
|
provinceCode = '320000'
|
|
cityCode = '320700'
|
|
districtCode = '320706'
|
|
specialtyCategory = 'arts'
|
|
specialtyType = 'track_field'
|
|
} | ConvertTo-Json -Compress
|
|
$invalidSpecialty = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $invalidSpecialtyBody -WebSession $registeredSession -SkipHttpErrorCheck
|
|
if ($invalidSpecialty.StatusCode -ne 400) {
|
|
throw 'Native candidate profile API accepted a mismatched specialty category and type'
|
|
}
|
|
|
|
$profileIdNumber = "SMOKE-NATIVE-$([guid]::NewGuid().ToString('N'))"
|
|
$profileBody = @{
|
|
name = '原生迁移注册考生'
|
|
gender = '女'
|
|
idNumber = $profileIdNumber
|
|
phone = '13800000000'
|
|
email = 'candidate@example.test'
|
|
address = '迁移测试路 1 号'
|
|
emergencyContact = '测试联系人'
|
|
emergencyPhone = '13900000000'
|
|
nativePlace = '江苏连云港'
|
|
birthDate = '2010-01-02'
|
|
ethnicity = '汉族'
|
|
postalCode = '222000'
|
|
guardianName = '测试监护人'
|
|
guardianPhone = '13700000000'
|
|
schoolId = $registrationSchool.id
|
|
classId = $registrationClass.id
|
|
provinceCode = '320000'
|
|
cityCode = '320700'
|
|
districtCode = '320706'
|
|
specialtyCategory = ''
|
|
specialtyType = ''
|
|
specialtyCertificate = ''
|
|
policyEligibility = ''
|
|
} | ConvertTo-Json -Compress
|
|
$profileUpdateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $profileBody -WebSession $registeredSession
|
|
if ($profileUpdateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Candidate profile update did not use the native ASP.NET Core endpoint'
|
|
}
|
|
$profileUpdate = $profileUpdateResponse.Content | ConvertFrom-Json
|
|
if ($profileUpdate.profile.profileCompleted -ne $true -or $profileUpdate.profile.districtName -ne '海州区' -or $profileUpdate.profile.status -ne 'pending') {
|
|
throw 'Native candidate profile update did not resolve the region or persist onboarding state'
|
|
}
|
|
|
|
$prematureRegistrationBody = @{ examId = 'not-approved-yet'; subjectIds = @('none') } | ConvertTo-Json -Compress
|
|
$prematureRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $prematureRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
|
|
if ($prematureRegistration.StatusCode -ne 403) {
|
|
throw 'Native registration API did not require an approved candidate profile'
|
|
}
|
|
|
|
$candidateWriteSetup = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
|
'tests/helpers/prepare-candidate-write-smoke.mjs', $smokeDatabasePath, $registration.registrationNumber
|
|
) -Environment $nodeEnvironment
|
|
if (-not $candidateWriteSetup.WaitForExit(10000)) {
|
|
$candidateWriteSetup.Kill($true)
|
|
throw 'Timed out while preparing the native candidate write smoke test'
|
|
}
|
|
$candidateWriteSetupOutput = $candidateWriteSetup.StandardOutput.ReadToEnd().Trim()
|
|
$candidateWriteSetupError = $candidateWriteSetup.StandardError.ReadToEnd()
|
|
if ($candidateWriteSetup.ExitCode -ne 0) {
|
|
throw "Could not prepare the native candidate write smoke test`n$candidateWriteSetupError"
|
|
}
|
|
$candidateWriteSetup.Dispose()
|
|
$candidateWriteTarget = $candidateWriteSetupOutput | ConvertFrom-Json
|
|
|
|
$nativeRegistrationBody = @{
|
|
examId = $candidateWriteTarget.examId
|
|
subjectIds = @($candidateWriteTarget.subjectId)
|
|
} | ConvertTo-Json -Compress
|
|
$nativeRegistrationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession
|
|
if ($nativeRegistrationResponse.StatusCode -ne 201 -or $nativeRegistrationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Exam registration submission did not use the native ASP.NET Core endpoint'
|
|
}
|
|
$nativeRegistration = $nativeRegistrationResponse.Content | ConvertFrom-Json
|
|
if ($nativeRegistration.registration.status -ne 'pending' -or $nativeRegistration.registration.subjectIds.Count -ne 1) {
|
|
throw 'Native exam registration did not persist the selected subject and pending state'
|
|
}
|
|
$duplicateRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
|
|
if ($duplicateRegistration.StatusCode -ne 409) {
|
|
throw 'Native exam registration did not reject a duplicate submission'
|
|
}
|
|
|
|
$legacyRegisteredSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
|
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $legacyRegisteredSession | Out-Null
|
|
foreach ($candidateWriteReadRoute in @('profile', 'registrations')) {
|
|
$legacyWriteRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $legacyRegisteredSession
|
|
$nativeWriteRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $registeredSession
|
|
Assert-JsonEquivalent -Expected $legacyWriteRead.Content -Actual $nativeWriteRead.Content -Label "Candidate write follow-up '$candidateWriteReadRoute'"
|
|
}
|
|
|
|
$nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
|
$nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession
|
|
if ($nativeLoginResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
|
throw 'Authentication login request did not use the native ASP.NET Core endpoint'
|
|
}
|
|
$nativeLogin = $nativeLoginResponse.Content | ConvertFrom-Json
|
|
if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
|
|
throw 'Native authentication could not verify the existing Node PBKDF2 account'
|
|
}
|
|
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
|
|
if ($adminCandidateRoute.StatusCode -ne 403) {
|
|
throw 'Native candidate API did not enforce the candidate role boundary'
|
|
}
|
|
|
|
$nativeMe = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $nativeSession
|
|
if ($nativeMe.user.username -ne 'admin' -or $nativeMe.permissions[0] -ne '*') {
|
|
throw 'Native authentication did not preserve the session or administrator projection'
|
|
}
|
|
|
|
$totpSetupBody = @{ currentPassword = '12345678' } | ConvertTo-Json -Compress
|
|
$totpSetup = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/setup" -Method Post -ContentType 'application/json' -Body $totpSetupBody -WebSession $nativeSession
|
|
if ($totpSetup.qrCode -notmatch '^data:image/png;base64,' -or $totpSetup.uri -notmatch '^otpauth://totp/') {
|
|
throw 'Native TOTP setup did not return a local PNG QR code and otpauth URI'
|
|
}
|
|
|
|
$totpCodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
|
'tests/helpers/current-totp-code.mjs', $totpSetup.secret
|
|
) -Environment $nodeEnvironment
|
|
if (-not $totpCodeProcess.WaitForExit(10000)) {
|
|
$totpCodeProcess.Kill($true)
|
|
throw 'Timed out while generating the native TOTP smoke-test code'
|
|
}
|
|
$totpCode = $totpCodeProcess.StandardOutput.ReadToEnd().Trim()
|
|
$totpCodeError = $totpCodeProcess.StandardError.ReadToEnd()
|
|
if ($totpCodeProcess.ExitCode -ne 0 -or -not $totpCode) {
|
|
throw "Could not generate the native TOTP smoke-test code`n$totpCodeError"
|
|
}
|
|
$totpCodeProcess.Dispose()
|
|
|
|
$totpEnableBody = @{ code = $totpCode } | ConvertTo-Json -Compress
|
|
$totpEnable = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/enable" -Method Post -ContentType 'application/json' -Body $totpEnableBody -WebSession $nativeSession
|
|
if ($totpEnable.user.totpEnabled -ne $true -or $totpEnable.recoveryCodes.Count -ne 8) {
|
|
throw 'Native TOTP enablement did not persist security state or issue eight recovery codes'
|
|
}
|
|
|
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/logout" -Method Post -WebSession $nativeSession | Out-Null
|
|
$totpPasswordLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody
|
|
if ($totpPasswordLogin.requiresTotp -ne $true -or -not $totpPasswordLogin.challenge) {
|
|
throw 'Native password login bypassed enabled TOTP'
|
|
}
|
|
|
|
$totpLoginSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
|
$recoveryLoginBody = @{ challenge = $totpPasswordLogin.challenge; code = $totpEnable.recoveryCodes[0] } | ConvertTo-Json -Compress
|
|
$totpLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login/totp" -Method Post -ContentType 'application/json' -Body $recoveryLoginBody -WebSession $totpLoginSession
|
|
if ($totpLogin.usedRecoveryCode -ne $true -or $totpLogin.user.username -ne 'admin') {
|
|
throw 'Native TOTP recovery-code login did not create a session'
|
|
}
|
|
|
|
$totpStatus = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp" -WebSession $totpLoginSession
|
|
if ($totpStatus.enabled -ne $true -or $totpStatus.recoveryCodesRemaining -ne 7) {
|
|
throw 'Native TOTP recovery code was not consumed exactly once'
|
|
}
|
|
|
|
$disableBody = @{ currentPassword = '12345678'; code = $totpEnable.recoveryCodes[1] } | ConvertTo-Json -Compress
|
|
$disabled = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/disable" -Method Post -ContentType 'application/json' -Body $disableBody -WebSession $totpLoginSession
|
|
if ($disabled.user.totpEnabled -ne $false) {
|
|
throw 'Native TOTP disable endpoint did not clear the security state'
|
|
}
|
|
|
|
$changePasswordBody = @{ currentPassword = '12345678'; newPassword = 'MigrationAuth456!' } | ConvertTo-Json -Compress
|
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/change-password" -Method Post -ContentType 'application/json' -Body $changePasswordBody -WebSession $totpLoginSession | Out-Null
|
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/logout" -Method Post -WebSession $totpLoginSession | Out-Null
|
|
$changedLoginBody = @{ username = 'admin'; password = 'MigrationAuth456!' } | ConvertTo-Json -Compress
|
|
$changedLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $changedLoginBody
|
|
if ($changedLogin.user.username -ne 'admin') {
|
|
throw 'Native password change did not create a Node-compatible PBKDF2 hash'
|
|
}
|
|
|
|
[pscustomobject]@{
|
|
AspNetCoreHost = 'passed'
|
|
StaticAssets = 'passed'
|
|
JsonProxy = 'passed'
|
|
SessionCookie = 'passed'
|
|
NativePublicApi = 'passed'
|
|
PublicParity = 'passed'
|
|
DocumentCodes = 'passed'
|
|
NativeAuthentication = 'passed'
|
|
NativeCandidateReads = 'passed'
|
|
NativeCandidateWrites = 'passed'
|
|
NativeCandidateDocuments = 'passed'
|
|
} | Format-List
|
|
}
|
|
finally {
|
|
foreach ($process in @($nativeAuthProcess, $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
|
|
}
|
|
}
|
|
}
|
|
}
|