You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

262 lines
19 KiB

# ============================================================================
# V35 RIASEC assessment BLACK-BOX ACCEPTANCE (real MySQL/Docker) - rev2
# Scenario isolation: each scenario uses a freshly auto-created mock student.
# No secrets printed; no business data deleted; only appends assessment rows.
# ============================================================================
$ErrorActionPreference = 'Continue'
Import-Module ThreadJob -ErrorAction SilentlyContinue
$BASE = 'http://127.0.0.1:8080/api/v1'
$LOG = 'C:\Users\15642\Desktop\zhilai\backend-infra\app.log'
$ENV = 'C:\Users\15642\Desktop\zhilai\backend-infra\.env'
$RMQ_API = 'http://127.0.0.1:15672/api/queues/%2F'
$kv=@{}; Get-Content $ENV | ForEach-Object{ if($_ -match '^\s*([^#][^=]*?)\s*=\s*(.*)\s*$'){ $kv[$matches[1].Trim()]=$matches[2].Trim() } }
$env:MPW=$kv['MYSQL_PASSWORD']; $RMQ_USER=$kv['RABBITMQ_USER']; $RMQ_PASS=$kv['RABBITMQ_PASS']
function MySQL($sql){ docker exec -e MYSQL_PWD=$env:MPW mydreamjob-mysql mysql -umydreamjob -N mydreamjob -e $sql 2>&1 }
function FirstOf($sql){ $r=(MySQL $sql | Select-Object -First 1); if($null -eq $r){return ''}; (($r -split '\s+')[0]).Trim() }
function PhoneOfRole($role){
# ADMIN uses a separate query; do not apply the pure-COMPANY exclusion.
if($role -eq 'ADMIN'){
FirstOf ("SELECT u.phone FROM users u JOIN user_roles ur ON ur.user_id=u.id AND ur.role='ADMIN' ORDER BY u.id LIMIT 1");
} else {
FirstOf ("SELECT u.phone FROM users u JOIN user_roles ur ON ur.user_id=u.id AND ur.role='$role' AND u.id NOT IN (SELECT user_id FROM user_roles WHERE role IN ('STUDENT','ADMIN')) LIMIT 1");
}
}
$pass=0;$fail=0;$skip=0;$failures=@()
function Chk($n,$c,$d){ if($c){$script:pass++;Write-Output "PASS - $n"}else{$script:fail++;$script:failures+="$n :: $d";Write-Output "FAIL - $n | $d"} }
function ChkSkip($n,$d){ $script:skip++;Write-Output "SKIP - $n | $d" }
function Api($method,$url,$token,$body){
$h=@{}; if($token){$h['Authorization']="Bearer $token"}
if($null -ne $body){$h['Content-Type']='application/json'}
try { $a=@{Method=$method;Uri=$url;Headers=$h;TimeoutSec=30;UseBasicParsing=$true}; if($null -ne $body){$a.Body=($body|ConvertTo-Json -Depth 8)}; $r=Invoke-WebRequest @a; $j=$null;try{$j=$r.Content|ConvertFrom-Json}catch{}; return @{status=[int]$r.StatusCode;json=$j;raw=$r.Content} }
catch { $c='';if($_.ErrorDetails -and $_.ErrorDetails.Message){$c=$_.ErrorDetails.Message};$st=if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1};$j=$null;try{$j=$c|ConvertFrom-Json}catch{};return @{status=$st;json=$j;raw=$c} }
}
function F($o,$n){ if($null -eq $o){return $null}; if($o.PSObject.Properties.Name -contains $n){return $o.$n}; return $null }
function Login($phone){
for($i=0;$i -lt 3;$i++){
try{ Invoke-RestMethod -Uri "$BASE/auth/sms/send?phone=$phone&purpose=login" -Method Post -TimeoutSec 15 | Out-Null }catch{}
Start-Sleep -Milliseconds 900
$m = Get-Content $LOG -ErrorAction SilentlyContinue | Select-String -Pattern "MOCK SMS CODE: phone=$phone, purpose=login, code=([0-9]{6})"
if($m){ $code=$m[-1].Matches[0].Groups[1].Value; $b=@{phone=$phone;code=$code;purpose='login'}|ConvertTo-Json
try{ $t=(Invoke-RestMethod -Uri "$BASE/auth/login" -Method Post -ContentType 'application/json' -Body $b -TimeoutSec 15).data.accessToken; if($t -and "$t".Length -gt 50){return $t} }catch{} }
Start-Sleep -Milliseconds 600
}
return $null
}
function RmqStat($name){
$q=$null; try{ $q=(& curl.exe -s -u "${RMQ_USER}:${RMQ_PASS}" "$RMQ_API" 2>&1 | Out-String)|ConvertFrom-Json }catch{}
if($null -eq $q){return @{ready=-1;unacked=-1}}
$h=$q|Where-Object{$_.name -eq $name}|Select-Object -First 1
# Missing queue is not an empty queue; return -1 so it cannot pass as ready=0.
if($null -eq $h){return @{ready=-1;unacked=-1}}
return @{ready=[long]$h.messages;unacked=[long]$h.messages_unacknowledged}
}
function NewStudent(){ $ph='188'+('{0:D8}' -f (Get-Random -Minimum 1000000 -Maximum 99999999)); $t=Login $ph; $id=FirstOf "SELECT id FROM users WHERE phone='$ph'"; return @{token=$t;phone=$ph;id=$id} }
Write-Output "===== V35 ASSESSMENT BLACK-BOX (real MySQL) rev2 ====="
# ---------- shared role tokens ----------
$companyTok = Login (PhoneOfRole 'COMPANY')
$adminTok = Login (PhoneOfRole 'ADMIN')
# ============ S1 main flow (fresh student) ============
Write-Output '== S1 main flow =='
$s1 = NewStudent; $tok=$s1.token
Chk 'S1 fresh student login' ($null -ne $tok)
if(-not $tok){ exit 1 }
$k = 'bb1-'+[guid]::NewGuid().ToString('N')
$c = Api 'POST' "$BASE/assessments/attempts" $tok @{typeCode='HOLLAND_RIASEC';idempotencyKey=$k}
$aid = F $c.json.data 'attemptId'; $v = F $c.json.data 'version'
Chk 'S1 create 200' ($c.status -eq 200) $c.status
Chk 'S1 totalQuestions=30' ((F $c.json.data 'totalQuestions') -eq 30) (F $c.json.data 'totalQuestions')
Chk 'S1 questions length=30' (@($c.json.data.questions).Count -eq 30) (@($c.json.data.questions).Count)
$cnt = FirstOf "SELECT COUNT(*) FROM assessment_attempt_questions WHERE attempt_id=$aid"
Chk 'S1 DB attempt_questions=30' ($cnt -eq 30) $cnt
$dimMap=@{}; foreach($d in (MySQL "SELECT q.dimension,COUNT(*) FROM assessment_attempt_questions aq JOIN assessment_questions q ON q.id=aq.question_id WHERE aq.attempt_id=$aid GROUP BY q.dimension")){ $p=($d -split '\s+'); if($p -and $p[0] -and $p[1]){$dimMap[$p[0]]=[int]$p[1]} }
Chk 'S1 DB 6 dims x5' ((@('A','C','E','I','R','S' | ForEach-Object{$dimMap[$_]-eq 5}) -contains $false) -eq $false) ($dimMap|ConvertTo-Json -Compress)
$qids=@($c.json.data.questions|ForEach-Object{$_.id})
# mid-save 10
$p1 = Api 'PATCH' "$BASE/assessments/attempts/$aid/answers" $tok @{version=$v;answers=(@($qids|Select-Object -First 10)|ForEach-Object{@{questionId=$_;value=4}})}
Chk 'S1 PATCH partial 200 answered=10' ($p1.status -eq 200 -and (F $p1.json.data 'answeredCount') -eq 10) "$($p1.status)/$(F $p1.json.data 'answeredCount')"
$g = Api 'GET' "$BASE/assessments/attempts/$aid" $tok
$gids=@($g.json.data.questions|ForEach-Object{$_.id})
Chk 'S1 GET order unchanged' (($gids -join ',') -eq ($qids -join ','))
Chk 'S1 GET restored 10' (@($g.json.data.questions|Where-Object{$null -ne $_.value}).Count -eq 10)
$nv = F $g.json.data 'version'
# 29-ish submit (only 10 answered) -> 9706
$s29 = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 10/30 submit -> 400/9706' ($s29.status -eq 400 -and (F $s29.json 'code') -eq 9706) "$($s29.status)/$(F $s29.json 'code')"
# full 30
$full = Api 'PATCH' "$BASE/assessments/attempts/$aid/answers" $tok @{version=$nv;answers=(@($qids)|ForEach-Object{@{questionId=$_;value=4}})}
$fv = F $full.json.data 'version'
$sub = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 submit 200 COMPLETED' ($sub.status -eq 200 -and (F $sub.json.data 'status') -eq 'COMPLETED') "$($sub.status)/$(F $sub.json.data 'status')"
Chk 'S1 result primaryCode present' ($null -ne (F $sub.json.data 'primaryCode')) (F $sub.json.data 'primaryCode')
Chk 'S1 result disclaimer' ($null -ne (F $sub.json.data 'disclaimer'))
$sub2 = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 repeat submit same result' ($sub2.status -eq 200 -and (F $sub2.json.data 'primaryCode') -eq (F $sub.json.data 'primaryCode')) "$($sub2.status)"
Chk 'S1 DB result count=1' ((FirstOf "SELECT COUNT(*) FROM assessment_results WHERE attempt_id=$aid") -eq 1)
Chk 'S1 result GET 200' ((Api 'GET' "$BASE/assessments/attempts/$aid/result" $tok).status -eq 200)
# active_key released
Chk 'S1 active_key released' (((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE id=$aid AND active_key IS NULL") -eq 1))
# non-owner 403
$sOther = NewStudent
Chk 'S1 non-owner GET attempt 403/2002' ((Api 'GET' "$BASE/assessments/attempts/$aid" $sOther.token).status -eq 403)
# ============ S2 duplicate active (fresh student) ============
Write-Output '== S2 duplicate active =='
$s2=NewStudent; $a1=Api 'POST' "$BASE/assessments/attempts" $s2.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb2a-'+[guid]::NewGuid().ToString('N'))}
Chk 'S2 first create 200' ($a1.status -eq 200) $a1.status
$dup=Api 'POST' "$BASE/assessments/attempts" $s2.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb2b-'+[guid]::NewGuid().ToString('N'))}
Chk 'S2 duplicate active -> 409/9708' ($dup.status -eq 409 -and (F $dup.json 'code') -eq 9708) "$($dup.status)/$(F $dup.json 'code')"
# verify only one DRAFT row in DB
Chk 'S2 DB only one active' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$($s2.id) AND status IN ('DRAFT','IN_PROGRESS') AND active_key IS NOT NULL") -eq 1)
# ============ S3 idempotency replay ============
Write-Output '== S3 idempotency replay =='
$s3=NewStudent; $kk='bb3-' + [guid]::NewGuid().ToString('N')
$r1=Api 'POST' "$BASE/assessments/attempts" $s3.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=$kk}
$r2=Api 'POST' "$BASE/assessments/attempts" $s3.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=$kk}
Chk 'S3 same key same body -> same attempt' ((F $r1.json.data 'attemptId') -eq (F $r2.json.data 'attemptId')) "$(F $r1.json.data 'attemptId') vs $(F $r2.json.data 'attemptId')"
# ============ S4 permissions ============
Write-Output '== S4 permissions =='
$pc = Api 'POST' "$BASE/assessments/attempts" $companyTok @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4c-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 COMPANY create 403/2002' ($pc.status -eq 403 -and (F $pc.json 'code') -eq 2002) "$($pc.status)/$(F $pc.json 'code')"
$pn = Api 'POST' "$BASE/assessments/attempts" $null @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4n-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 anonymous create 401' ($pn.status -eq 401) $pn.status
# INACTIVE: use a REAL inactive definition if present in DB; otherwise SKIP (never use unknown type NOPE as substitute)
$inactiveType = FirstOf "SELECT type_code FROM assessment_definitions WHERE status='INACTIVE' LIMIT 1"
if($inactiveType){
$pi = Api 'POST' "$BASE/assessments/attempts" $tok @{typeCode=$inactiveType;idempotencyKey=('bb4i-'+[guid]::NewGuid().ToString('N'))}
Chk "S4 INACTIVE($inactiveType) create 404/9701" ($pi.status -eq 404 -and (F $pi.json 'code') -eq 9701) "$($pi.status)/$(F $pi.json 'code')"
} else { ChkSkip 'S4 INACTIVE type create 404/9701' 'no INACTIVE definition seed in DB; NOT substituting unknown type' }
# ADMIN black-box coverage: if no ADMIN account exists -> explicit SKIP (not PASS)
if($adminTok){
$pa = Api 'POST' "$BASE/assessments/attempts" $adminTok @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4a-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 ADMIN create 403/2002' ($pa.status -eq 403 -and (F $pa.json 'code') -eq 2002) "$($pa.status)/$(F $pa.json 'code')"
} else { ChkSkip 'S4 ADMIN create 403/2002' 'no ADMIN account in DB; ADMIN black-box not covered (covered by unit test AssessmentAttemptServiceTest.adminUser_create_returnsForbidden)' }
# ============ S5 concurrency (fresh student) ============
Write-Output '== S5 concurrent create + submit =='
$s5=NewStudent
$s5tok = $s5.token; $s5id = $s5.id
$csBefore = [long](FirstOf "SELECT COUNT(*) FROM candidate_dimension_scores")
$nThreads=4
$tmpDir = Join-Path $env:TEMP ('v35bb-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
# --- concurrent create (different idempotency keys): capture real status + code per request ---
$createSb = {
param($base,$tk,$body,$outFile)
function W($st,$code){ [System.IO.File]::WriteAllText($outFile, ("$st|"+$code)) }
try {
$resp = Invoke-WebRequest -Uri "$base/assessments/attempts" -Method Post -Headers @{Authorization="Bearer $tk"} -ContentType 'application/json' -Body $body -UseBasicParsing -TimeoutSec 30
$code=-1; try { $j=$resp.Content|ConvertFrom-Json; $code=[int]$j.code } catch {}
W $resp.StatusCode $code
} catch {
$st = if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1}
$code=-1
if($_.ErrorDetails -and $_.ErrorDetails.Message){ try { $j=$_.ErrorDetails.Message|ConvertFrom-Json; $code=[int]$j.code } catch {} }
W $st $code
}
}
$pool=[runspacefactory]::CreateRunspacePool(1,$nThreads); $pool.Open()
$jobs=@()
foreach($i in 1..$nThreads){
$body = @{typeCode='HOLLAND_RIASEC';idempotencyKey=('ccr-'+$i+'-'+[guid]::NewGuid().ToString('N'))}|ConvertTo-Json -Compress
$out = Join-Path $tmpDir ("c$i.txt")
$rs=[powershell]::Create(); $rs.RunspacePool=$pool
$null=$rs.AddScript($createSb).AddArgument($BASE).AddArgument($s5tok).AddArgument($body).AddArgument($out)
$jobs += ,@($rs,$rs.BeginInvoke())
}
foreach($j in $jobs){ $j[0].EndInvoke($j[1])|Out-Null; $j[0].Dispose() }
$pool.Close()
$cresults=@()
foreach($i in 1..$nThreads){
$f=Join-Path $tmpDir ("c$i.txt")
if(Test-Path $f){
$p=(Get-Content $f -Raw) -replace '\s+','' -split '\|'
if($p.Count -ge 2){ $cresults += [pscustomobject]@{status=[int]$p[0];code=[int]$p[1]} }
}
}
$cstat=@($cresults | ForEach-Object {$_.status})
$ccode=@($cresults | ForEach-Object {$_.code})
Write-Output ("S5 concurrent create pairs: " + (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ','))
$nWinner = @($cresults | Where-Object {$_.status -eq 200}).Count
$nLoser = @($cresults | Where-Object {$_.status -eq 409 -and $_.code -eq 9708}).Count
$badCreate = @($cresults | Where-Object {$_.status -ne 200 -and -not ($_.status -eq 409 -and $_.code -eq 9708)}).Count
Chk 'S5 concurrent create: exactly one 200' ($cresults.Count -eq $nThreads -and $nWinner -eq 1) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent create: every loser is 409/9708' ($nLoser -eq ($nThreads-1) -and $badCreate -eq 0) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent create: no 500/-1/empty' ($cresults.Count -eq $nThreads -and @($cresults|Where-Object{$_.status -eq 500 -or $_.status -eq -1 -or $_.code -eq -1}).Count -eq 0) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 DB: exactly one attempt persisted' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$s5id") -eq 1)
Chk 'S5 DB: exactly one active attempt' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$s5id AND active_key IS NOT NULL") -eq 1)
# --- concurrent submit (2 threads) on the winning active attempt: allowed 200, no 500 ---
$winAid = FirstOf "SELECT id FROM assessment_attempts WHERE student_user_id=$s5id AND active_key IS NOT NULL LIMIT 1"
if($winAid){
$s5g = Api 'GET' "$BASE/assessments/attempts/$winAid" $s5tok
$s5all = @($s5g.json.data.questions | ForEach-Object { @{questionId=$_.id; value=4} })
Api 'PATCH' "$BASE/assessments/attempts/$winAid/answers" $s5tok @{version=(F $s5g.json.data 'version');answers=$s5all} | Out-Null
$submitSb = {
param($base,$tk,$aid,$outFile)
function W2($st,$code){ [System.IO.File]::WriteAllText($outFile, ("$st|$code")) }
try {
$resp=Invoke-WebRequest -Uri "$base/assessments/attempts/$aid/submit" -Method Post -Headers @{Authorization="Bearer $tk"} -UseBasicParsing -TimeoutSec 30
$code=-1; try { $j=$resp.Content|ConvertFrom-Json; $code=[int]$j.code } catch {}
W2 ([int]$resp.StatusCode) $code
}
catch {
$st=if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1}
$code=-1
if($_.ErrorDetails -and $_.ErrorDetails.Message){ try { $j=$_.ErrorDetails.Message|ConvertFrom-Json; $code=[int]$j.code } catch {} }
W2 $st $code
}
}
$pool2=[runspacefactory]::CreateRunspacePool(1,2); $pool2.Open()
$j2=@(); foreach($i in 1..2){ $out=Join-Path $tmpDir ("s$i.txt"); $rs=[powershell]::Create(); $rs.RunspacePool=$pool2; $null=$rs.AddScript($submitSb).AddArgument($BASE).AddArgument($s5tok).AddArgument($winAid).AddArgument($out); $j2 += ,@($rs,$rs.BeginInvoke()) }
foreach($j in $j2){ $j[0].EndInvoke($j[1])|Out-Null; $j[0].Dispose() }; $pool2.Close()
$sresults=@()
foreach($i in 1..2){
$f=Join-Path $tmpDir ("s$i.txt")
if(Test-Path $f){
$p=(Get-Content $f -Raw) -replace '\s+','' -split '\|'
if($p.Count -ge 2){ $sresults += [pscustomobject]@{status=[int]$p[0];code=[int]$p[1]} }
}
}
Write-Output ("S5 concurrent submit pairs: " + (($sresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ','))
# Submit is idempotent: success is 200; a race-window conflict may only be 409/9703.
$validSubmit = @($sresults | Where-Object { $_.status -eq 200 -or ($_.status -eq 409 -and $_.code -eq 9703) }).Count
Chk 'S5 concurrent submit: only 200 or 409/9703' ($sresults.Count -eq 2 -and $validSubmit -eq 2) (($sresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent submit: only one result' ((FirstOf "SELECT COUNT(*) FROM assessment_results WHERE attempt_id=$winAid") -eq 1)
Chk 'S5 concurrent submit: only one audit' ((FirstOf "SELECT COUNT(*) FROM audit_logs WHERE entity_id=$winAid AND action='ASSESSMENT_ATTEMPT_SUBMITTED'") -eq 1)
} else { ChkSkip 'S5 concurrent submit' 'no winning active attempt found' }
$csAfter = [long](FirstOf "SELECT COUNT(*) FROM candidate_dimension_scores")
Chk 'S5 candidate_dimension_scores unchanged' ($csBefore -eq $csAfter) "$csBefore vs $csAfter"
# ============ S6 audit desensitization (scan student audits) ============
Write-Output '== S6 audit desensitization =='
$phones = @($s1.phone,$s2.phone,$s3.phone,$s5.phone)
$bad=''
$auditRows = @(MySQL "SELECT details FROM audit_logs WHERE user_id IN ($($s1.id),$($s2.id),$($s5.id)) AND action IN ('ASSESSMENT_ATTEMPT_CREATED','ASSESSMENT_ATTEMPT_SUBMITTED') ORDER BY id DESC LIMIT 40")
foreach($row in $auditRows){
$hit=$false; foreach($ph in $phones){ if($ph -and $row -like "*$ph*"){ $hit=$true;break } }
if($hit){ $bad='leaked phone'; break }
if($row -match '答案|我对|Bearer|password|secret|email=|JWT|accessToken'){ $bad=$row; break }
}
Chk 'S6 audit details no phone/email/password/token/stem' ($bad -eq '') $(if($bad){$bad.Substring(0,[Math]::Min(80,$bad.Length))}else{'clean'})
Chk 'S6 submit audit rows written' (@($auditRows | Where-Object { $_ -match 'answeredCount' }).Count -ge 1)
# ============ S7 rabbitmq ============
Write-Output '== S7 rabbitmq final =='
$n1=RmqStat 'queue.notification'; $n2=RmqStat 'queue.audit'; $n3=RmqStat 'queue.notification.dlq'
Chk 'S7 queue.notification ready/unacked=0' ($n1.ready -eq 0 -and $n1.unacked -eq 0) "r=$($n1.ready) u=$($n1.unacked)"
Chk 'S7 queue.audit ready/unacked=0' ($n2.ready -eq 0 -and $n2.unacked -eq 0) "r=$($n2.ready) u=$($n2.unacked)"
Chk 'S7 queue.notification.dlq ready/unacked=0' ($n3.ready -eq 0 -and $n3.unacked -eq 0) "r=$($n3.ready) u=$($n3.unacked)"
Write-Output "============================================================"
Write-Output "SUMMARY PASS=$pass FAIL=$fail SKIP=$skip"
if($failures.Count -gt 0){ Write-Output 'FAILURES:'; $failures|ForEach-Object{Write-Output " - $_"} }else{ Write-Output 'NO FAILURES' }