# ============================================================================ # 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' }