<# IT-1 / M0 end-to-end verification (DV-1 "M0 test script"). Maps to docs/apartment/07 table 7-3 M0 and docs/apartment/13 table 13-11. Prerequisites: 1) Copy dev/.env.example to dev/scripts/.env and set APT_DB_PASSWORD / APT_TOKEN_SECRET. 2) MySQL apt_rev01 and Redis database=1 reachable; run dev/sql/apt_rev01/09_it1_permissions.sql. 3) Run mvn -pl ruoyi-admin -am package to build ruoyi-admin/target/ruoyi-admin.jar. Usage: powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it1-m0.ps1 powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it1-m0.ps1 -SkipStartup Notes: captcha switch is NOT disabled and sys_config is NOT modified. The captcha answer is read (read-only) from Redis database=1 key captcha_codes:. Login uses admin and the apt_guest account created by 09_it1_permissions.sql; both use the public RuoYi template password. Remove apt_guest before production. #> param( [switch]$SkipStartup, [string]$BaseUrl = 'http://127.0.0.1:8080', [string]$JavaPath = '', [string]$AdminPassword = 'admin123', [string]$GuestPassword = 'admin123', [int]$ReadyAttempts = 60 ) $ErrorActionPreference = 'Stop' $devDir = Split-Path $PSScriptRoot -Parent $backendDir = Join-Path $devDir 'RuoYi-Vue-master' $jar = Join-Path $backendDir 'ruoyi-admin/target/ruoyi-admin.jar' # ---------- 1. Credentials ---------- $envFile = Join-Path $PSScriptRoot '.env' if (-not [System.IO.File]::Exists($envFile)) { $envFile = Join-Path $devDir '.env' } if (-not [System.IO.File]::Exists($envFile)) { throw 'Missing credentials file: copy dev/.env.example to dev/scripts/.env and set APT_DB_PASSWORD / APT_TOKEN_SECRET' } Write-Host "Using credentials file: $envFile" Get-Content -LiteralPath $envFile | ForEach-Object { $line = $_.Trim() if ($line -eq '' -or $line.StartsWith('#')) { return } if ($line -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') } } foreach ($name in @('APT_DB_PASSWORD', 'APT_TOKEN_SECRET')) { if ([string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable($name, 'Process'))) { throw "Missing required environment variable $name" } } # jjwt signWith(HS512, secret) expects a Base64-encoded key of at least 64 bytes. # A placeholder text in .env causes an ArrayIndexOutOfBoundsException on login. # Normalize for this verification run only; the .env file is left untouched. $secretValue = [Environment]::GetEnvironmentVariable('APT_TOKEN_SECRET', 'Process') $secretValid = $false try { $decoded = [System.Convert]::FromBase64String($secretValue) if ($decoded.Length -ge 64) { $secretValid = $true } } catch { $secretValid = $false } if (-not $secretValid) { $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider $keyBytes = New-Object byte[] 64 $rng.GetBytes($keyBytes) $rng.Dispose() [Environment]::SetEnvironmentVariable('APT_TOKEN_SECRET', [System.Convert]::ToBase64String($keyBytes), 'Process') Write-Host 'APT_TOKEN_SECRET was not a valid Base64 HS512 key; generated an ephemeral one for this run.' } # ---------- 2. Redis RESP read-only helper (captcha answer) ---------- function Send-RedisCommand { param([System.Net.Sockets.NetworkStream]$Stream, [string[]]$Parts) $sb = [System.Text.StringBuilder]::new() [void]$sb.Append('*').Append($Parts.Count).Append("`r`n") foreach ($p in $Parts) { $n = [System.Text.Encoding]::UTF8.GetByteCount($p) [void]$sb.Append('$').Append($n).Append("`r`n").Append($p).Append("`r`n") } $bytes = [System.Text.Encoding]::UTF8.GetBytes($sb.ToString()) $Stream.Write($bytes, 0, $bytes.Length) $Stream.Flush() } function Read-RedisLine { param([System.Net.Sockets.NetworkStream]$Stream) $sb = [System.Text.StringBuilder]::new() while ($true) { $b = $Stream.ReadByte() if ($b -lt 0) { throw 'Redis connection closed unexpectedly' } if ($b -eq 13) { [void]$Stream.ReadByte(); break } [void]$sb.Append([char]$b) } return $sb.ToString() } function Read-RedisBulk { param([System.Net.Sockets.NetworkStream]$Stream) $line = Read-RedisLine -Stream $Stream if (-not $line.StartsWith('$')) { throw "Redis reply is not a bulk string: $line" } $len = [int]$line.Substring(1) if ($len -lt 0) { return $null } $buf = [byte[]]::new($len) $read = 0 while ($read -lt $len) { $n = $Stream.Read($buf, $read, $len - $read) if ($n -le 0) { throw 'Incomplete Redis reply' } $read += $n } [void]$Stream.ReadByte() [void]$Stream.ReadByte() return [System.Text.Encoding]::UTF8.GetString($buf) } function Get-CaptchaCode { param([string]$Uuid) $tcp = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 6379) try { $stream = $tcp.GetStream() $stream.ReadTimeout = 3000 Send-RedisCommand -Stream $stream -Parts @('SELECT', '1') [void](Read-RedisLine -Stream $stream) Send-RedisCommand -Stream $stream -Parts @('GET', ('captcha_codes:' + $Uuid)) $value = Read-RedisBulk -Stream $stream if ($null -eq $value) { throw 'Captcha answer missing in Redis database=1' } return $value.Trim('"') } finally { $tcp.Dispose() } } # ---------- 3. HTTP helpers ---------- function Invoke-Api { param([string]$Path, [string]$Token = '') $headers = @{} if ($Token -ne '') { $headers['Authorization'] = 'Bearer ' + $Token } try { $res = Invoke-WebRequest -Uri ($BaseUrl + $Path) -Headers $headers -UseBasicParsing -TimeoutSec 15 return @{ status = [int]$res.StatusCode; raw = $res.Content; body = ($res.Content | ConvertFrom-Json) } } catch { $response = $_.Exception.Response if ($null -eq $response) { throw } # PowerShell 5.1 keeps the error body in ErrorDetails; GetResponseStream may be consumed. $content = $_.ErrorDetails.Message if ([string]::IsNullOrEmpty($content)) { $reader = [System.IO.StreamReader]::new($response.GetResponseStream()) $content = $reader.ReadToEnd() } if ([string]::IsNullOrEmpty($content)) { $content = '{}' } return @{ status = [int]$response.StatusCode; raw = $content; body = ($content | ConvertFrom-Json) } } } function Get-Token { param([string]$User, [string]$Password) $captcha = Invoke-RestMethod -Uri ($BaseUrl + '/captchaImage') -Method Get -TimeoutSec 10 $body = @{ username = $User; password = $Password } if ($captcha.captchaEnabled) { $body.code = Get-CaptchaCode -Uuid $captcha.uuid $body.uuid = $captcha.uuid } $resp = Invoke-RestMethod -Uri ($BaseUrl + '/login') -Method Post ` -ContentType 'application/json;charset=UTF-8' -Body ($body | ConvertTo-Json -Compress) -TimeoutSec 15 if ($resp.code -ne 200) { throw "Login failed [$User]: $($resp.msg)" } return $resp.token } # ---------- 4. Startup and readiness ---------- $proc = $null if (-not $SkipStartup) { if (-not (Test-Path -LiteralPath $jar)) { throw "Missing $jar - run mvn package first" } if ($JavaPath -eq '') { $javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME', 'Process') if ([string]::IsNullOrEmpty($javaHome)) { $JavaPath = 'java' } else { $JavaPath = Join-Path $javaHome 'bin/java.exe' } } $outLog = Join-Path $env:TEMP 'apt-it1-stdout.log' $errLog = Join-Path $env:TEMP 'apt-it1-stderr.log' $proc = Start-Process -FilePath $JavaPath -ArgumentList @('-jar', ('"' + $jar + '"')) ` -PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog Write-Host "Started application PID=$($proc.Id), stderr log: $errLog" } $ready = $false for ($i = 0; $i -lt $ReadyAttempts; $i++) { try { $probe = Invoke-WebRequest -Uri ($BaseUrl + '/apt/ops/connectivity') -UseBasicParsing -TimeoutSec 3 if ([int]$probe.StatusCode -eq 200) { $ready = $true; break } } catch { Start-Sleep -Seconds 2 } } if (-not $ready) { throw 'Application not ready within 120s - check stderr log' } # ---------- 5. M0 checks ---------- $results = @() try { $adminToken = Get-Token -User 'admin' -Password $AdminPassword $guestToken = Get-Token -User 'apt_guest' -Password $GuestPassword $r1 = Invoke-Api -Path '/apt/route/list' $results += [pscustomobject]@{ Case = 'M0-1 no token'; Expected = 'HTTP 401 / 40101' Actual = "HTTP $($r1.status) / $($r1.body.code)" Pass = [bool]($r1.status -eq 401 -and $r1.body.code -eq 40101) } $r2 = Invoke-Api -Path '/apt/route/list' -Token $guestToken $results += [pscustomobject]@{ Case = 'M0-2 token without permission'; Expected = 'HTTP 403 / 40301' Actual = "HTTP $($r2.status) / $($r2.body.code)" Pass = [bool]($r2.status -eq 403 -and $r2.body.code -eq 40301) } $r3 = Invoke-Api -Path '/apt/route/list?gyid=APT-B' -Token $adminToken $results += [pscustomobject]@{ Case = 'M0-3 cross-apartment gyid'; Expected = 'HTTP 403 / 40302, no data' Actual = "HTTP $($r3.status) / $($r3.body.code)" Pass = [bool]($r3.status -eq 403 -and $r3.body.code -eq 40302 -and $r3.raw -notmatch '"rows"') } $r4 = Invoke-Api -Path '/apt/ops/connectivity/error-probe' $noLeak = ($r4.raw -notmatch 'internal-detail-marker' -and $r4.raw -notmatch 'at com\.ruoyi') $results += [pscustomobject]@{ Case = 'M0-4 no stack trace leak'; Expected = 'unified body with data.traceId' Actual = "HTTP $($r4.status) / $($r4.body.code) / leak=$(-not $noLeak)" Pass = [bool]($r4.body.data.traceId -and $noLeak) } $r5 = Invoke-Api -Path '/apt/route/list?pageSize=1000' -Token $adminToken $results += [pscustomobject]@{ Case = 'M0-5 page size cap'; Expected = 'HTTP 200 / pageSize=100' Actual = "HTTP $($r5.status) / pageSize=$($r5.body.pageSize)" Pass = [bool]($r5.status -eq 200 -and $r5.body.pageSize -eq 100) } } finally { if ($null -ne $proc -and -not $proc.HasExited) { Stop-Process -Id $proc.Id -Force Write-Host "Stopped application PID=$($proc.Id)" } } $results | Format-Table -AutoSize $failed = @($results | Where-Object { -not $_.Pass }) Write-Host "M0 checks passed: $($results.Count - $failed.Count)/$($results.Count)" if ($failed.Count -gt 0) { exit 1 } exit 0