<# IT-2 / DV-2 end-to-end verification: M1 route, M2 room, master version markers. Maps to docs/apartment/07 table 7-3 M1/M2 and table 7-7 steps 4/5. Prerequisites: 1) dev/.env with APT_DB_PASSWORD; MySQL apt_rev01 and Redis database=1 reachable. 2) dev/scripts/apply-it2-sql.ps1 applied (DDL + Mock seed + permissions). 3) mvn -pl ruoyi-admin -am package built ruoyi-admin/target/ruoyi-admin.jar. Usage: powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1 powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1 -SkipStartup Notes: captcha stays enabled and sys_config is unchanged; the captcha answer is read (read-only) from Redis database=1. A short APT_TOKEN_SECRET is replaced by an ephemeral Base64 HS512 key for this run only. The script starts its own jar on -ServerPort (default 8099), leaving any 8080 instance untouched. #> param( [switch]$SkipStartup, [string]$BaseUrl = 'http://127.0.0.1:8099', [int]$ServerPort = 8099, [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' $results = @() # ---------- 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: $envFile" } Write-Host "Using credentials file: $envFile" foreach ($line in [System.IO.File]::ReadAllLines($envFile)) { $trimmed = $line.Trim() if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } if ($trimmed -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" } } $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 = '', [string]$Method = 'Get', [hashtable]$Body = $null) $headers = @{} if ($Token -ne '') { $headers['Authorization'] = 'Bearer ' + $Token } $params = @{ Uri = ($BaseUrl + $Path); Method = $Method; Headers = $headers UseBasicParsing = $true; TimeoutSec = 20 } if ($null -ne $Body) { $params['ContentType'] = 'application/json;charset=UTF-8' $params['Body'] = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress)) } try { $res = Invoke-WebRequest @params return @{ status = [int]$res.StatusCode; raw = $res.Content; body = ($res.Content | ConvertFrom-Json) } } catch { $response = $_.Exception.Response if ($null -eq $response) { throw } $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 } function Add-Result { param([string]$Case, [string]$Expected, [string]$Actual, [bool]$Pass) $script:results += [pscustomobject]@{ Case = $Case; Expected = $Expected; Actual = $Actual; Pass = $Pass } } # ---------- 4. Startup and readiness ---------- $proc = $null if (-not $SkipStartup) { if (-not [System.IO.File]::Exists($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-it2-stdout.log' $errLog = Join-Path $env:TEMP 'apt-it2-stderr.log' $proc = Start-Process -FilePath $JavaPath -ArgumentList @('-jar', ('"' + $jar + '"'), ('--server.port=' + $ServerPort)) ` -PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog Write-Host "Started application PID=$($proc.Id) on port $ServerPort, 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 - check stderr log' } # ---------- 5. M0 regression + M1 + M2 + version checks ---------- try { $admin = Get-Token -User 'admin' -Password $AdminPassword $guest = Get-Token -User 'apt_guest' -Password $GuestPassword $r = Invoke-Api -Path '/apt/route/list' Add-Result 'M0-1 no token' 'HTTP 401 / 40101' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 401 -and $r.body.code -eq 40101) $r = Invoke-Api -Path '/apt/route/list' -Token $guest Add-Result 'M0-2 no permission' 'HTTP 403 / 40301' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 403 -and $r.body.code -eq 40301) $r = Invoke-Api -Path '/apt/room/list?gyid=APT-B' -Token $admin Add-Result 'M0-3 cross gyid' 'HTTP 403 / 40302' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 403 -and $r.body.code -eq 40302 -and $r.raw -notmatch '"rows"') $r = Invoke-Api -Path '/apt/room/list?pageSize=1000' -Token $admin Add-Result 'M0-4 page cap' 'HTTP 200 / pageSize=100' "HTTP $($r.status) / pageSize=$($r.body.pageSize)" ` ($r.status -eq 200 -and $r.body.pageSize -eq 100) # apt.* / springdoc group-configs now live in ruoyi-apt/apt-defaults.yml via spring.config.import $doc = Invoke-Api -Path '/v3/api-docs/apt' $aptPaths = @() if ($doc.status -eq 200 -and $null -ne $doc.body.paths) { $aptPaths = @($doc.body.paths.PSObject.Properties.Name | Where-Object { $_ -like '/apt/*' }) } Add-Result 'M0-5 apt config + springdoc group' 'HTTP 200 with /apt/* paths' ` "HTTP $($doc.status) / aptPaths=$($aptPaths.Count)" ` ($doc.status -eq 200 -and $aptPaths.Count -gt 0) $routeList = Invoke-Api -Path '/apt/route/list' -Token $admin $routeRows = @($routeList.body.rows) $routeRow = $routeRows | Where-Object { $_.jlid -eq 'JL-001' } | Select-Object -First 1 if ($null -eq $routeRow) { throw 'Mock route JL-001 missing - run apply-it2-sql.ps1' } $routeId = $routeRow.id Add-Result 'M1-1 list + mapping' 'HTTP 200, >=3 rows, jlid/jch mapped' ` "HTTP $($routeList.status) / rows=$($routeRows.Count) / jch=$($routeRow.jch)" ` ($routeList.status -eq 200 -and $routeRows.Count -ge 3 -and $routeRow.jch) $r = Invoke-Api -Path "/apt/route/$routeId" -Token $admin Add-Result 'M1-2 detail' 'HTTP 200 / jlid=JL-001' "HTTP $($r.status) / $($r.body.data.jlid)" ` ($r.status -eq 200 -and $r.body.data.jlid -eq 'JL-001') $now = Get-Date $dupBody = @{ jlid = 'JL-001'; cc = 'G101' kcsj = $now.AddHours(8).ToString('yyyy-MM-dd HH:mm:ss') jcsj = $now.AddHours(6).ToString('yyyy-MM-dd HH:mm:ss') lysj = $now.AddHours(5).ToString('yyyy-MM-dd HH:mm:ss') jch = 'e2e'; qd = 'e2e'; zd = 'e2e' } $r = Invoke-Api -Path '/apt/route' -Token $admin -Method Post -Body $dupBody Add-Result 'M1-3 duplicate jlid' 'HTTP 409 / 40901' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 409 -and $r.body.code -eq 40901) $r = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put -Body @{ enabled = $false } Add-Result 'M1-4 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 400 -and $r.body.code -eq 40001) $r = Invoke-Api -Path "/apt/route/$routeId/recalc-check" -Token $admin -Method Post Add-Result 'M1-5 recalc-check' 'HTTP 200 / computedJcsj present' ` "HTTP $($r.status) / computedJcsj=$($r.body.data.computedJcsj)" ` ($r.status -eq 200 -and $r.body.data.computedJcsj) $off = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put ` -Body @{ enabled = $false; reason = 'IT-2 e2e disable' } $on = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put ` -Body @{ enabled = $true; reason = 'IT-2 e2e restore' } Add-Result 'M1-6 status with reason' 'HTTP 200 / 200' "HTTP $($off.status) / $($on.status)" ` ($off.status -eq 200 -and $on.status -eq 200) $roomList = Invoke-Api -Path '/apt/room/list' -Token $admin $roomRows = @($roomList.body.rows) $roomRow = $roomRows | Where-Object { $_.roomNo -eq '201' } | Select-Object -First 1 if ($null -eq $roomRow) { throw 'Mock room 201 missing - run apply-it2-sql.ps1' } $roomId = $roomRow.id Add-Result 'M2-1 room list' 'HTTP 200, >=5 rows' "HTTP $($roomList.status) / rows=$($roomRows.Count)" ` ($roomList.status -eq 200 -and $roomRows.Count -ge 5) $r = Invoke-Api -Path '/apt/room/list?roomStatus=4' -Token $admin $filtered = @($r.body.rows) $allFour = $true foreach ($row in $filtered) { if ($row.roomStatus -ne '4') { $allFour = $false } } Add-Result 'M2-2 status filter' 'only roomStatus=4' ` "HTTP $($r.status) / rows=$($filtered.Count) / allFour=$allFour" ` ($r.status -eq 200 -and $filtered.Count -ge 1 -and $allFour) $r = Invoke-Api -Path '/apt/room/status-summary' -Token $admin Add-Result 'M2-3 summary' 'HTTP 200 / total>=5' "HTTP $($r.status) / total=$($r.body.data.total)" ` ($r.status -eq 200 -and $r.body.data.total -ge 5) $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin $hasLists = $r.body.data.PSObject.Properties.Name -contains 'bindings' Add-Result 'M2-4 detail' 'HTTP 200 / roomNo=201 / bindings present' ` "HTTP $($r.status) / roomNo=$($r.body.data.roomNo) / lists=$hasLists" ` ($r.status -eq 200 -and $r.body.data.roomNo -eq '201' -and $hasLists) $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin -Method Put ` -Body @{ roomType = 'DOUBLE'; roomFloor = '3' } Add-Result 'M2-5 snapshot readonly' 'HTTP 409 / 40904' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 409 -and $r.body.code -eq 40904) $r = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body @{ action = 'DISABLE' } Add-Result 'M2-6 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 400 -and $r.body.code -eq 40001) # Make the disable/restore sequence replayable even after an interrupted run. $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin if ($before.body.data.roomStatus -eq '4') { $reset = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put ` -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e precondition reset'; commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) } if ($reset.status -ne 200) { throw "Precondition reset failed: $($reset.raw)" } $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin } $beforeVersion = [long]$before.body.data.stateVersion $cmdId = 'IT2D' + [guid]::NewGuid().ToString('N') $disableBody = @{ action = 'DISABLE'; reason = 'IT-2 e2e disable'; commandId = $cmdId } $disable = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody $v1 = $disable.body.data.stateVersion Add-Result 'M2-7 disable' 'status=4 / version+1' ` "HTTP $($disable.status) / status=$($disable.body.data.roomStatus) / v=$beforeVersion->$v1" ` ($disable.status -eq 200 -and $disable.body.data.roomStatus -eq '4' -and [long]$v1 -eq ($beforeVersion + 1)) $dup = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody Add-Result 'M2-8 commandId idempotent' 'duplicated=true / version unchanged' ` "HTTP $($dup.status) / duplicated=$($dup.body.data.duplicated) / v=$($dup.body.data.stateVersion)" ` ($dup.status -eq 200 -and $dup.body.data.duplicated -eq $true -and [long]$dup.body.data.stateVersion -eq [long]$v1) $after = Invoke-Api -Path "/apt/room/$roomId" -Token $admin $livePersonsPresent = $after.body.data.PSObject.Properties.Name -contains 'livePersons' Add-Result 'M2-9 disabled still queryable' 'status=4 / livePersons present' ` "HTTP $($after.status) / status=$($after.body.data.roomStatus) / livePersons=$livePersonsPresent" ` ($after.status -eq 200 -and $after.body.data.roomStatus -eq '4' -and $livePersonsPresent) $restore = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put ` -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e restore'; commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) } Add-Result 'M2-10 restore recalc' 'status<>4 / version+1 again' ` "HTTP $($restore.status) / status=$($restore.body.data.roomStatus) / v=$($restore.body.data.stateVersion)" ` ($restore.status -eq 200 -and $restore.body.data.roomStatus -ne '4' ` -and [long]$restore.body.data.stateVersion -eq ($beforeVersion + 2)) $r = Invoke-Api -Path '/apt/room' -Token $admin -Method Post -Body @{} Add-Result 'M2-11 add disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 409 -and $r.body.code -eq 40940) $versions = Invoke-Api -Path '/apt/master/versions' -Token $admin $vdata = @($versions.body.data) $allLocal = $true foreach ($v in $vdata) { if ($v.source -ne 'LOCAL' -or $v.sourceVersion -ne 0 -or $v.localMock -ne $true) { $allLocal = $false } } Add-Result 'M2-12 version markers' '6 domains LOCAL/0/mock=true' ` "HTTP $($versions.status) / count=$($vdata.Count) / allLocal=$allLocal / notice=$($versions.body.mockNotice)" ` ($versions.status -eq 200 -and $vdata.Count -eq 6 -and $allLocal -and $versions.body.mockNotice) $r = Invoke-Api -Path '/apt/master/ROUTE/resync' -Token $admin -Method Post -Body @{} Add-Result 'M2-13 resync disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" ` ($r.status -eq 409 -and $r.body.code -eq 40940) } 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 "IT-2 checks passed: $($results.Count - $failed.Count)/$($results.Count)" if ($failed.Count -gt 0) { exit 1 } exit 0