From 6e73d34561af192298dac81c82211087c9a460a3 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Wed, 14 Oct 2026 18:31:38 +0800 Subject: [PATCH] test(qa): verify native Kingbase restore migration and fusion workflows --- DEMO_RUNBOOK.md | 81 +++++-- .../web/exception/GlobalExceptionHandler.java | 8 + .../security/Round5v4MissingResourceTest.java | 20 ++ RuoYi-Vue/ruoyi-train/pom.xml | 2 + .../TrainIotLockAuthBridgeServiceImpl.java | 10 +- .../mapper/train/TrainAnalysisMapper.xml | 2 +- .../lock/Round5v4BrowserFixture.java | 49 ++++ .../lock/Round5v4KingbaseIntegrationTest.java | 227 ++++++++++++++++++ .../lock/TrainIotLockAuthBridgeMockTest.java | 7 + .../src/views/train/accommodation/index.vue | 16 +- docs/iot/ROUND5V3_SERVER_BACKUP.md | 3 + docs/iot/ROUND5V4_SOFTWARE_QA.md | 179 ++++++++++++++ docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md | 11 +- scripts/qa/QaDruidProbe.java | 16 ++ scripts/qa/browser_fixture.py | 31 +++ scripts/qa/browser_login.py | 58 +++++ scripts/qa/browser_regression.js | 41 ++++ scripts/qa/kingbase_manifest.sql | 35 +++ scripts/qa/qa.py | 43 +++- scripts/qa/round5v4_database_stage.py | 187 +++++++++++++++ scripts/qa/round5v4_storage_guard.py | 72 ++++++ scripts/qa/run_kingbase_tests.py | 30 +++ scripts/qa/test_qa_tools.py | 6 +- 23 files changed, 1103 insertions(+), 31 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5v4MissingResourceTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java create mode 100644 docs/iot/ROUND5V4_SOFTWARE_QA.md create mode 100644 scripts/qa/QaDruidProbe.java create mode 100644 scripts/qa/browser_fixture.py create mode 100644 scripts/qa/browser_login.py create mode 100644 scripts/qa/browser_regression.js create mode 100644 scripts/qa/kingbase_manifest.sql create mode 100644 scripts/qa/round5v4_database_stage.py create mode 100644 scripts/qa/round5v4_storage_guard.py create mode 100644 scripts/qa/run_kingbase_tests.py diff --git a/DEMO_RUNBOOK.md b/DEMO_RUNBOOK.md index c82690c54..6da136407 100644 --- a/DEMO_RUNBOOK.md +++ b/DEMO_RUNBOOK.md @@ -5,9 +5,15 @@ ## 当前验收边界 -Round 5V-3 已成功接管SSH管理入口,完成匹配V9原生一致性备份及归档完整读取。 -005授权有效,但独立恢复环境尚未确认或获准创建,当前状态为 -`MIGRATION_AUTHORIZED_BLOCKED_ISOLATED_RESTORE`,005未执行。 +Round 5V-4 已在获准的新建离线 Kingbase V9 容器中实际恢复原生备份,完成 +005 的提交、重复执行、异常/断连/锁超时回滚及原备份恢复演练,然后才对 +`railwaysystem_fusion_qa_20261013` 执行 16 列 / 4 索引增量迁移。 +11 项真实 Kingbase Service/Mapper/事务测试(含独立测试 JVM 强制中断)已通过。 +当前 QA 后端、15 个 Chrome 路由与合成学员入住/调宿/退宿的 Mock 双回执闭环已实际验收。 +详见 [本轮软件验收记录](docs/iot/ROUND5V4_SOFTWARE_QA.md)。 +但收尾18:27实测服务器22/4321均超时,后端出现08001/EOF;当前就绪状态为 +`BLOCKED_NETWORK`,不是软件功能失败或迁移未执行。已停止本轮本机QA三进程。 +管理员恢复合法连接后,重新执行只读预检和隔离启动即可;不要重做005、覆盖恢复或修改系统路由。 不能将 H2/替身测试、构建成功或登录页显示称为 Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。 迁移前置、失败回滚和核验清单见 [迁移计划](docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md)。 @@ -30,6 +36,7 @@ bash scripts/bootstrap/install-kingbase-jdbc.sh bash scripts/bootstrap/check-kingbase-jdbc.sh mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v +mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am -Dtest=Round5v4MissingResourceTest -Dsurefire.failIfNoSpecifiedTests=false test ``` 驱动私有坐标:`cn.com.kingbase:kingbase8:9.0.0`;SHA256: @@ -72,11 +79,12 @@ V009R003C018原生工具;现有kingbase管理角色可经容器本地连接认 fusion_qa_app提权。已完成原生备份,服务器及本机副本均在非Git目录,目录0700、文件0600。 最新位置、SHA及独立恢复解阻方案见 [本轮记录](docs/iot/ROUND5V3_SERVER_BACKUP.md)。 `sys_restore --file=/dev/null`成功仅证明归档可完整读取,不能替代实际数据库恢复。 -本机Docker守护进程不可用;服务器只确认一个共享V9实例,旧V8目录不是获准的 -兼容恢复环境。需提供已有独立恢复目标,或明确另行批准隔离容器/实例及恢复库。 -不要自行创建数据库、启动新容器或复用共享库进行覆盖恢复。 +Round 5V-4 已另获明确授权,复用服务器现有 V9 镜像创建专用隔离恢复容器, +原生恢复及迁移/回滚均已实际验证;该授权不扩展到其他实例或数据库。 +恢复证据及新建资源身份见本轮记录;不要把一次性演练脚本当作通用初始化脚本重跑, +不要复用共享库进行覆盖恢复。历史 Round 5V-3 文档保留为当时的真实阻塞记录。 -## 3. 启停隔离 QA(迁移批准并完成后) +## 3. 启停隔离 QA(目标库 005 已完成) ```bash python3 scripts/qa/qa.py start --config /protected/round5v.env @@ -91,6 +99,9 @@ python3 scripts/qa/qa.py frontend whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭; OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。 继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。 +启动器将构建包复制为 `.qa-runtime/ruoyi-admin-.jar` 并校验散列,防止 Maven +覆盖正在运行的嵌套 JAR。Druid URL 去除 JDBC 秒单位超时参数,池统一使用连接30秒/ +网络60秒;实际 Kingbase/Druid 探针验证了 60000ms,不能把 URL 的 15 当成池毫秒值。 JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600, 以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。 不得为演示去打开真实门禁或危险接口。 @@ -101,10 +112,48 @@ curl --noproxy '*' --fail http://127.0.0.1:18096/captchaImage python3 scripts/qa/qa.py stop ``` -stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清端口、杀其他Chrome、 +stop只处理本脚本账本中且进程命令身份仍匹配的PID,等待其退出再移除账本;不会清端口、杀其他Chrome、 停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。 -## 4. 迁移后验收清单(未完成项不得标 PASS) +## 4. 可复现的真实 Kingbase / Chrome 软件验收 + +仅在已迁移、已批准合成写入的同名 QA 库执行: + +```bash +python3 scripts/qa/run_kingbase_tests.py --approve-synthetic-qa-writes --config /protected/round5v.env +# 真实 Kingbase 11 项;不使用 H2,不启动任何真实 MQTT / 人脸客户端。 +# 合成数据有 R5V4 标识并保留审计,不删除既有数据,不重置序列。 +``` + +Chrome 验收使用 Playwright CLI 技能(本轮实际 Chrome 155.0.8059.40),打开独立 +`round5v4` 会话后运行: + +```bash +python3 scripts/qa/browser_login.py --config /protected/round5v.env +# 若 QA 自身启用验证码:从实际截图识别后,加 --use-current-page --captcha ACTUAL_ANSWER; +# 不关闭验证码。本轮现有配置的 captchaImage 返回 captchaEnabled=false,未改其开关。 +playwright-cli -s=round5v4 run-code --filename=scripts/qa/browser_regression.js +python3 scripts/qa/browser_fixture.py seed --approve-synthetic-qa-writes --config /protected/round5v.env +``` + +登录配置额外需要受保护文件中的 QA_ADMIN_USERNAME / QA_ADMIN_PASSWORD。登录驱动仅 +在独立 about:blank 页内存中读取配置,关闭该页后填写登录表单,不把配置上传至应用。 +CLI 不具备本机工具时使用技能自带 wrapper;不要为此修改工程依赖或系统浏览器设置。 + +`seed` 仅首次执行,生成 `.qa-runtime/round5v4/browser-fixture.properties`;已存在则拒绝覆盖。 +按该文件学号在动态菜单 `/equip/accommodation` 搜索,依次入住、调房至同标识 B 宿舍、退宿。 +每次操作后,先观察 AWAITING_ACK,然后执行受控回执驱动,再到授权和指令页面核对: + +```bash +python3 scripts/qa/browser_fixture.py state --approve-synthetic-qa-writes --config /protected/round5v.env +python3 scripts/qa/browser_fixture.py ack --approve-synthetic-qa-writes --config /protected/round5v.env +python3 scripts/qa/browser_fixture.py verify --approve-synthetic-qa-writes --config /protected/round5v.env +``` + +驱动使用真实 Mapper/Service,只接受该文件合成人员与两把合成门锁、MOCK 来源及 +`round5v-local-mock` 环境的当前 AWAITING_ACK 指令,不开放任何 HTTP 回执端点。 +授权页按人员编号过滤,指令页按 fixture.marker(分组编号)过滤,应有四条 CONFIRMED 指令。 +住宿 V2 模型只持久化分配与最后变更时间,页面已按真实字段标注,不捏造独立入住/退宿时间戳。 1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。 2. IoT九页:group/gateway/device/face/auth/command/accesslog/mqttlog/config, @@ -116,13 +165,15 @@ stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清 ACK由隔离的Service测试驱动,不通过开放生产式控制接口来换取通过。 5. 受控Mock ACK成功只能证明软件闭环,不能证明真实门锁已授权或撤权。 -现有 `Round5sExecutionIntegrationTest` 的文件型H2证据不是第4项的替代品; -`Round5vAccommodationAtomicityTest`证明异常传播/事务声明,不证明Kingbase回滚。 +现有 H2 与替身测试仍不等于真实数据库证据;本轮新增 +`Round5v4KingbaseIntegrationTest` 才是上述 Kingbase 事务和应用进程中断验证的执行入口。 -## 5. CI与当前阻塞 +## 5. CI与硬件边界 `.gitlab-ci.yml` 已改为显式web+RUN_FUSION_VALIDATION=true才允许的验证流水线; Java21、私有JDBC预检及npm ci,**无部署job**。本轮未Push,也未触发/验证远程Runner。 -005授权、原生备份及既有管理连接均已确认;当前仍缺实际独立恢复证明、真实Kingbase生命周期验证和 -依托新QA后端的完整Chrome验收。迁移安全前提未满足时,单独前端会明确返回502, -不得用旧实验后端或伪造API响应冒充新版本验收。 +本轮数据库、事务、启动和 Chrome 软件验收已经完成;仍未做真实门锁、人脸或网关验收。 +硬件测试另需老师批准、确认协议中未验证的帧校验字段/回执及重传策略、单台白名单和人工核对流程。 +商业驱动和恢复容器许可仅按现有合法 QA/试用环境验证,不代表生产部署许可。 +应用账号既有的三张系统表过宽权限未扩大也未自动收回,应由管理员另行最小化。 +任何安全前提不满足时,不得用旧实验后端或伪造 API 响应冒充新版本验收。 diff --git a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java index 1fb68628d..9acd42f61 100644 --- a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java +++ b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java @@ -29,6 +29,14 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + /** Disabled/absent endpoints are 404, not a logged system failure or an exception-detail leak. */ + @ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class) + @org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NOT_FOUND) + public AjaxResult handleMissingResource(org.springframework.web.servlet.resource.NoResourceFoundException e) + { + return AjaxResult.error(HttpStatus.NOT_FOUND, "接口不存在或未启用"); + } + /** * 权限校验异常 */ diff --git a/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5v4MissingResourceTest.java b/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5v4MissingResourceTest.java new file mode 100644 index 000000000..2b26a9a5c --- /dev/null +++ b/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5v4MissingResourceTest.java @@ -0,0 +1,20 @@ +package com.ruoyi.framework.security; + +import com.ruoyi.framework.web.exception.GlobalExceptionHandler; +import org.junit.Test; +import static org.junit.Assert.*; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.springframework.http.HttpMethod; + +public class Round5v4MissingResourceTest { + @Test public void absentSensitiveEndpointsReturnRedacted404RatherThan500() throws Exception { + GlobalExceptionHandler h=new GlobalExceptionHandler(); + Object body=h.handleMissingResource(new NoResourceFoundException(HttpMethod.POST,"iot/mqtt/publish","/iot/mqtt/publish")); + assertEquals(404,((java.util.Map)body).get("code")); + assertFalse(body.toString().contains("static resource")); + assertFalse(body.toString().contains("iot/mqtt")); + ResponseStatus status=GlobalExceptionHandler.class.getMethod("handleMissingResource",NoResourceFoundException.class).getAnnotation(ResponseStatus.class); + assertEquals(404,status.value().value()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/pom.xml b/RuoYi-Vue/ruoyi-train/pom.xml index e33fb1e39..6b8ede308 100644 --- a/RuoYi-Vue/ruoyi-train/pom.xml +++ b/RuoYi-Vue/ruoyi-train/pom.xml @@ -16,6 +16,8 @@ + + cn.com.kingbasekingbase8test diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java index a63572f56..9c7949368 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java @@ -15,6 +15,7 @@ import com.ruoyi.iot.lock.mapper.LockDeviceMapper; import com.ruoyi.iot.lock.service.ILockAuthService; import com.ruoyi.iot.lock.service.ILockCommandService; import com.ruoyi.iot.lock.service.ILockFaceService; +import com.ruoyi.iot.lock.service.LockExecutionTransactions; import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainClassPlan; import com.ruoyi.train.domain.TrainDormitoryInfo; @@ -92,6 +93,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe @Autowired private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; + @Autowired + private LockExecutionTransactions executionTransactions; + @org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}") private boolean passwordFallbackEnabled = false; @@ -553,7 +557,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } try { - lockAuthService.push(authId); + // AFTER_COMMIT still exposes the completed business transaction's resources. + // Enqueue in REQUIRES_NEW so its own commit callback actually dispatches. + executionTransactions.committed(() -> { lockAuthService.push(authId); return null; }); } catch (Exception ex) { @@ -569,7 +575,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } try { - lockAuthService.clear(authId); + executionTransactions.committed(() -> { lockAuthService.clear(authId); return null; }); } catch (Exception ex) { diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml index e4f56fd72..8afb643ea 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml @@ -140,7 +140,7 @@ SELECT COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name, count(1) AS usage_count, - ROUND(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0)::numeric, 1) AS total_hours + ROUND(CAST(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0) AS numeric), 1) AS total_hours FROM train_lesson l LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id LEFT JOIN train_training_site ts ON ts.site_id = l.site_id diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java new file mode 100644 index 000000000..bf9518e90 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java @@ -0,0 +1,49 @@ +package com.ruoyi.train.integration.lock; + +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.*; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.security.ExecutionStates; + +/** Offline CLI fixture: no web endpoint, no real MQTT client, only explicitly marked synthetic QA records. */ +public final class Round5v4BrowserFixture { + public static void main(String[] args) throws Exception { + Round5v4KingbaseIntegrationTest.Harness h=new Round5v4KingbaseIntegrationTest.Harness("round5v-local-mock"); + Path path=Paths.get(System.getenv("ROUND5V4_LOG_DIR"),"browser-fixture.properties"); + Properties p=new Properties(); + if("seed".equals(args[0])) { + if(Files.exists(path))throw new SecurityException("Fixture already exists; preserve its audit trail"); + h.seed();p.setProperty("marker",h.marker);p.setProperty("studentId",h.marker+"S");p.setProperty("person",h.person); + p.setProperty("dormA",h.dormA);p.setProperty("dormB",h.dormB);p.setProperty("acc",String.valueOf(h.acc)); + p.setProperty("deviceA",String.valueOf(h.deviceA));p.setProperty("deviceB",String.valueOf(h.deviceB)); + Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + try(Writer w=Files.newBufferedWriter(path)){p.store(w,"SYNTHETIC ONLY; no credentials");} + System.out.println("BROWSER_FIXTURE_CREATED "+h.marker);return; + } + try(Reader r=Files.newBufferedReader(path)){p.load(r);} + if(!p.getProperty("marker","").matches("R5V4[0-9a-f]{16}"))throw new SecurityException("Invalid fixture identity"); + String person=p.getProperty("person");Set devices=new HashSet<>(Arrays.asList(Long.valueOf(p.getProperty("deviceA")),Long.valueOf(p.getProperty("deviceB")))); + java.util.List all=h.authMapper.selectAuthsByPersonNo(person); + for(LockAuth auth:all) { + if(!devices.contains(auth.getDeviceId()) || !h.gate.sameOrigin(auth) || !"MOCK".equals(auth.getExecutionMode()))throw new SecurityException("Non-fixture authorization"); + LockCommand c=h.commandMapper.selectCommandByIntentKey(auth.getIntentKey()); + if("ack".equals(args[0]) && c!=null && ExecutionStates.AWAITING_ACK.equals(c.getExecutionState())) { + if(!"MOCK".equals(c.getExecutionMode()) || !h.gate.sameOrigin(c))throw new SecurityException("Non-Mock command"); + h.confirm(c); + } else if(!Arrays.asList("ack","state","verify").contains(args[0]))throw new IllegalArgumentException("seed/ack/state/verify only"); + auth=h.authMapper.selectAuthById(auth.getAuthId()); + System.out.println("FIXTURE_AUTH id="+auth.getAuthId()+" action="+auth.getIntentAction()+" version="+auth.getIntentVersion()+" state="+auth.getExecutionState()+" sync="+auth.getSyncStatus()); + } + if("verify".equals(args[0])) { + org.junit.Assert.assertEquals(2,all.size()); + for(LockAuth auth:all){org.junit.Assert.assertEquals(ExecutionStates.REVOKE,auth.getIntentAction());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,auth.getExecutionState());org.junit.Assert.assertEquals("4",auth.getSyncStatus());} + java.util.List commands=h.jdbc.queryForList("select c.command_id from iotlocks.lock_command c join iotlocks.lock_auth a on a.auth_id=c.biz_id where a.person_no=? and c.environment_id=? order by c.command_id",Long.class,person,h.env); + org.junit.Assert.assertEquals(4,commands.size()); + for(Long id:commands){LockCommand c=h.commandMapper.selectCommandById(id);org.junit.Assert.assertEquals("MOCK",c.getExecutionMode());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,c.getExecutionState());org.junit.Assert.assertEquals("1",c.getGatewayAck());org.junit.Assert.assertEquals("1",c.getDeviceAck());System.out.println("FIXTURE_COMMAND id="+id+" type="+c.getCommandType()+" mode=MOCK state=CONFIRMED dual_ack=true");} + org.junit.Assert.assertEquals(Integer.valueOf(2),h.jdbc.queryForObject("select count(*) from public.train_accommodation a join public.train_enrollment e on e.enrollment_id=a.enrollment_id where e.student_id=? and a.accommodation_status=2",Integer.class,p.getProperty("studentId"))); + System.out.println("FIXTURE_BROWSER_DATABASE_CLOSED_LOOP_PASS"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java new file mode 100644 index 000000000..28a093ecc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java @@ -0,0 +1,227 @@ +package com.ruoyi.train.integration.lock; + +import static org.junit.Assert.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermissions; +import java.sql.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.*; +import org.mybatis.spring.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import com.ruoyi.iot.config.IotMqttProperties; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.mapper.*; +import com.ruoyi.iot.lock.protocol.*; +import com.ruoyi.iot.lock.security.*; +import com.ruoyi.iot.lock.service.*; +import com.ruoyi.iot.lock.service.impl.*; +import com.ruoyi.iot.service.IMqttService; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService; +import com.ruoyi.train.integration.lock.service.impl.TrainIotLockAuthBridgeServiceImpl; +import com.ruoyi.train.mapper.*; +import com.ruoyi.train.service.ITrainAccommodationService; +import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl; + +/** Opt-in REAL Kingbase, real production XML/Services/transaction manager. All egress is a fake transport. */ +public class Round5v4KingbaseIntegrationTest { + private Harness h; + @Before public void setup() throws Exception { + Assume.assumeTrue("Explicit QA write opt-in required", "1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS"))); + h=new Harness("round5v4-qa-"+UUID.randomUUID()); h.seed(); + } + @After public void preserveSyntheticAuditRows() { + if(h==null)return; + h.props.setMock(true); + // Preserve all synthetic records; close only never-attempted test work, never historical business rows. + for(Long id:h.jdbc.queryForList("select command_id from iotlocks.lock_command where environment_id=? and execution_state='READY' and attempt_started_at is null",Long.class,h.env)) { + try {h.commands.cancel(id);}catch(RuntimeException ignored) { } + } + h.jdbc.update("update iotlocks.lock_auth set execution_state='MANUAL_REVIEW',remark='Round5V4 synthetic test complete; preserved for audit' where environment_id=? and execution_state='READY'",h.env); + } + @Test public void checkinTransferCheckoutRealTransactionsAndMockDualAck() throws Exception { + h.accommodations.checkIn(h.acc,"round5v4");LockAuth first=h.auth(h.deviceA);h.confirm(h.latest(first)); + assertEquals(ExecutionStates.CONFIRMED,h.auth(h.deviceA).getExecutionState()); + TrainAccommodation next=h.accommodations.transfer(h.acc,h.dormB,"1","round5v4"); + LockAuth old=h.auth(h.deviceA),fresh=h.auth(h.deviceB); + assertEquals(ExecutionStates.REVOKE,old.getIntentAction());assertEquals(ExecutionStates.GRANT,fresh.getIntentAction()); + h.confirm(h.latest(old));h.confirm(h.latest(fresh)); + h.accommodations.checkOut(next.getAccommodationId(),"round5v4");h.confirm(h.latest(h.auth(h.deviceB))); + assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); + assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceB).getSyncStatus()); + assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(next.getAccommodationId()).getAccommodationStatus()); + } + @Test public void revokeIntentBeforeCommandSurvivesActualJvmHalt() throws Exception { + h.checkedIn();assertEquals(73,h.child("intent",h.acc)); + LockAuth pending=h.auth(h.deviceA);assertEquals("1",pending.getStatus());assertEquals(ExecutionStates.REVOKE,pending.getIntentAction()); + assertNull(h.commandMapper.selectCommandByIntentKey(pending.getIntentKey())); + h.restart();h.auths.retryPending();LockCommand revoke=h.latest(pending);assertNotNull(revoke);h.confirm(revoke); + assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); + assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); + } + @Test public void readyRevokeBeforeSendSurvivesActualJvmHalt() throws Exception { + h.checkedIn();assertEquals(75,h.child("ready",h.auth(h.deviceA).getAuthId())); + LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.READY,revoke.getExecutionState());assertNull(revoke.getAttemptStartedAt()); + h.restart();h.commands.retry(revoke.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(revoke); + assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); + } + @Test public void committedClaimUnknownAfterJvmHaltNeverBlindlyReplays() throws Exception { + h.checkedIn();assertEquals(74,h.child("claim",h.auth(h.deviceA).getAuthId())); + LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.DISPATCHING,revoke.getExecutionState());assertNotNull(revoke.getAttemptStartedAt()); + h.jdbc.update("update iotlocks.lock_command set attempt_started_at=? where command_id=?",new Timestamp(System.currentTimeMillis()-120000),revoke.getCommandId()); + h.restart();h.commands.markTimeout(30);h.commands.retryPending();h.auths.retryPending(); + assertEquals(ExecutionStates.MANUAL_REVIEW,h.commandMapper.selectCommandById(revoke.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get()); + assertNotEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); + } + @Test public void failedNeverAttemptedCommandCanRecover() throws Exception { + h.checkedIn();h.transport.attempts.set(0);h.transport.connected=false;h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand command=h.latest(h.auth(h.deviceA)); + assertEquals(LockConstants.STATUS_FAIL,command.getCommandStatus());assertEquals(ExecutionStates.READY,command.getExecutionState());assertNull(command.getAttemptStartedAt()); + h.transport.connected=true;h.commands.retry(command.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(command); + assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); + } + @Test public void concurrentNativeRowLocksClaimOnlyOnce() throws Exception { + h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");Long id=h.latest(h.auth(h.deviceA)).getCommandId();h.transport.connected=true; + ExecutorService pool=Executors.newFixedThreadPool(4); + try { + List> tasks=new ArrayList<>();for(int i=0;i<4;i++)tasks.add(pool.submit(()->h.commands.dispatch(id))); + for(Future f:tasks)f.get(20,TimeUnit.SECONDS); + }finally {pool.shutdownNow();} + assertEquals(1,h.transport.attempts.get());h.confirm(h.commandMapper.selectCommandById(id)); + } + @Test public void checkinAndIntentRollbackTogetherAfterInjectedFailure() throws Exception { + h.injectBridgeFault("reconcileCheckin"); + try{h.accommodations.checkIn(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { } + assertEquals(Integer.valueOf(0),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); + assertNull(h.auth(h.deviceA));assertEquals(0,h.transport.attempts.get()); + } + @Test public void checkoutAndRevokeIntentRollbackTogether() throws Exception { + h.checkedIn();LockAuth before=h.auth(h.deviceA);h.injectBridgeFault("reconcileCheckout"); + try{h.accommodations.checkOut(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { } + LockAuth after=h.auth(h.deviceA);assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction()); + assertEquals(Integer.valueOf(1),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); + } + @Test public void lateOldRevokeAckCannotOverwriteNewGrantVersion() throws Exception { + h.checkedIn();h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand old=h.latest(h.auth(h.deviceA));h.confirm(old); + LockAuth grant=h.auth(h.deviceA);h.auths.updateAuth(grant);LockAuth newer=h.auth(h.deviceA);String key=newer.getIntentKey();Long version=newer.getIntentVersion(); + h.confirm(old);assertEquals(key,h.auth(h.deviceA).getIntentKey());assertEquals(version,h.auth(h.deviceA).getIntentVersion());assertEquals("0",h.auth(h.deviceA).getStatus()); + } + @Test public void mockAndLegacyCommandsAreQuarantinedAcrossRealBoundary() throws Exception { + h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand old=h.latest(h.auth(h.deviceA)); + h.props.setMock(false);h.props.setEnabled(false);h.props.setRealDeviceWriteEnabled(false);h.props.setDeviceWhitelist(Collections.emptyList()); + h.transport.connected=true;h.commands.dispatch(old.getCommandId()); + assertEquals(ExecutionStates.QUARANTINED,h.commandMapper.selectCommandById(old.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get()); + assertFalse(h.gate.checkDeviceWriteAllowed(old.getDeviceSn(),"0012")); + } + @Test public void uniqueIntentConstraintIsEnforcedByActualKingbase() throws Exception { + h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand original=h.latest(h.auth(h.deviceA)); + try { + h.tx.required(()->{LockCommand copy=h.commandMapper.selectCommandById(original.getCommandId());copy.setCommandId(null);copy.setCommandNo("R5V4"+UUID.randomUUID().toString().replace("-",""));h.commandMapper.insertCommand(copy);return null;});fail(); + }catch(org.springframework.dao.DuplicateKeyException expected) { } + assertEquals(Integer.valueOf(1),h.jdbc.queryForObject("select count(*) from iotlocks.lock_command where intent_key=?",Integer.class,original.getIntentKey())); + } + + static class Harness { + final String env;DataSource ds;JdbcTemplate jdbc;SqlSessionTemplate sessions;DataSourceTransactionManager manager; + LockAuthMapper authMapper;LockCommandMapper commandMapper;TrainAccommodationMapper trainMapper; + LockExecutionTransactions tx;IotMqttProperties props;RealIotSecurityGate gate;LockAuthServiceImpl auths;LockCommandServiceImpl commands; + TrainIotLockAuthBridgeServiceImpl bridgeTarget;ITrainLockAuthBridgeService bridge;TrainAccommodationServiceImpl accommodationTarget;ITrainAccommodationService accommodations; + Transport transport;String person,marker,dormA,dormB;long deviceA,deviceB,acc,enrollment; + Harness(String environment) throws Exception { + env=environment;String url=System.getenv("QA_DB_URL"); + if(!"1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")) || url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException("Explicit isolated QA opt-in/URL required"); + Class.forName("com.kingbase8.Driver");DriverManagerDataSource raw=new DriverManagerDataSource(url,"fusion_qa_app",System.getenv("QA_DB_PASSWORD")); + ds=new AbstractDataSource(){public Connection getConnection()throws SQLException{Connection c=raw.getConnection();try(Statement s=c.createStatement()){s.execute("set search_path to iotlocks,public,pg_catalog,sys_catalog");s.execute("set lock_timeout='5s'");}return c;}public Connection getConnection(String u,String p)throws SQLException{return getConnection();}}; + jdbc=new JdbcTemplate(ds);assertEquals("railwaysystem_fusion_qa_20261013",jdbc.queryForObject("select current_database()",String.class));assertEquals("fusion_qa_app",jdbc.queryForObject("select current_user",String.class)); + // Recovery APIs scan pending work. Refuse tests if unrelated active work could be affected. + for(String table:Arrays.asList("lock_auth","lock_command")) + if(jdbc.queryForObject("select count(*) from iotlocks."+table+" where execution_state='READY' and execution_mode<>'LEGACY' and coalesce(environment_id,'') not like 'round5v4-qa-%' and coalesce(environment_id,'')<>?",Integer.class,env)>0) + throw new SecurityException("Unrelated pending QA work exists; recovery test refused"); + manager=new DataSourceTransactionManager(ds);tx=new LockExecutionTransactions(manager); + SqlSessionFactoryBean f=new SqlSessionFactoryBean();f.setDataSource(ds);org.apache.ibatis.session.Configuration mc=new org.apache.ibatis.session.Configuration();mc.setDatabaseId("kingbase");f.setConfiguration(mc);f.setTypeAliasesPackage("com.ruoyi.iot.lock.domain;com.ruoyi.train.domain"); + List resources=new ArrayList<>(); + for(String n:Arrays.asList("LockAuth","LockCommand","LockDevice","LockGateway","LockGroup","LockFace"))resources.add(new ClassPathResource("mapper/lock/"+n+"Mapper.xml")); + for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))resources.add(new ClassPathResource("mapper/train/"+n+"Mapper.xml")); + f.setMapperLocations(resources.toArray(new org.springframework.core.io.Resource[0]));sessions=new SqlSessionTemplate(f.getObject()); + authMapper=sessions.getMapper(LockAuthMapper.class);commandMapper=sessions.getMapper(LockCommandMapper.class);trainMapper=sessions.getMapper(TrainAccommodationMapper.class); + props=new IotMqttProperties();props.setMock(true);props.setEnabled(false);props.setRealDeviceWriteEnabled(false);props.setDeviceWhitelist(Collections.emptyList());props.setEnvironmentId(env);restart(); + } + @SuppressWarnings("unchecked") T transactional(Object target,Class type){ + ProxyFactory p=new ProxyFactory(target);p.setInterfaces(type);p.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)p.getProxy(); + } + void restart() throws Exception { + gate=new RealIotSecurityGate(props); + for(String n:Arrays.asList("device","gateway","group","auth","command")){ + Class type=Class.forName("com.ruoyi.iot.lock.mapper.Lock"+Character.toUpperCase(n.charAt(0))+n.substring(1)+"Mapper");inject(gate,n+"Mapper",sessions.getMapper(type)); + } + commands=new LockCommandServiceImpl();auths=new LockAuthServiceImpl();transport=new Transport(this); + for(Object s:Arrays.asList(commands,auths)){inject(s,"authMapper",authMapper);inject(s,"commandMapper",commandMapper);inject(s,"securityGate",gate);inject(s,"transactions",tx);} + inject(commands,"mqttService",transport);inject(auths,"deviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(auths,"gatewayMapper",sessions.getMapper(LockGatewayMapper.class));inject(auths,"groupMapper",sessions.getMapper(LockGroupMapper.class));inject(auths,"commandService",commands); + LockFaceServiceImpl faces=new LockFaceServiceImpl();inject(faces,"faceMapper",sessions.getMapper(LockFaceMapper.class)); + bridgeTarget=new TrainIotLockAuthBridgeServiceImpl(); + inject(bridgeTarget,"executionTransactions",tx); + for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))inject(bridgeTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper"))); + inject(bridgeTarget,"lockDeviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(bridgeTarget,"lockAuthMapper",authMapper);inject(bridgeTarget,"lockCommandMapper",commandMapper);inject(bridgeTarget,"lockAuthService",auths);inject(bridgeTarget,"lockCommandService",commands);inject(bridgeTarget,"lockFaceService",faces);inject(bridgeTarget,"securityGate",gate); + bridge=transactional(bridgeTarget,ITrainLockAuthBridgeService.class); + accommodationTarget=new TrainAccommodationServiceImpl(); + for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainDormitoryInfo","TrainClassPlan"))inject(accommodationTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper"))); + accommodationTarget.setAuthEngine("iot");accommodationTarget.setIotLockAuthBridgeService(bridge);accommodations=transactional(accommodationTarget,ITrainAccommodationService.class); + } + void seed(){ + String key=UUID.randomUUID().toString().replace("-","");marker="R5V4"+key.substring(0,16);person="P5"+key.substring(0,6);dormA=marker+"A";dormB=marker+"B"; + String plan=marker+"P",student=marker+"S";Timestamp start=new Timestamp(System.currentTimeMillis()-86400000),end=new Timestamp(System.currentTimeMillis()+3*86400000L); + jdbc.update("insert into public.train_class_plan(plan_id,plan_name,start_time,end_time,create_by) values(?,?,?,?,?)",plan,"Round5V4 Synthetic",start,end,"round5v4"); + jdbc.update("insert into public.train_student(s_id,s_name,person_no,org_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4"); + enrollment=jdbc.queryForObject("insert into public.train_enrollment(plan_id,student_id,accommodation_required,create_by) values(?,?,1,'round5v4') returning enrollment_id",Long.class,plan,student); + Long group=jdbc.queryForObject("insert into iotlocks.lock_group(group_code,group_name,create_by) values(?,?,'round5v4') returning group_id",Long.class,marker,"Round5V4 Synthetic"); + Long gateway=jdbc.queryForObject("insert into iotlocks.lock_gateway(gateway_sn,group_id,create_by) values(?,?,'round5v4') returning gateway_id",Long.class,"G5"+key.substring(0,6),group); + for(int i=0;i<2;i++){ + String sn=(i==0?"D5":"D6")+key.substring(0,6); + Long id=jdbc.queryForObject("insert into iotlocks.lock_device(device_sn,device_name,gateway_id,group_id,device_password,dev_ver,dev_type,create_by) values(?,?,?,?,?,'11','19','round5v4') returning device_id",Long.class,sn,"Round5V4 Synthetic",gateway,group,UUID.randomUUID().toString()); + if(i==0)deviceA=id;else deviceB=id; + jdbc.update("insert into public.train_dormitory_info(dorm_id,dorm_building,dorm_floor,dorm_room,lock_device_sn,capacity,create_by) values(?,?,'1',?,?,4,'round5v4')",i==0?dormA:dormB,marker,i==0?"A":"B",sn); + } + jdbc.update("insert into iotlocks.lock_face(person_no,person_name,feature_data,feature_md5,extract_status,create_by) values(?,'Round5V4 Synthetic',?,?,'1','round5v4')",person,"mock_feature_round5v4_"+marker,key); + TrainAccommodation a=new TrainAccommodation(enrollment,dormA,"1");a.setCreateBy("round5v4");accommodations.insertTrainAccommodation(a);acc=a.getAccommodationId(); + } + LockAuth auth(long device){List all=authMapper.selectAuthsByPersonNo(person);return all.stream().filter(a->a.getDeviceId()==device).findFirst().orElse(null);} + LockCommand latest(LockAuth a){return commandMapper.selectCommandByIntentKey(authMapper.selectAuthById(a.getAuthId()).getIntentKey());} + void checkedIn(){accommodations.checkIn(acc,"round5v4");confirm(latest(auth(deviceA)));} + void confirm(LockCommand c){ack(c,"10");ack(c,"0");} + void ack(LockCommand c,String code){YdlinkFrameDTO req=YdlinkCodec.fromJson(c.getPayload());YdlinkFrameDTO response=new YdlinkFrameDTO();response.setSeq(c.getCommandNo());response.setOp(req.getOp());response.setFlatParams(Arrays.asList(String.valueOf(req.getParams().get(0)),c.getDeviceSn(),code,"","round5v4-mock"));String body=YdlinkCodec.toJson(response);if("10".equals(code))commands.handleGatewayAck(c.getTopic()+"_report",c.getCommandNo(),body,"Mock only");else commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),body,true,"Mock only");} + void injectBridgeFault(String method){accommodationTarget.setIotLockAuthBridgeService((ITrainLockAuthBridgeService)Proxy.newProxyInstance(ITrainLockAuthBridgeService.class.getClassLoader(),new Class[]{ITrainLockAuthBridgeService.class},(p,m,a)->{try{Object r=m.invoke(bridge,a);if(m.getName().equals(method))throw new IllegalStateException("synthetic post-intent rollback");return r;}catch(InvocationTargetException e){throw e.getCause();}}));} + int child(String mode,long id)throws Exception{ + Path dir=Paths.get(System.getenv("ROUND5V4_LOG_DIR"));Files.createDirectories(dir);Path log=Files.createTempFile(dir,"child-"+mode,".log",PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + Process p=new ProcessBuilder(System.getProperty("java.home")+"/bin/java","-cp",System.getProperty("surefire.test.class.path",System.getProperty("java.class.path")),Round5v4KingbaseIntegrationTest.class.getName(),mode,String.valueOf(id),env).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + if(!p.waitFor(40,TimeUnit.SECONDS)){p.destroyForcibly();throw new AssertionError("Isolated child timeout");}return p.exitValue(); + } + } + static void inject(Object target,String name,Object value)throws Exception{Field f=target.getClass().getDeclaredField(name);f.setAccessible(true);f.set(target,value);} + static class Transport implements IMqttService { + final Harness h;boolean connected=true,crashAfterClaim;AtomicInteger attempts=new AtomicInteger();Transport(Harness harness){h=harness;} + public boolean isConnected(){return connected;} + public void publishCommand(LockCommand c){h.gate.requirePersistedDispatch(c);assertEquals(ExecutionStates.DISPATCHING,h.jdbc.queryForObject("select execution_state from iotlocks.lock_command where command_id=?",String.class,c.getCommandId()));if(crashAfterClaim)Runtime.getRuntime().halt(74);attempts.incrementAndGet();} + public void publish(String t,String p){throw new AssertionError("Raw publication forbidden");}public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("Raw publication forbidden");} + public void subscribe(String t){}public void subscribe(String t,Integer q){}public void unsubscribe(String t){}public void reconnect(){}public Map getSubscriptions(){return Collections.emptyMap();} + } + /** Separate process: committed database intent/claim survives Runtime.halt without touching any server process. */ + public static void main(String[] args)throws Exception{ + Harness h=new Harness(args[2]);long id=Long.parseLong(args[1]); + if(args[0].equals("intent")){ + ILockAuthService crashing=(ILockAuthService)Proxy.newProxyInstance(ILockAuthService.class.getClassLoader(),new Class[]{ILockAuthService.class},(p,m,a)->{if(m.getName().equals("clear"))Runtime.getRuntime().halt(73);try{return m.invoke(h.auths,a);}catch(InvocationTargetException e){throw e.getCause();}}); + inject(h.bridgeTarget,"lockAuthService",crashing);h.accommodations.checkOut(id,"round5v4"); + }else if(args[0].equals("ready")){h.transport.connected=false;h.auths.clear(id);Runtime.getRuntime().halt(75);} + else if(args[0].equals("claim")){h.transport.crashAfterClaim=true;h.auths.clear(id);} + throw new AssertionError("Crash injection was not reached"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java index d94ca78ad..207be51d7 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java @@ -114,6 +114,13 @@ public class TrainIotLockAuthBridgeMockTest // 组装桥接服务 bridgeService = new TrainIotLockAuthBridgeServiceImpl(); + // This in-memory fixture does not prove database transaction semantics. + com.ruoyi.iot.lock.service.LockExecutionTransactions transactions = new com.ruoyi.iot.lock.service.LockExecutionTransactions(new org.springframework.transaction.PlatformTransactionManager() { + public org.springframework.transaction.TransactionStatus getTransaction(org.springframework.transaction.TransactionDefinition d) { return new org.springframework.transaction.support.SimpleTransactionStatus(); } + public void commit(org.springframework.transaction.TransactionStatus s) { } + public void rollback(org.springframework.transaction.TransactionStatus s) { } + }); + setField(bridgeService, "executionTransactions", transactions); com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties(); executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock"); com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps); diff --git a/build_ui/src/views/train/accommodation/index.vue b/build_ui/src/views/train/accommodation/index.vue index b97f412be..9bfca8e85 100644 --- a/build_ui/src/views/train/accommodation/index.vue +++ b/build_ui/src/views/train/accommodation/index.vue @@ -73,14 +73,14 @@ {{ scope.row.accommodationStatus }} - + - + @@ -187,14 +187,14 @@ {{ scope.row.accommodationStatus }} - + - + diff --git a/docs/iot/ROUND5V3_SERVER_BACKUP.md b/docs/iot/ROUND5V3_SERVER_BACKUP.md index 34ac10a0f..8655e0c2f 100644 --- a/docs/iot/ROUND5V3_SERVER_BACKUP.md +++ b/docs/iot/ROUND5V3_SERVER_BACKUP.md @@ -1,5 +1,8 @@ # Round 5V-3 — SSH接管、原生备份完成,独立恢复仍待解阻 +历史记录保持原状。后续 Round 5V-4 已另获新建隔离恢复容器授权,实际恢复、005演练/回滚及源QA迁移均已完成; +当前结论见 [Round 5V-4 软件验收](ROUND5V4_SOFTWARE_QA.md),下文的“尚未恢复/未迁移”仅指当时状态。 + 日期:2026-10-14;工作分支 `fix/iot-safety-hardening`;开始提交 `b94ac7f05aef80e092fddcf3d6ff3ac0a1a890b3`。本轮没有重新运行H2或源码安全审计。 凭据仅用于SSH认证输入,未写入工程、命令行参数、报告或凭据文件。 diff --git a/docs/iot/ROUND5V4_SOFTWARE_QA.md b/docs/iot/ROUND5V4_SOFTWARE_QA.md new file mode 100644 index 000000000..1e1cffb64 --- /dev/null +++ b/docs/iot/ROUND5V4_SOFTWARE_QA.md @@ -0,0 +1,179 @@ +# Round 5V-4 — Kingbase 隔离恢复、005 迁移与软件融合 QA + +执行日期:2026-10-14。工作树:`boot4-iot-fusion-archive`;分支:`fix/iot-safety-hardening`。 +本轮基线:`ba5dc7a6af9e6bb6b5166ef04d49af621c4f8de2`。 +GitLab 已归档融合基线仍为 `feat/boot4-iot-fusion@37392144`;本轮没有 Push、Merge 或共享部署。 + +**收尾状态(18:27之后):BLOCKED_NETWORK,不声明当前 SOFTWARE_QA_READY=YES。** +下列迁移、11项真实Kingbase测试、15路由及浏览器业务闭环在17:35前已实际通过并保留证据。 +之后数据库连接中断(后端首条记录17:50:55,SQLSTATE08001;后续EOF),18:27独立TCP探测 +192.168.139.218:22和:4321均超时。无法仅凭本机证据区分服务器离线、链路或路由原因, +未修改Mac/服务器网络、代理、服务或数据库配置。已停止本轮本机QA前端/后端/Redis, +不把端口仍监听当成数据库可用。恢复合法连接后只需重新预检、启动与关键读验收, +**005已经成功提交,不要因为断网而重复初始化、覆盖恢复或重做迁移。** + +## 1. 授权与实际边界 + +本轮用户另行批准在 192.168.139.218 新建离线、资源受限 V9 临时恢复容器及恢复库; +只有原生备份恢复、005 演练及失败恢复通过,才能迁移独立 QA 库。 +所有 Kingbase 业务写入均限定 `railwaysystem_fusion_qa_20261013`,或本轮新隔离容器中的专用恢复库。 +未修改共享 `training`、独立 `iotlocks` 等数据库;目标 QA 内的 `iotlocks` 是 Schema,不是另一个数据库。 +未扩大业务账号权限、修改系统网络、控制 MQTT/人脸/门锁设备、重启共享服务。 + +## 2. 镜像、许可、资源和共享服务 + +服务器只读确认:root UID0,hostname `localhost.localdomain`,NFSChina Server 4.0, +128 CPU、512 GiB 内存、可用约 372000 MiB,磁盘剩余约 535 GiB,负载低。 +现有 `kingbase` 容器 `dcd88030ca5a`,4321→54321,版本 `KingbaseES V009R003C018`; +原挂载 `/opt/kingbase-docker/data` 未修改或复用。原生工具目录 `/home/kingbase/install/kingbase/bin/`。 + +仅复用已存在镜像 `kingbase_v009r003c018b0003_single_x86:v1`: +`sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515`。 +宿主机已有导入 tar 的 manifest/config 散列与 Docker image ID 相符;无 RepoDigest, +未拉取新镜像,不声称额外验证了供应商签名或生产供应链来源。 + +从镜像原始层读取并用原生许可工具检查:V009 企业版试用授权,180天,容器名/MAC不绑定, +未见 vCPU 限额;未改写许可、复制生产凭据或连接许可服务器。新服务实际通过原生启动许可校验。 +这只证明现有获准 QA/试用范围可启动,不代表长期或生产部署许可。 +参考:[Kingbase Docker 安装与许可检查](https://bbs.kingbase.com.cn/kingbase-html/v9/install-updata/install-docker/install-docker-1.html)。 + +新容器 `railwaysystem-qa-restore-round5v4`: +`c11ca10490c242651c0e87c2778102cd9de32cd703b48b57c26801686d4ef41f`。 +实际运行过;Docker inspect 与 cgroup 实测确认 network=none、只有 lo、路由为空、 +PortBindings={}、2 CPU、2 GiB memory+swap总额、pids=256、privileged=false、drop NET_RAW, +没有 Docker Socket 或正式数据卷。唯一新数据挂载: +`/opt/railwaysystem-qa-restore-round5v4/data` → `/home/kingbase/userdata`。 +创建前检查 realpath、符号链接、名称和挂载冲突;专用随机凭据仅在 root0700 目录中的0600文件。 + +存储不是 5 GiB 硬配额。按用户允许的替代条件,独立监护进程每2秒统计任务目录、overlay及日志, +达到4 GiB或宿主机空闲低于100 GiB即核验容器身份后停止;日志有轮转上限。 +实际观测用量约219 MB,远低于阈值;没有 OOM。监护实现:`scripts/qa/round5v4_storage_guard.py`。 +共享 Kingbase/GitLab 的容器 ID 和启动时间一直未变(均为2026-10-06),GitLab healthy,其他服务持续运行。 +本轮恢复完成后仅停止上述新容器;不删除其数据、恢复证据或备份。 +首次Docker stop因镜像入口未响应而超时退出137(非OOM);仅重启该新实例后, +实际复核恢复库和原备份恢复库全量清单仍一致,再用原生sys_ctl fast正常停库(exit0), +sys_controldata确认cluster state=shut down,最后停止容器入口。没有重启共享实例。 + +## 3. 原生备份与实际恢复(不是文件读取替代) + +备份源:`railwaysystem_fusion_qa_20261013`;原生 V9 `sys_dump` 自定义归档, +此前同一导出快照的63表清单/指纹,与本轮迁移前源库及新恢复库再次逐项匹配。 +远端:`/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump`; +本机:`/Users/wzb/railway-fusion-qa-backup-20261014.6cgPn7/fusion-qa-pre005-v2.dump`。 +380907字节,SHA256:`caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4`。 +两份均在 Git 外受保护目录,原备份未改写、未删除。 + +在新实例创建 `railwaysystem_fusion_restore_round5v4`,用匹配版本 `sys_restore -O -x -1 -e` +实际恢复成功,退出0,stderr为空。Owner 映射到仅在新实例存在的 `round5v4_admin`, +不恢复源库认证配置/ACL;此为明确的恢复映射,而非遗漏生产认证。 + +实测恢复对象:public54表 + iotlocks9表;所有63表行数及排序行指纹一致; +892列、146索引、113约束、39序列、20扩展及Schema/默认值/触发器清单完成对账。 +旧授权10条、旧指令15条,培训学员47、参训57、住宿34条。 +唯一 native dump/reparse 语法差异是 `chk_external_operation_status` 的数组/元素 text cast: +只对这一个精确约束做规范化,另用原生SQL对10个允许值、4个非法值及NULL共15例验证语义无差异。 +其他元数据不宽泛忽略。恢复后基本只读查询通过。 +清单生成器:`scripts/qa/kingbase_manifest.sql`;受保护证据包含原生清单和JSON摘要。 + +## 4. 005 的隔离演练、回滚与源 QA 执行 + +005 SHA256:`90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f`。 +审计完整20条语句:16项新增列、4项新增索引(含唯一意图索引);无DROP/TRUNCATE/DELETE/覆写业务数据。 + +隔离 V9 实际通过: + +| 场景 | 实际结果 | +|---|---| +| 迁移中故意调用不存在函数 | SQLSTATE42883;全量DDL回滚,原结构和数据指纹一致 | +| 客户端在未提交005后断开 | 服务器自动回滚;结构/指纹一致 | +| 另一连接持读锁,DDL等锁500ms | SQLSTATE55P03;安全回滚,未破坏数据 | +| 005正常COMMIT | 16列/4索引、默认值、旧记录隔离均正确 | +| 同一005重复执行 | 全量结构/数据清单不变 | +| 重复意图键 | 原生唯一约束23505,事务回滚 | +| 从原备份进行失败恢复 | 在同一新容器内另建专用 recovery 库并实际恢复,完整清单与迁移前一致 | + +上述为真实数据库的事务、连接中断及恢复验证;另有收尾时仅临时实例非优雅停止后的重启完整性复核, +不是对共享/源QA数据库服务器断电作测试,也不是完整灾难恢复/长期故障注入认证。 +没有在源QA库上删除列/表或全库覆盖演练。 + +之后重新核验源数据库名/Schema、原备份SHA、63表清单均未变化、其他源库连接数0; +使用已有管理角色,不提权业务账号。设置 lock_timeout=3s、statement_timeout=30s, +显式事务及两表ACCESS EXCLUSIVE锁,在锁内再次核对原行指纹,执行相同005并COMMIT。 +源 QA 的后验清单与隔离迁移后清单完全对应。旧10授权/15命令为 LEGACY/QUARANTINED、 +version0、无来源环境/意图key,未转换为REAL。业务验收后再次过滤LEGACY核对其完整原记录指纹,仍一致。 +一次性执行/核验代码:`scripts/qa/round5v4_database_stage.py`。不要对已在使用的QA库重跑该一次性编排。 + +`fusion_qa_app` 真实连接成功,现有SELECT/INSERT/UPDATE及序列权限足够全部业务测试; +无superuser/createdb/createrole、无schema CREATE。既有 sys_menu/sys_notice_read/sys_role_menu +三表仍有TRUNCATE/TRIGGER/REFERENCES旧授权:本轮未使用、未扩大,也未擅自收回,建议管理员另行最小化。 + +## 5. 真实 Kingbase Service / Mapper / 事务与故障恢复 + +`Round5v4KingbaseIntegrationTest`:11项真实Kingbase测试,使用生产XML、Service、 +DataSourceTransactionManager及真实AOP事务代理;账号固定 fusion_qa_app;URL强校验仅允许目标QA库。 +外部仅替换 MQTT 传输,未加载真实MQTT或人脸HTTP客户端。恢复扫描前拒绝无关READY工作。 +合成 R5V4 数据保留审计,不删除原数据,不重置序列;用例后仅关闭自己的未尝试任务。 + +覆盖:入住、调宿撤旧建新、退宿意图;意图提交/命令创建前、命令创建/发送前、 +DISPATCHING占用提交/传输结果未知三种独立JVM `Runtime.halt`,再创建新Service读取持久化状态恢复; +UNKNOWN转MANUAL_REVIEW且零盲目重发;未尝试的FAIL撤权可补偿;4线程原生行锁仅发送一次; +唯一意图冲突;入住和退宿故障注入时住宿状态与意图同事务回滚;旧撤权ACK不覆盖新授权;MOCK到REAL边界隔离。 +数据库提交、Mock发送、双Mock回执均分别断言;不等于设备最终执行。 + +真实测试发现并修复此前未暴露的事务边界缺陷:桥接 AFTER_COMMIT 调用 REQUIRED 时仍可能 +加入已提交但尚未清理的事务,命令滞留READY。现在桥接回调通过 `LockExecutionTransactions.committed` +明确在 REQUIRES_NEW 中生成命令,再由它自己的提交回调占用/发送;业务原子性仍留在原事务内。 +修改位于 `TrainIotLockAuthBridgeServiceImpl.dispatchPushSafe/dispatchClearSafe`。 + +另通过85项选定后端回归、新增1项Boot4缺路由处理回归、12项QA工具测试及前端生产构建, +不把这些替代真实数据库结论。 + +## 6. 实际后端与 Chrome 验收 + +当前归档工程新进程,Java21.0.2 / Boot4.1.0,127.0.0.1:18096;生产前端127.0.0.1:18088; +独立Redis127.0.0.1:16389,口令随机、无持久化。不复用实验PID51901/8096或其他工程PID53305/8080。 +运行JAR有SHA绑定私有副本;停机核验账本/PID出生时间/命令/cwd并等待退出,无粗暴清端口。 + +有效参数强制:mqtt.enabled=false、mock=true、real-device-write-enabled=false、whitelist空、 +protocol-verified=false、retry-task-enabled=false、QA/OpenAPI关闭、人脸HTTP关闭且Mock、DMS关闭、Quartz不自动运行。 +数据库用户名/URL固定QA,秘密只从本机0600配置及私有随机运行配置注入。 +Kingbase/Druid实际探针发现URL中的socketTimeout=15被池解析为15ms;启动器移除URL超时并明确池60000ms, +同一探针实际验证60000ms。参考 [Druid 1.2.28 原始实现](https://github.com/alibaba/druid/blob/1.2.28/core/src/main/java/com/alibaba/druid/pool/DruidAbstractDataSource.java)。 +首页Kingbase分析SQL的 `::numeric` 改为等价标准CAST,消除Druid统计SQL解析异常;没有关闭SQL安全检查。 +负向运行检查还发现被禁用的MQTT/QA端点会被旧全局处理器误报成HTTP200/业务500; +新增对Boot4 NoResourceFoundException的脱敏404处理,并以单元及真实HTTP复验,不注册或开放控制接口。 + +真实 Google Chrome155.0.8059.40(headed)管理员登录成功,getInfo/getRouters=200; +现有QA验证码配置返回 captchaEnabled=false,未改开关,不声称完成启用状态验证码挑战。 +15路由逐页通过组件名、实际表格/图表、HTTP/业务code、JS错误检查: +首页Index/AdminDashboard四张真实ECharts、学员Student、教师Teacher、开班Plan、宿舍Dormitory、动态菜单住宿Accommodation, +以及9个IoT组件Group/Gateway/Device/Face/Auth/AccessLog/Command/MqttLog/ServiceConfig。 +最后一轮浏览器请求仅出现127.0.0.1:18088,无外部主机;浏览器设有外发拒绝保护,未发生外部请求阻断事件。 +品牌/Logo/侧边栏/动态菜单保留,无404/登录替身页;17:35前最终验收期间后端无SQL或未捕获异常。 +后续网络连接异常见本报告顶部,不能将该时点的成功外推为当前在线状态。 + +浏览器合成 fixture `R5V4463c944c9547465b`,学号后缀S、人员P5463c94: +预分配2043 → 真UI入住 → Mock grant确认 → 真UI调房到B/新住宿2044 → 旧权revoke+新权grant确认 +→ 真UI退宿 → revoke确认;两个授权1035/1036最终REVOKE版本2、CONFIRMED、sync4; +四条0011/0012命令均MOCK、CONFIRMED、网关/设备双ACK,住宿两条已退宿;UI与数据库再次一致核对。 +离线回执驱动只接受该fixture的当前MOCK命令,危险HTTP回执入口未启用。 +住宿履历和指令流水在真实Chrome中查看,不是仅调用API或替身页面。 + +前端旧页面引用模型不存在的checkInTime/checkOutTime,已改为真实assignedTime/updateTime, +标签为“分配时间/最近状态变更”;无额外DDL,不将updateTime冒充精确入住/退宿专用时间戳。 +原生备份、数据库/浏览器运行日志和截图仅保存在受保护Git外/忽略目录,不提交个人资料。 + +## 7. 证据位置、复现与剩余边界 + +- 远端专用根:`/opt/railwaysystem-qa-restore-round5v4/`,含原生恢复输出、结构/行指纹、 + isolated-results.json、source-migration-results.json、container-safety.json、historical-preservation.json、空间监护日志。 +- 本机受保护忽略目录 `.qa-runtime/round5v4/`:同名JSON、真实Kingbase JUnit/子JVM日志、 + 后端/前端构建、浏览器15页结果和闭环数据验证;JUnit XML在ruoyi-train/target/surefire-reports。 +- `output/playwright/`:最终首页、IoT九页(敏感数据行遮罩)、合成住宿履历/退宿/授权/指令证据;均不入Git。 +- 具体构建、隔离启停、Kingbase选定测试及浏览器夹具流程见根 `DEMO_RUNBOOK.md`。 + +软件功能QA已通过,但当前可运行就绪状态受上述网络问题阻塞,更不代表生产发布或真实硬件验收。 +仍须老师明确批准单台测试、确认真实协议校验字段/回执语义、 +提供现场设备与单台白名单、核对UNKNOWN人工处置流程;门禁保持关闭。 +Vue2/CLI4废弃依赖、旧历史素材/外部凭据轮换、既有账号过宽权限及长期商业许可仍须单独治理。 +本轮没有远程CI/部署验证,没有数据库服务器断电测试,没有真实IoT执行证据。 diff --git a/docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md b/docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md index 22c173d7e..4c3425ede 100644 --- a/docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md +++ b/docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md @@ -1,6 +1,15 @@ # Round 5V — isolated Kingbase migration and rollback gate -## Current Round 5V-3 decision +## Current Round 5V-4 outcome + +The separately authorized new offline V9 recovery container actually restored the native backup; +005 rehearsal, repeatability, transactional rollback and fresh-target backup recovery passed. +Only afterwards, the exact approved 16-column/4-index script committed on the named source QA database. +Real Kingbase service/transaction/process-interruption tests and Chrome software acceptance now passed. +See [Round 5V-4 evidence](ROUND5V4_SOFTWARE_QA.md). Do not rerun initialization or overwrite a live database. +The following sections retain the pre-migration plan/observations, not current missing-approval claims. + +## Historical Round 5V-3 decision The user explicitly authorized migration 005 **only** on `railwaysystem_fusion_qa_20261013`, together with protected backup and isolated diff --git a/scripts/qa/QaDruidProbe.java b/scripts/qa/QaDruidProbe.java new file mode 100644 index 000000000..53db0ea0b --- /dev/null +++ b/scripts/qa/QaDruidProbe.java @@ -0,0 +1,16 @@ +import com.alibaba.druid.pool.DruidDataSource; +import java.sql.*; +/** Read-only actual Kingbase/Druid initialization probe, secrets only from environment. */ +class QaDruidProbe { + public static void main(String[] args)throws Exception { + String url=System.getenv("QA_DB_URL"); + if(url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException(); + try(DruidDataSource ds=new DruidDataSource()) { + ds.setDriverClassName("com.kingbase8.Driver");ds.setUrl(url);ds.setUsername("fusion_qa_app");ds.setPassword(System.getenv("QA_DB_PASSWORD")); + ds.setInitialSize(1);ds.setMaxActive(2);ds.setMinIdle(1);ds.setConnectTimeout(30000);ds.setSocketTimeout(60000); + if(args.length>0)ds.setConnectionProperties(args[0]); + ds.setValidationQuery("select 1");ds.setConnectionInitSqls(java.util.List.of("set search_path to iotlocks,public,pg_catalog,sys_catalog")); + ds.init();try(Connection c=ds.getConnection()){c.setReadOnly(true);c.setAutoCommit(false);try(Statement s=c.createStatement();ResultSet r=s.executeQuery("select current_database(),current_user")){r.next();System.out.println(r.getString(1)+" | "+r.getString(2)+" | network_timeout_ms="+c.getNetworkTimeout());}c.rollback();} + } + } +} diff --git a/scripts/qa/browser_fixture.py b/scripts/qa/browser_fixture.py new file mode 100644 index 000000000..2c4980339 --- /dev/null +++ b/scripts/qa/browser_fixture.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Explicit synthetic fixture/Mock ACK driver; no QA HTTP endpoints or real device transport.""" +import argparse +import os +from pathlib import Path +import subprocess +import xml.etree.ElementTree as ET +import qa + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('action',choices=['seed','ack','state','verify']) + parser.add_argument('--approve-synthetic-qa-writes',action='store_true',required=True) + parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env') + args=parser.parse_args() + env=qa.environment(qa.load_config(args.config));qa.private_directory() + directory=qa.RUNTIME/'round5v4';directory.mkdir(mode=0o700,exist_ok=True) + env.update(ROUND5V4_KINGBASE_TESTS='1',ROUND5V4_LOG_DIR=str(directory)) + report=qa.ROOT/'RuoYi-Vue/ruoyi-train/target/surefire-reports/TEST-com.ruoyi.train.integration.lock.Round5v4KingbaseIntegrationTest.xml' + tree=ET.parse(report);cp=tree.find("./properties/property[@name='surefire.test.class.path']").attrib['value'] + command=['java','-cp',cp,'com.ruoyi.train.integration.lock.Round5v4BrowserFixture',args.action] + result=subprocess.run(command,cwd=qa.ROOT,env=env,text=True,capture_output=True,timeout=90) + with open(directory/('browser-fixture-'+args.action+'.log'),'w',opener=lambda p,f:os.open(p,f,0o600)) as log: + log.write(result.stdout+result.stderr) + for line in result.stdout.splitlines(): + if line.startswith(('BROWSER_FIXTURE_CREATED','FIXTURE_AUTH','FIXTURE_COMMAND','FIXTURE_BROWSER_DATABASE')):print(line) + print('FIXTURE_DRIVER_EXIT='+str(result.returncode)) + return result.returncode + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/scripts/qa/browser_login.py b/scripts/qa/browser_login.py new file mode 100644 index 000000000..36a7f1cb5 --- /dev/null +++ b/scripts/qa/browser_login.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Log into the task's Chrome QA session without credentials in CLI arguments, code, or logs. + +The protected configuration is read through File API on a separate about:blank page, +never uploaded to the application; only two login values are filled, solely in memory. +Requires the Playwright CLI skill and an already-open isolated round5v4 Chrome session. +""" +import argparse +import json +from pathlib import Path +import subprocess +import qa + +CODE = r'''async (page) => { + if(!USE_CURRENT_PAGE){await page.context().clearCookies();await page.goto('http://127.0.0.1:18088/login');} + await page.getByRole('textbox',{name:'账号',exact:true}).waitFor(); + await page.waitForLoadState('networkidle'); + page.__qaEvidence={responses:[],errors:[]}; + if(page.__qaErrorListener)page.removeListener('pageerror',page.__qaErrorListener); + if(page.__qaResponseListener)page.removeListener('response',page.__qaResponseListener); + page.__qaErrorListener=error=>page.__qaEvidence.errors.push(error.message); + page.__qaResponseListener=async response=>{const url=response.url();if(!url.includes('/prod-api/'))return;let code=null;try{code=(await response.json()).code;}catch{}page.__qaEvidence.responses.push({path:url.split('/prod-api')[1],status:response.status(),code});}; + page.on('pageerror',page.__qaErrorListener);page.on('response',page.__qaResponseListener); + const local=await page.context().newPage();let credentials={}; + try { + await local.goto('about:blank');await local.setContent(''); + await local.locator('#protected-config').setInputFiles(CONFIG_PATH); + credentials=await local.locator('#protected-config').evaluate(async input=>{ + const text=await input.files[0].text();const vals={}; + for(const line of text.split('\n')){const m=line.match(/^(?:export )?(QA_ADMIN_USERNAME|QA_ADMIN_PASSWORD)=(.*)$/);if(m){let v=m[2].trim();if((v.startsWith("'")&&v.endsWith("'"))||(v.startsWith('"')&&v.endsWith('"')))v=v.slice(1,-1);vals[m[1]]=v;}} + return vals; + }); + }finally{await local.close();} + if(!credentials.QA_ADMIN_USERNAME||!credentials.QA_ADMIN_PASSWORD)throw new Error('Protected QA login configuration missing'); + if(await page.getByRole('textbox',{name:'验证码',exact:true}).isVisible()){ + if(!CAPTCHA_ANSWER)throw new Error('Captcha enabled: complete visual captcha manually; do not disable it'); + await page.getByRole('textbox',{name:'验证码',exact:true}).fill(CAPTCHA_ANSWER); + } + await page.getByRole('textbox',{name:'账号',exact:true}).fill(credentials.QA_ADMIN_USERNAME); + await page.getByRole('textbox',{name:'密码',exact:true}).fill(credentials.QA_ADMIN_PASSWORD);credentials={}; + await page.getByRole('button',{name:'登 录'}).click();await page.waitForURL(url=>url.origin==='http://127.0.0.1:18088'&&!['/login','/404','/401'].includes(url.pathname),{timeout:20000});await page.waitForLoadState('networkidle'); + return {url:page.url(),title:await page.title(),api:page.__qaEvidence.responses,errors:page.__qaEvidence.errors}; +}''' + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env') + parser.add_argument('--cli',type=Path,default=Path.home()/'.codex/skills/playwright/scripts/playwright_cli.sh') + parser.add_argument('--captcha',default='',help='Answer from the actual displayed captcha, never disable validation') + parser.add_argument('--use-current-page',action='store_true') + args=parser.parse_args();qa.load_config(args.config) + code=CODE.replace('CONFIG_PATH',json.dumps(str(args.config.expanduser().resolve()))).replace('CAPTCHA_ANSWER',json.dumps(args.captcha)).replace('USE_CURRENT_PAGE',str(args.use_current_page).lower()) + result=subprocess.run([str(args.cli),'-s=round5v4','run-code',code],text=True,capture_output=True,timeout=45) + # The emitted code contains a path, never credential values. Do not snapshot a filled login form. + print(result.stdout) + return result.returncode + +if __name__=='__main__':raise SystemExit(main()) diff --git a/scripts/qa/browser_regression.js b/scripts/qa/browser_regression.js new file mode 100644 index 000000000..0becf7a90 --- /dev/null +++ b/scripts/qa/browser_regression.js @@ -0,0 +1,41 @@ +async (page) => { + if (!page.__qaEvidence) throw new Error('Run the protected Chrome login driver first'); + const blockedHosts=new Set(),requestHosts=new Set(); + // Real pages/API only; never substitute responses or allow a stored photo URL to contact a device service. + await page.route('**/*',async route=>{ + const url=route.request().url(),host=url.match(/^https?:\/\/([^/]+)/)?.[1]; + if(host)requestHosts.add(host); + if(host&&host!=='127.0.0.1:18088'){blockedHosts.add(host);await route.abort('blockedbyclient');} + else await route.continue(); + }); + const routes = [ + ['/index', null], ['/person/student', 'Student'], ['/person/teacher', null], + ['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'], + ...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth', + accesslog:'AccessLog',command:'Command',mqttlog:'MqttLog',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c]) + ]; + const results=[]; + for (const [path,expected] of routes) { + const start=page.__qaEvidence.responses.length; + await page.goto('http://127.0.0.1:18088'+path); + await page.waitForLoadState('networkidle'); + const components=await page.locator('.app-main').evaluate(e=>{ + function names(v){return v?[v.$options.name||v.$options._componentTag||'',...v.$children.flatMap(names)]:[];} + return names(e.__vue__).filter(n=>n&&!n.startsWith('El')); + }); + const api=[...new Map(page.__qaEvidence.responses.slice(start).map(r=>[JSON.stringify(r),r])).values()]; + const tables=await page.locator('.app-main .el-table').count(); + const pass=page.url()==='http://127.0.0.1:18088'+path&& + (await page.title()).includes('SYFJGYD')&&(!expected||components.includes(expected))&& + !components.some(n=>/404|401|ErrorPage/i.test(n))&&api.length>0&& + api.every(r=>r.status===200&&(r.code==null||r.code===200))&&page.__qaEvidence.errors.length===0&&blockedHosts.size===0&& + (path==='/index'?await page.locator('canvas').count()===4:tables>0); + results.push({path,pass,components:[...new Set(components)],tables,api}); + if(!pass)throw new Error('Actual component/API regression failed: '+JSON.stringify(results.at(-1))); + if(path==='/index')await page.screenshot({path:'output/playwright/round5v4-home-final.png',fullPage:true}); + if(path.startsWith('/iot/'))await page.screenshot({path:'output/playwright/round5v4-'+path.split('/').at(-1)+'-headers.png', + mask:[page.locator('.el-table__body-wrapper'),page.locator('.avatar-container')]}); + } + return {pass:true,chrome:page.context().browser().version(),results,errors:page.__qaEvidence.errors, + requestHosts:[...requestHosts],blockedExternalHosts:[...blockedHosts]}; +} diff --git a/scripts/qa/kingbase_manifest.sql b/scripts/qa/kingbase_manifest.sql new file mode 100644 index 000000000..7465aa999 --- /dev/null +++ b/scripts/qa/kingbase_manifest.sql @@ -0,0 +1,35 @@ +\set ON_ERROR_STOP on +BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; +SET LOCAL statement_timeout='30s'; +SET LOCAL lock_timeout='5s'; +SET LOCAL timezone='UTC'; +SET LOCAL search_path=pg_catalog,sys_catalog,public,iotlocks; +SELECT current_database() IN ('railwaysystem_fusion_qa_20261013','railwaysystem_fusion_restore_round5v4','railwaysystem_fusion_recovery_round5v4') AS allowed_database +\gset +\if :allowed_database +\else +\echo REFUSING_UNAPPROVED_DATABASE +\quit 3 +\endif +SELECT 'IDENTITY',current_database(),current_user,version(); +SELECT 'SCHEMA',nspname FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname; +SELECT 'TABLE',schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename; +SELECT format('SELECT %L,count(*),md5(coalesce(string_agg(row_to_json(t)::text,%L ORDER BY row_to_json(t)::text),%L)) FROM %I.%I t;',concat('DATA:',schemaname,'.',tablename),'','',schemaname,tablename) +FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename +\gexec +SELECT 'LEGACY_DATA:lock_auth',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'])::text,'' ORDER BY auth_id),'')) FROM iotlocks.lock_auth t; +SELECT 'LEGACY_DATA:lock_command',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at'])::text,'' ORDER BY command_id),'')) FROM iotlocks.lock_command t; +SELECT 'COLUMN',table_schema,table_name,column_name,ordinal_position,data_type,is_nullable,coalesce(column_default,''),coalesce(character_maximum_length::text,'') +FROM information_schema.columns WHERE table_schema IN ('public','iotlocks') ORDER BY table_schema,table_name,ordinal_position; +SELECT 'INDEX',schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename,indexname; +SELECT 'CONSTRAINT',n.nspname,t.relname,c.conname,c.contype,pg_get_constraintdef(c.oid) +FROM pg_constraint c JOIN pg_class t ON c.conrelid=t.oid JOIN pg_namespace n ON n.oid=t.relnamespace +WHERE n.nspname IN ('public','iotlocks') ORDER BY n.nspname,t.relname,c.conname; +SELECT 'TRIGGER',n.nspname,t.relname,g.tgname,pg_get_triggerdef(g.oid) +FROM pg_trigger g JOIN pg_class t ON t.oid=g.tgrelid JOIN pg_namespace n ON n.oid=t.relnamespace +WHERE n.nspname IN ('public','iotlocks') AND NOT g.tgisinternal ORDER BY n.nspname,t.relname,g.tgname; +SELECT 'SEQUENCE',schemaname,sequencename,start_value,min_value,max_value,increment_by,cycle,cache_size,last_value +FROM pg_sequences WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,sequencename; +SELECT 'EXTENSION',extname,extversion FROM pg_extension ORDER BY extname; +SELECT 'OWNER',schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename; +ROLLBACK; diff --git a/scripts/qa/qa.py b/scripts/qa/qa.py index 56782efc2..4b199fc26 100644 --- a/scripts/qa/qa.py +++ b/scripts/qa/qa.py @@ -9,10 +9,12 @@ from pathlib import Path import secrets import shlex import signal +import shutil import socket import stat import subprocess import sys +import time import urllib.parse import xml.etree.ElementTree as ET @@ -95,6 +97,15 @@ def available(port): probe.bind(('127.0.0.1', port)) +def druid_url(url): + # Druid 1.2.28 also parses URL timeout values as pool milliseconds, whereas + # Kingbase JDBC uses seconds. Keep pool timeout configuration in one place. + parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:')) + query = [(k,v) for k,v in urllib.parse.parse_qsl(parsed.query) + if k.lower() not in ('connecttimeout', 'sockettimeout')] + return 'jdbc:' + urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) + + def launch(name, command, env): private_directory() ledger = RUNTIME / (name + '.json') @@ -114,6 +125,15 @@ def start(config, env, backend_port, redis_port): available(backend_port) available(redis_port) private_directory() + built_jar = ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar' + if not built_jar.is_file(): + raise ValueError('Build backend jar first') + digest = hashlib.sha256(built_jar.read_bytes()).hexdigest() + runtime_jar = RUNTIME / ('ruoyi-admin-' + digest[:16] + '.jar') + if not runtime_jar.exists(): + shutil.copyfile(built_jar, runtime_jar) + if hashlib.sha256(runtime_jar.read_bytes()).hexdigest() != digest: + raise ValueError('Runtime artifact hash mismatch') secret_path = RUNTIME / 'runtime-secrets.env' if not secret_path.exists(): with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f: @@ -125,17 +145,23 @@ def start(config, env, backend_port, redis_port): if not config.get(key): raise ValueError('Missing required protected identity configuration') env[key] = config[key] - env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'], + runtime_url = druid_url(env['QA_DB_URL']) + env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_url, SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app', SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'], SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD']) redis_config = RUNTIME / 'redis.conf' with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f: f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n') - command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'), + command = ['java', '-jar', str(runtime_jar), '--spring.profiles.active=druid', - '--spring.datasource.druid.master.url=' + env['QA_DB_URL'], + '--spring.datasource.druid.master.url=' + runtime_url, '--spring.datasource.druid.master.username=fusion_qa_app', + '--spring.datasource.druid.master.connect-timeout=30000', + '--spring.datasource.druid.master.socket-timeout=60000', + '--spring.datasource.druid.master.initial-size=2', + '--spring.datasource.druid.master.min-idle=2', + '--spring.datasource.druid.master.max-active=8', '--server.address=127.0.0.1', '--server.port=' + str(backend_port), '--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port), '--spring.data.redis.database=0', '--spring.quartz.auto-startup=false', @@ -153,6 +179,16 @@ def start(config, env, backend_port, redis_port): launch('backend', command, env) +def wait_for_exit(pid): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], text=True, capture_output=True).stdout.strip() + if not state or state.startswith('Z'): + return + time.sleep(0.1) + raise ValueError('Task-owned process did not stop; ledger retained, no forced kill') + + def stop(): private_directory() for name in ['frontend', 'backend', 'redis']: @@ -174,6 +210,7 @@ def stop(): if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started): raise ValueError('PID identity mismatch; refusing to signal ' + name) os.kill(item['pid'], signal.SIGTERM) + wait_for_exit(item['pid']) path.unlink() print('Stopped task-owned ' + name) diff --git a/scripts/qa/round5v4_database_stage.py b/scripts/qa/round5v4_database_stage.py new file mode 100644 index 000000000..8da87720a --- /dev/null +++ b/scripts/qa/round5v4_database_stage.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Run only on the authorized host. Isolated rehearsal first; source phase is separately gated.""" +import argparse +from collections import Counter +import hashlib +import json +from pathlib import Path +import re +import select +import subprocess + +ROOT = Path('/opt/railwaysystem-qa-restore-round5v4') +RESTORE = 'railwaysystem-qa-restore-round5v4' +RESTORE_DB = 'railwaysystem_fusion_restore_round5v4' +RECOVERY_DB = 'railwaysystem_fusion_recovery_round5v4' +SOURCE_DB = 'railwaysystem_fusion_qa_20261013' +BIN = '/home/kingbase/install/kingbase/bin/' +MIGRATION_SHA = '90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f' +DUMP = Path('/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump') +DUMP_SHA = 'caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4' +RESULTS = {} + + +def argv(container, database): + assert (container == 'kingbase' and database == SOURCE_DB) or (container == RESTORE and database in [RESTORE_DB,RECOVERY_DB,'kingbase']) + role = 'kingbase' if container == 'kingbase' else 'round5v4_admin' + return ['docker','exec','-i',container,BIN+'ksql','-X','-w','-p','54321','-U',role,'-d',database,'-Atq'] + + +def sql(container,database,body,label,expected=None): + result = subprocess.run(argv(container,database),input='\\set ON_ERROR_STOP on\n\\set VERBOSITY sqlstate\n'+body+'\n',text=True,capture_output=True,timeout=90) + (ROOT/(label+'.stdout')).write_text(result.stdout) + (ROOT/(label+'.stderr')).write_text(result.stderr) + if expected: + assert result.returncode != 0 and expected in result.stderr,(label,result.returncode,result.stderr[:100]) + else: + assert result.returncode == 0,(label,result.returncode,result.stderr[:100]) + return result.stdout.strip() + + +def manifest(container,database,label): + raw=sql(container,database,(ROOT/'manifest.sql').read_text(),label) + lines=raw.splitlines() + assert sum(l.startswith('TABLE|') for l in lines)==63 + assert sum(l.startswith('DATA:') for l in lines)==63 + return lines + + +def canonical(lines): + out=[] + for line in lines: + if line.startswith(('IDENTITY|','OWNER|')):continue + if line.startswith('CONSTRAINT|public|train_external_operation|chk_external_operation_status|'): + # Only this actual native dump/reparse cast difference is admitted, after SQL truth-table verification. + states=re.findall(r"'([^']*)'::varchar",line) + assert states==['CREATED','DISPATCHING','REQUESTED','ACCEPTED','PENDING','RETRY_WAIT','AMBIGUOUS','SUCCESS','FAILED','TIMEOUT'] + line='CONSTRAINT_EQUIVALENT_STATUS_SET|'+','.join(states) + out.append(line) + return out + + +def verify_constraint_equivalence(source,restored): + prefix='CONSTRAINT|public|train_external_operation|chk_external_operation_status|' + a=next(l for l in source if l.startswith(prefix)).split('|',5)[5] + b=next(l for l in restored if l.startswith(prefix)).split('|',5)[5] + assert a.startswith('CHECK (') and b.startswith('CHECK (') + values=re.findall(r"'([^']*)'::varchar",a)+['created','INVALID','',' '] + rows=','.join("('%s'::varchar)"%v.replace("'","''") for v in values)+',(NULL::varchar)' + result=sql(RESTORE,RESTORE_DB,'BEGIN READ ONLY; SELECT count(*) FROM (VALUES '+rows+') AS cases(operation_status) WHERE ('+a[7:-1]+') IS DISTINCT FROM ('+b[7:-1]+'); ROLLBACK;','constraint-equivalence') + assert result=='0',result + RESULTS['constraint_equivalence_cases']=len(values)+1 + + +def migration(): + p=ROOT/'005.sql';raw=p.read_bytes();assert hashlib.sha256(raw).hexdigest()==MIGRATION_SHA + statements=[s.strip() for s in re.sub(r'--[^\n]*','',raw.decode()).split(';') if s.strip()] + assert len(statements)==20 + assert all(re.match(r'^(ALTER TABLE iotlocks\.(lock_auth|lock_command) ADD COLUMN IF NOT EXISTS |CREATE (UNIQUE )?INDEX IF NOT EXISTS \w+ ON iotlocks\.(lock_auth|lock_command)\()',s) for s in statements) + return raw.decode() + + +def begin(): + return "BEGIN; SET LOCAL lock_timeout='3s'; SET LOCAL statement_timeout='30s';\n" + + +def validate_post(container,database,label): + result=sql(container,database,"""BEGIN READ ONLY; +SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND +((table_name='lock_auth' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at')) OR +(table_name='lock_command' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at'))); +SELECT count(*) FROM pg_indexes WHERE schemaname='iotlocks' AND indexname IN ('uq_round5s_command_intent','uq_round5s_auth_intent','ix_round5s_command_recovery','ix_round5s_auth_recovery'); +SELECT count(*) FROM iotlocks.lock_auth WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL; +SELECT count(*) FROM iotlocks.lock_command WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL OR attempt_started_at IS NOT NULL OR device_sn IS NOT NULL; +SELECT count(*) FROM iotlocks.lock_auth; SELECT count(*) FROM iotlocks.lock_command; ROLLBACK;""",label) + assert result.splitlines()==['16','4','0','0','10','15'],result + + +def isolated(): + assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA + before=manifest(RESTORE,RESTORE_DB,'restored-baseline') + source=manifest('kingbase',SOURCE_DB,'source-baseline') + verify_constraint_equivalence(source,before) + assert canonical(source)==canonical(before),'Source and restore contents differ' + assert all(l.split('|')[-1]=='round5v4_admin' for l in before if l.startswith('OWNER|')) + RESULTS['native_restore_integrity']='PASS: 63 tables/data fingerprints, schema/columns/indexes/constraints/triggers/sequences/extensions' + body=migration() + sql(RESTORE,RESTORE_DB,begin()+body+'\nSELECT round5v4_deliberate_missing_function();','rollback-error','42883') + assert canonical(manifest(RESTORE,RESTORE_DB,'after-error'))==canonical(before) + RESULTS['ddl_error_transaction_rollback']='PASS' + # EOF disconnects an uncommitted migration connection; native server must roll back. + sql(RESTORE,RESTORE_DB,begin()+body,'rollback-disconnect') + assert canonical(manifest(RESTORE,RESTORE_DB,'after-disconnect'))==canonical(before) + RESULTS['ddl_client_disconnect_rollback']='PASS' + holder=subprocess.Popen(argv(RESTORE,RESTORE_DB),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + try: + holder.stdin.write("BEGIN; LOCK TABLE iotlocks.lock_auth IN ACCESS SHARE MODE; SELECT 'HOLDER_READY';\n");holder.stdin.flush() + assert select.select([holder.stdout],[],[],10)[0] and holder.stdout.readline().strip()=='HOLDER_READY' + sql(RESTORE,RESTORE_DB,"BEGIN; SET LOCAL lock_timeout='500ms'; SET LOCAL statement_timeout='3s';\n"+body,'lock-timeout','55P03') + finally: + holder.stdin.write('ROLLBACK;\n\\q\n');holder.stdin.flush();holder.wait(timeout=10) + assert canonical(manifest(RESTORE,RESTORE_DB,'after-lock-timeout'))==canonical(before) + RESULTS['ddl_lock_timeout_rollback']='PASS' + sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-commit') + validate_post(RESTORE,RESTORE_DB,'post-migration-check') + post=manifest(RESTORE,RESTORE_DB,'isolated-post') + assert [l for l in before if l.startswith('LEGACY_DATA:')]==[l for l in post if l.startswith('LEGACY_DATA:')] + sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-repeat') + assert canonical(manifest(RESTORE,RESTORE_DB,'after-repeat'))==canonical(post) + RESULTS['migration_commit_repeat_legacy_isolation']='PASS' + sql(RESTORE,RESTORE_DB,"""BEGIN; +INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000001,'R5V4_UNIQUE_A','0012','1','round5v4-unique-intent'); +INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000002,'R5V4_UNIQUE_B','0012','1','round5v4-unique-intent'); +COMMIT;""",'unique-intent-rejection','23505') + assert canonical(manifest(RESTORE,RESTORE_DB,'after-unique-rollback'))==canonical(post) + RESULTS['unique_intent_and_rollback']='PASS' + # Fresh target only: never DROP/overwrite either the source QA or the first restored database. + sql(RESTORE,'kingbase','CREATE DATABASE '+RECOVERY_DB+' OWNER round5v4_admin TEMPLATE template0;', 'create-recovery-db') + with DUMP.open('rb') as stream: + r=subprocess.run(['docker','exec','-i',RESTORE,BIN+'sys_restore','-w','-p','54321','-U','round5v4_admin','-d',RECOVERY_DB,'-O','-x','--no-tablespaces','-1','-e'],stdin=stream,capture_output=True,timeout=90) + (ROOT/'recovery-restore.stderr').write_bytes(r.stderr) + assert r.returncode==0 and not r.stderr + assert canonical(manifest(RESTORE,RECOVERY_DB,'fresh-recovered'))==canonical(before) + RESULTS['native_backup_failure_recovery']='PASS' + assert canonical(manifest('kingbase',SOURCE_DB,'source-still-unmodified'))==canonical(source) + RESULTS['source_unchanged_during_rehearsal']='PASS' + (ROOT/'isolated-results.json').write_text(json.dumps(RESULTS,indent=2)) + print(json.dumps(RESULTS,indent=2)) + + +def source_migrate(): + proof=json.loads((ROOT/'isolated-results.json').read_text()) + required=['native_restore_integrity','ddl_error_transaction_rollback','ddl_client_disconnect_rollback', + 'ddl_lock_timeout_rollback','migration_commit_repeat_legacy_isolation', + 'unique_intent_and_rollback','native_backup_failure_recovery','source_unchanged_during_rehearsal'] + assert all(proof.get(k,'').startswith('PASS') for k in required) + assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA + baseline=(ROOT/'source-baseline.stdout').read_text().strip().splitlines() + current=manifest('kingbase',SOURCE_DB,'source-immediately-before') + assert canonical(current)==canonical(baseline),'Source changed after the proven backup' + sessions=sql('kingbase',SOURCE_DB,'BEGIN READ ONLY; SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid(); ROLLBACK;','source-other-sessions') + assert sessions=='0','Other source QA sessions exist; coordinate writers first' + # Recheck affected data under the actual migration locks, not only before acquiring them. + guards=[] + for table,extra in [('lock_auth',[]),('lock_command',['device_sn','attempt_started_at'])]: + prefix='LEGACY_DATA:'+table+'|' + expected=next(l for l in current if l.startswith(prefix)).split('|')[2] + excluded=['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at']+extra + array='ARRAY['+','.join("'%s'"%s for s in excluded)+']' + key='auth_id' if table=='lock_auth' else 'command_id' + expression="(SELECT md5(coalesce(string_agg((to_jsonb(t)-"+array+")::text,'' ORDER BY "+key+"),'')) FROM iotlocks."+table+" t)" + guards.append("IF "+expression+" IS DISTINCT FROM '"+expected+"' THEN RAISE EXCEPTION 'QA data changed; migration refused'; END IF;") + guard='DO $guard$ BEGIN '+''.join(guards)+' END $guard$;\n' + body=begin()+"LOCK TABLE iotlocks.lock_auth,iotlocks.lock_command IN ACCESS EXCLUSIVE MODE;\n"+guard+migration()+'\nCOMMIT;' + sql('kingbase',SOURCE_DB,body,'source-migration-commit') + validate_post('kingbase',SOURCE_DB,'source-post-migration-check') + post=manifest('kingbase',SOURCE_DB,'source-after-migration') + isolated_post=(ROOT/'isolated-post.stdout').read_text().strip().splitlines() + assert canonical(post)==canonical(isolated_post),'Unexpected source schema/data difference' + result={'source_qa_migration':'PASS','schema_and_data_match_rehearsal':'PASS', + 'legacy_auth_count':10,'legacy_command_count':15,'added_columns':16,'added_indexes':4, + 'privileges_expanded':False,'other_databases_modified':False} + (ROOT/'source-migration-results.json').write_text(json.dumps(result,indent=2));print(json.dumps(result,indent=2)) + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--source-only',action='store_true');args=parser.parse_args() + source_migrate() if args.source_only else isolated() diff --git a/scripts/qa/round5v4_storage_guard.py b/scripts/qa/round5v4_storage_guard.py new file mode 100644 index 000000000..e37ed947f --- /dev/null +++ b/scripts/qa/round5v4_storage_guard.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Bounded watchdog for ONLY the separately authorized Round5V4 restore container.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import time + +NAME = 'railwaysystem-qa-restore-round5v4' +IMAGE = 'sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515' +ROOT = Path('/opt/railwaysystem-qa-restore-round5v4') +LIMIT = 4 * 1024 ** 3 # stop below the approved 5GiB budget; bind has NO hard quota +RESERVE = 100 * 1024 ** 3 + + +def command(*args): + return subprocess.check_output(args, text=True, timeout=20).strip() + + +def inspect(container_id): + item = json.loads(command('docker', 'inspect', container_id))[0] + if item['Id'] != container_id or item['Name'] != '/' + NAME or item['Image'] != IMAGE: + raise RuntimeError('Container identity mismatch; no unrelated process will be signaled') + if item['Config'].get('Labels', {}).get('railway.task') != 'round5v4': + raise RuntimeError('Missing task ownership label') + mounts = [m for m in item['Mounts'] if m['Destination'] == '/home/kingbase/userdata'] + if len(mounts) != 1 or Path(mounts[0]['Source']).resolve() != ROOT / 'data': + raise RuntimeError('Unexpected data mount') + return item + + +def used_bytes(path): + return int(command('du', '-sk', str(path)).split()[0]) * 1024 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--once', action='store_true') + args = parser.parse_args() + if ROOT.is_symlink() or ROOT.resolve() != ROOT or (ROOT / 'data').is_symlink(): + raise RuntimeError('Task paths must not be symlinks') + container_id = (ROOT / 'container.id').read_text().strip() + ever_running = False + while True: + item = inspect(container_id) + running = item['State']['Running'] + ever_running = ever_running or running + upper = Path(item['GraphDriver']['Data']['UpperDir']).resolve() + if not str(upper).startswith('/var/lib/docker/overlay2/') or upper.name != 'diff': + raise RuntimeError('Unexpected writable-layer location') + used = used_bytes(ROOT) + used_bytes(upper) + log_path = item.get('LogPath') + used += Path(log_path).stat().st_size if log_path and Path(log_path).exists() else 0 + free = shutil.disk_usage(ROOT).free + print(json.dumps({'time': time.time(), 'running': running, 'used_bytes': used, + 'stop_threshold_bytes': LIMIT, 'host_free_bytes': free}), flush=True) + if used >= LIMIT or free < RESERVE: + # Revalidate immutable task ownership immediately before stopping. + inspect(container_id) + subprocess.run(['docker', 'stop', '--time', '15', container_id], check=True, timeout=25, + stdout=subprocess.DEVNULL) + print('STOPPED_TASK_CONTAINER_STORAGE_THRESHOLD', flush=True) + return + if args.once or (ever_running and not running): + return + time.sleep(2) + + +if __name__ == '__main__': + main() diff --git a/scripts/qa/run_kingbase_tests.py b/scripts/qa/run_kingbase_tests.py new file mode 100644 index 000000000..85062e9e3 --- /dev/null +++ b/scripts/qa/run_kingbase_tests.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Opt-in synthetic writes to the migrated, named Kingbase QA database only. No DDL or network devices.""" +import argparse +import os +from pathlib import Path +import subprocess +import qa + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--approve-synthetic-qa-writes', action='store_true', required=True) + parser.add_argument('--config', type=Path, default=Path.home()/'.railwaysystem-qa.env') + args = parser.parse_args() + env = qa.environment(qa.load_config(args.config)) + if qa.inspect(env, ['--require-migrated']): + raise SystemExit('QA schema preflight failed') + qa.private_directory() + directory = qa.RUNTIME/'round5v4' + directory.mkdir(mode=0o700, exist_ok=True) + env.update(ROUND5V4_KINGBASE_TESTS='1', ROUND5V4_LOG_DIR=str(directory)) + command = ['mvn', '-o', '-q', '-f', 'RuoYi-Vue/pom.xml', '-pl', 'ruoyi-train', '-am', + '-Dtest=Round5v4KingbaseIntegrationTest', '-Dsurefire.failIfNoSpecifiedTests=false', 'test'] + with open(directory/'kingbase-tests.log','w',opener=lambda p,f:os.open(p,f,0o600)) as log: + result = subprocess.run(command, cwd=qa.ROOT, env=env, stdout=log, stderr=subprocess.STDOUT) + print('KINGBASE_TEST_EXIT='+str(result.returncode)) + print('PRIVATE_LOG='+str(directory/'kingbase-tests.log')) + return result.returncode + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/scripts/qa/test_qa_tools.py b/scripts/qa/test_qa_tools.py index d88cb68e0..c34875dd7 100644 --- a/scripts/qa/test_qa_tools.py +++ b/scripts/qa/test_qa_tools.py @@ -34,6 +34,10 @@ class QaToolsTest(unittest.TestCase): with self.assertRaises(ValueError): qa.environment({}) + def test_druid_timeout_units_cannot_be_overridden_by_jdbc_url_seconds(self): + url = 'jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?connectTimeout=5&socketTimeout=15&characterEncoding=utf8' + self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url)) + def test_unsafe_inherited_overrides_removed(self): with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}): env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'}) @@ -91,7 +95,7 @@ class QaToolsTest(unittest.TestCase): def test_stop_accepts_python_wrapper_only_with_task_identity(self): with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \ - patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill: + patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill, patch.object(qa, 'wait_for_exit'): script = str(qa.ROOT / 'scripts/qa/serve_frontend.py') command = ['/usr/local/bin/python3', script, '--port', '18088'] (Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))