248 lines
10 KiB
PowerShell
248 lines
10 KiB
PowerShell
<#
|
|||
|
|
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:<uuid>.
|
||
|
|
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
|