test(invariants): Phase 23 Task 2.2 —— INV-011 显式行为守卫(9 用例 + 两次负向注入验证)

产出 Inv011EvidenceAccessGuardTest:非领取人 / 跨用户佐证 / 非 local_eligibility 任务一律拒绝,
且 successful 与 denied 两条路径都断言 sensitive_access_logs 真实落库(不用 mock 断言)。
另含 3 个结构性守卫(用户侧端点无用户可控 userId 入参、佐证读取入口的双重权限约束、
佐证读取不暴露可伪造 operatorId)与 1 个守卫自检(参数名反射可读,防断言静默永真)。

前置修复:schema-h2.sql 补 sensitive_access_logs 表 —— 该表此前在测试库完全不存在,
而它是「访问留痕」命题的唯一落点,缺失导致该命题根本无法断言(表漂移盲区,与 Phase 12/15/19 同族)。

负向注入两次,均精确命中:
① 停用 requireClaimOwner → 恰 1 红(非领取人用例),其余 8 绿
② 停用 catch 内 denied 审计 → 恰 2 红(非领取人 + 跨用户),第 3 条走 try 外的另一路径故正确未红
两次注入均已还原并复跑全绿(9/9);V11InvariantsTest 12/12 绿;UI 契约门禁 18/18 绿。

文档:Task 2.2 闭环记录 + 附 A 扩至 7 处校正(含对本文档初稿「机构创建 500 归因」的自我更正,
实测为 chk_org_status 违约而非 id 为空)+ 附 B 扩至 11 项新发现
(5 个 Mapper 方法声明了却无实现、隐私设置三方契约冲突 + 表/实体主键错配、
测试镜像 mvn -o 不可用、本机全量回归会卡住)。
This commit is contained in:
WorkBuddy
2026-09-19 14:02:52 +08:00
parent e666ee6f8a
commit b834fa86a0
4 changed files with 561 additions and 19 deletions
@@ -18,7 +18,7 @@
| 项 | 状态 |
|---|---|
| Phase 01~22 | ✅ 复验关闭 |
| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1)**,仅余 E2E 在 CI 上的首次实测绿 |
| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1)**;**INV-011 已补真实行为守卫(2026-09-19,见 Task 2.2,9 用例 + 两次注入验证)**。仅余 E2E 在 CI 上的首次实测绿 |
| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → **65 passed**) |
| 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046)+ 端口/入口守卫 |
| 后端回归 | **669 / 0 failures / 0 errors / 3 skipped** |
@@ -36,7 +36,11 @@
- **禁止用 `rm` / `git rm` 删除文件** —— 本机有 safe-delete 钩子会把「删文件」放大为「删父目录」(2026-09-18 实测:`git rm` 单个文件导致整个 `docs/` 被移除)。需要移除内容时**一律原地重写**(Write/Edit)。
- 不修改业务规则、不扩大 V1 范围;新增能力必须走 Phase 顺序。
- 每批完成后跑 `node scripts/run-ui-contracts.mjs`(**守卫数量以执行器 `MIN_GUARDS` 为准;2026-09-19 实为 18 个,须 18/18 绿** —— 原文「14 个 / 14/14」已过期:2026-09-19 起相继新增 `wxml-structure-contracts`、`miniapp-self-method-contracts`、`wxss-component-selector-contracts`、`miniapp-api-client-behavior`)+ `ui-document-baseline-contracts.mjs`(含端口与 `/admin/` 子路径断言)。
- 后端改动跑离线容器 `mvn -o test`,必须 0 failures / 0 errors。
- 后端改动跑容器 `mvn test`(**2026-09-19 校正:不能用 `-o` 离线** ——
本机可用的测试镜像 `danyang-match-backend-test` 里缺 `surefire-junit-platform`,
离线模式必报 `BUILD FAILURE`;首次运行需联网下载该 provider,之后才有缓存。
原文「离线容器 `mvn -o test`」在本机**不可执行**),必须 0 failures / 0 errors。
命令见附 B-9。
- **E2E 改动跑 `pytest tests/e2e`**(需活栈;本机 `E2E_BASE_URL=http://localhost:8085/api/v1`、`E2E_OPERATOR_PASSWORD` 见 CI 变量),必须 0 failed。
本机复跑前若出现 `429 账号已锁定`,是 Redis 里的登录失败锁定键(`auth:operator_login_lock:<operatorId>`,TTL 15 分钟),
由前一轮失败登录累积触发 —— 删掉该键即可,不是业务缺陷。
@@ -148,16 +152,51 @@
`fileId` 必须属于该用户自己的 `user_photos`(`userPhotoMapper.findByUserIdOrderBySort`)。
- 测试基建:`@SpringBootTest @ActiveProfiles("test")`(H2,`schema-h2.sql`),参照 `V11InvariantsTest`。
- [ ] **Step 1:** 写负向测试:非授权角色读取他人本地资格佐证 → 必须 403/不可达。
(建议同时覆盖:非领取人、跨用户佐证、非 local_eligibility 任务、`purpose` 为空)
- [ ] **Step 2:** 确认真实落库路径(不是只断言常量,见 `CoreInvariantsTest` 的教训)——
即断言 `sensitive_access_logs` 真的新增了对应 `result` 的记录。
- [ ] **Step 3:** 负向注入验证守卫变红。
⚠️ 本机后端跑的是**当日构建的 jar**,改 Java 代码需重建镜像;注入应优先选
**不需要重建**的手段(如撤销 `local_eligibility.evidence.view` 的角色授予、
或直接改库内状态),否则该步骤成本极高。
- [x] **Step 1 / Step 2:** 写负向测试 + 真实落库断言 ✅(2026-09-19)
- [x] **Step 3:** 负向注入验证守卫变红 ✅(2026-09-19,**两次注入,均精确命中**)
**Acceptance:** INV-011 有真实行为守卫。
**产出**:`backend/src/test/java/.../invariants/Inv011EvidenceAccessGuardTest.java`(9 个用例,全绿)
**前置修复(必须先做,否则 Step 2 无从落地)**
- `backend/src/test/resources/schema-h2.sql` **原本没有 `sensitive_access_logs` 表** →
「审计真的落库了吗」这条断言在测试期根本无法编写。已按 V001 + V034 + V035 补齐
(不建 FK,因测试库无 `operators` 表;与 `review_tasks` 同风格)。详见附 B-8。
**9 个用例的分工(两条独立命题 + 一个反证 + 两个边界 + 三个结构性守卫)**
| 用例 | 咬住的命题 |
|---|---|
| `inv011NonClaimantDeniedAndAudited` | 非领取人 → 拒绝 + `denied` 落库 |
| `inv011CrossUserEvidenceDeniedAndAudited` | 领取人拿自己 taskId 读**他人**佐证 → 拒绝 + `denied` |
| `inv011NonLocalEligibilityTaskDeniedAndAudited` | 非 `local_eligibility` 任务 → 拒绝 + `denied` |
| `inv011ClaimOwnerSucceedsAndAudited` | **反证**:领取人读本任务佐证 → 放行 + `success`(排除「全局拒绝」假阳性) |
| `inv011BlankPurposeRejectedWithoutAudit` | 边界:`purpose` 空 → 拒绝且**不留痕**(早退于触及用户数据之前) |
| `inv011UnknownTaskRejectedWithoutAudit` | 边界:`taskId` 不存在 → 拒绝且**不留痕** |
| `inv011UserSideEndpointsTakeNoUserControllableUserId` | 结构性:用户侧 4 个端点**无用户可控 `userId` 入参**,身份只来自 `@AuthenticationPrincipal` |
| `inv011EvidenceReadEndpointRequiresDedicatedAuthority` | 结构性:佐证读取入口**类级 `review.view` + 方法级 `local_eligibility.evidence.view`** 双重约束,且不暴露伪造用 `operatorId` 入参 |
| `inv011GuardSelfCheckParameterNamesAreReadable` | 守卫自检:反射能读到参数名(否则上一条会**静默永真**) |
> 关于「403」的口径:两级 `@PreAuthorize` 在 HTTP 层给 403;业务层给 `RuntimeException`。
> 本测试**两层分别覆盖** —— 业务层走真实落库断言,HTTP 层走注解反射断言(见后两个结构性用例)。
**Step 3 负向注入记录(两次,均精确命中,无一误伤)**
| 注入 | 手段 | 预期 | 实测 |
|---|---|---|---|
| ① | 停用 `requireClaimOwner(task, operatorId)` | 归属校验失效 | **恰 1 红**:`inv011NonClaimantDeniedAndAudited`("Expected RuntimeException to be thrown, but nothing was thrown"),其余 8 绿 ✓ |
| ② | 停用 `catch` 块内的 `logEvidenceAccess(..., "denied")` | 拒绝路径不留痕 | **恰 2 红**:`inv011NonClaimantDeniedAndAudited`、`inv011CrossUserEvidenceDeniedAndAudited`("应恰好落库 1 条审计(实得 0 条)"),其余 7 绿 ✓ |
> 注入②中 `inv011NonLocalEligibilityTaskDeniedAndAudited` **正确地未变红** ——
> 它走的是 try 块**之外**的那条 `logEvidenceAccess`(任务类型不符的早退分支),
> 不受注入影响。这从侧面证明用例确实咬住了**两条不同的代码路径**,而不是重复断言同一处。
>
> 两次注入后均已还原并**复跑全绿**(9/9),`git diff` 中不存在 `ReviewService.java` → 无残留。
**与原文的一处校正**:Step 3 原注「改 Java 代码需重建镜像,成本极高」**不再成立** ——
镜像 `danyang-match-backend-test` 内含 JDK+Maven,挂载源码即可直接跑 surefire,
注入-验证-还原一轮约 3.5 分钟。详见附 B-9。
**Acceptance:** ✅ INV-011 已从「常量口径 + 指针」升级为**真实行为守卫**(9 用例 + 两次注入验证)。
### Task 2.3: E2E 进入 CI(**已决策并落地**,2026-09-18)
@@ -292,15 +331,34 @@
| Global Constraints / 验收纪律 / 当前停点 | UI 契约门禁 14 个、14/14 绿 | **18 个**(2026-09-19 相继新增 4 项守卫),基线 18/18 |
| Task 2.1 | `tests/e2e/**` 现为占位文件 | **仅 2 个文件**是占位;其余在 Phase 23B 已是真实用例 |
| 当前停点 | 本机活栈 E2E `49 passed` | 基线 49,Task 2.1 完成后 **65 passed** |
| Global Constraints | 后端改动跑**离线**容器 `mvn -o test` | 本机测试镜像缺 `surefire-junit-platform`,`-o` **必然 BUILD FAILURE**;须去掉 `-o` 联网跑(附 B-9) |
| Task 2.2 Step 3 | 「改 Java 代码需重建镜像,注入成本极高」 | 镜像 `danyang-match-backend-test` 内含 JDK 21 + Maven + 依赖缓存,**挂载源码即可跑 surefire**,一轮注入≈3.5 分钟(附 B-9) |
| 附 B-1(初稿) | 机构创建 500 因「从不给 `id` 赋值 → uuid 空串」 | **归因错误**。实测为 `violates check constraint "chk_org_status"`(status 被写成 `normal`);`id` 由 `AutoFillHandler` 补全,不会为空。已在 B-1 内留痕更正 |
### B. 新发现(不在本计划内,需另行处置)
1. **`POST /operator/organizations` 必然 500**(High)
`OrganizationService.createOrganization` 从不给 `id` 赋值,而 `BaseEntity` 用
`@TableId(type = IdType.INPUT)`、`organizations.id` 无 DB 默认值 →
`invalid input syntax for type uuid: ""`。且该接口**未校验 `type`**,
会撞生产 `chk_org_type`(白名单:`women_federation / trade_union / charity_center /
volunteer_team / civil_affairs_partner / other`),大概率在 PG 上再炸一次。
1. **`POST /operator/organizations` 必然 500 —— 根因是 status 取值,不是 id**(High)
`OrganizationService.createOrganization` 写 `org.setStatus(Constants.ACCOUNT_STATUS_NORMAL)`
即 `"normal"`,而 `organizations.type/status` 的 CHECK 白名单(V001)是
`chk_org_status: active / inactive` → **CHECK 违约**。
**2026-09-19 实测复核**(本机活栈,`e2e_admin` 打 `POST /api/v1/operator/organizations`,
`type` 取合法值 `charity_center`):
```
HTTP 500
{"success":false,"code":"SERVER_ERROR","message":"服务端内部错误,请联系管理员",...}
后端日志: Caused by: org.postgresql.util.PSQLException:
ERROR: new row for relation "organizations" violates check constraint "chk_org_status"
```
> **归因更正**(本条初稿有误,如实留痕):初稿写作「从不给 `id` 赋值 →
> `invalid input syntax for type uuid: ""`」。该归因**错误**:`OrganizationMapper` 是纯
> `BaseMapper`(无自定义 insert),`AutoFillHandler.insertFill` 会在 `id == null` 时补 UUID,
> 故 id 不会为空。真实根因是 status 取值不在白名单。**教训同附 A —— 代码级推断必须实测复核,
> 不能凭「读了实现」就下结论。**
仍成立的次要问题:该接口**未校验 `type`**,非白名单值会撞 `chk_org_type`
(`women_federation / trade_union / charity_center / volunteer_team /
civil_affairs_partner / other`)。
影响:运营端无法新建机构 → 无机构则**无法新建活动**(`activities.organizer_org_id` NOT NULL)。
2. **互选记录永不过期**(Medium,即批 3 Task 3.3 的 P16-T13)
`createMutualConsent` 从不写 `expires_at`(列无默认值),而
@@ -330,8 +388,83 @@
本机活栈若复用旧卷,口令会停在上一轮的值 → 大量 401。
本机已用 `UPDATE operators SET password_hash = crypt('<CI 口令>', gen_salt('bf',8)) WHERE username LIKE 'e2e\_%'`
对齐到 CI 口令。(这与 Task 2.3 顺带修的 CI 缺陷 #5 是同一根因的两面。)
8. **H2 测试库缺 `sensitive_access_logs` 表**(Medium,Task 2.2 顺手修复)
生产有该表(V001 建表 + V034 加 `access_target`/`access_result` + V035 补 `updated_at`/`deleted_at`),
而 `backend/src/test/resources/schema-h2.sql` **完全没有它**。而 `ReviewService.logEvidenceAccess`
在成功与拒绝两条路径上都必然写它 → 后果是:**INV-011 的「访问留痕」命题在测试期根本无法断言**,
只能退化成常量断言(这正是 Task 2.2 之前的状态)。已按三份迁移补齐列定义(不建 FK,
因测试库无 `operators` 表;与 `review_tasks` / `local_eligibility_evidences` 同风格)。
与 schema-h2.sql 中已记录的 Phase 12/15/19 系列「表/约束漂移盲区」同族。
9. **本机跑 Java 测试的通道**(工具性发现,可复用)
本机**无 JDK / 无 Maven / 无 `~/.m2`**(`mvn` 报 `Could not find Maven installation`),
但存在镜像 `danyang-match-backend-test`(940MB,内含 Temurin JDK 21 + Maven 3.9.16 +
`/root/.m2` 132MB 依赖缓存 + 完整源码)。挂载源码即可直接跑 surefire:
```bash
docker run --rm -v "E:/develop/公益婚恋服务平台/backend":/app -w /app \
danyang-match-backend-test:latest mvn test -Dtest=<测试类> -DfailIfNoTests=false
```
- 首次运行需**联网**下载 `surefire-junit-platform`(该 jar 不在缓存里);`-o` 离线模式会失败。
- 单次编译 + 运行约 3.5 分钟;`backend/target/` 会被容器写回宿主机(该目录已忽略,无碍)。
- **重要**:这使 Task 3.x 一类「改 Java 代码」的验证不再需要重建后端镜像 ——
原 Task 2.2 Step 3 标注的「改 Java 需重建镜像、成本极高」不再成立,负向注入可直接做。
- ⚠️ **本机跑「全量」后端测试会卡住(实测)**:2026-09-19 尝试 `mvn test` 跑全部 102 个测试类,
运行 **1 小时仍未产出任何 surefire 报告**(`docker ps` 显示容器在跑,`surefire-reports/` 无新文件),
疑似某个 `@SpringBootTest` 阻塞在无超时的外部连接上。已强制终止。
**结论:本机只适合跑「单个测试类」回归**(`-Dtest=<类名>`,约 3.5 分钟);
全量回归应交给 CI(Task 2.3 已把 backend test 串进流水线)。
本机验证策略因此为:改动哪个测试类就单跑哪个 + 依赖 CI 做全量兜底。
10. **5 个 Mapper 方法「声明了却无实现」**(High,**未实测触发,但判定依据可复核**)
`Backend` 的 mapper 统一用**注解式 SQL**(如 `ActivityCapacityGroupMapper` 的 `@Select`/`@Update`)。
以下方法**既无 SQL 注解、也无 XML**:
| Mapper 方法 | 调用点 | 后果 |
|---|---|---|
| `ReviewReasonMapper.findAllEnabled` | `ReviewService:92,143` | 审核原因选项接口**必崩** |
| `UserPreferenceMapper.findByUserId` | `MatchEligibilityService:59,107`;`RecommendationEngineService:140,141,216,219` | 推荐打分 / 匹配资格判定**必崩** |
| `UserPrivacySettingsMapper.findByUserId` | `RecommendationEngineService:334`;`PrivacyService` | 推荐 + 隐私设置读写**必崩** |
| `ReviewItemMapper.findByReviewTaskId` | 无 | 死方法 |
| `ReviewReasonMapper.findByCategory` | 无 | 死方法 |
**判定依据**(三条均已复核):① 全仓无任何 mapper XML —— `Glob backend/src/main/resources/**/*.xml` 返回空;
② `pom.xml` 无自定义 `ISqlInjector` / `MapperAnnotationBuilder` —— MyBatis-Plus 只会为 `BaseMapper`
的 CRUD 注入语句;③ 对照类 `ActivityCapacityGroupMapper` 的同位置方法均带注解。
→ 上述方法在 MyBatis 解析期抛 `BindingException: Invalid bound statement (not found)`。
**为什么此前没人发现**:本机无候选用户、无审核数据,这些路径的前置条件为空
(例:`getDailyRecommendations` 先 `isParticipantEligible` 再查候选池,候选池为空就不会走到
打分环节)→ **测试环境用「数据为空」掩盖了「代码必崩」**。生产有数据即触发。
**未实测的原因**:需要构造「有候选用户 / 有审核任务」的数据集才能从 HTTP 层触发。
建议的**机械化验证**(比人工审查强):新增一个守卫测试,用
`sqlSessionFactory.getConfiguration().hasStatement(<MapperFqn>.<methodName>)` 遍历全部
Mapper 接口的声明方法 —— 无绑定即失败。这样一类缺陷可被一次性、持续地挡住。
11. **隐私设置的「三方契约冲突」+ 表/实体主键错配**(High —— 直接影响 Task 3.1 第 1 项的工作量评估)
原文把 Task 3.1 第 1 项描述为「接真实 `user_privacy_settings`」的**接线**工作。
实测发现它**不是接线**,而是需要**契约裁定**:
| 来源 | 字段集 |
|---|---|
| 生产表(V002) | `visible_in_recommendations` / `visible_in_search` / `show_occupation` / `show_lifestyle_photos` / `receive_activity_recommendations` / `accept_matchmaker_recommendations` |
| 小程序页 `pages/privacy-settings` | `visibleInRecommendations` / `allowActivityInvitation` / `showLastOnline` |
| `AccountLifecycleService`(桩) | `visibleInRecommendations` / `allowActivityInvitation` / `showLastOnline` —— **与前端一致** |
| `PrivacyService`(看似「真实实现」) | 与生产表一致的六个 —— **与前端仅 1 个交集** |
- **小程序的 `allowActivityInvitation` / `showLastOnline` 在表里没有对应列**;
表里的 5 列在小程序里没有 UI。三方只有 `visibleInRecommendations` 同时存在。
- 也就是说:**桩是「照前端契约写的假数据」,而「真实实现」是「照表结构写的」** ——
两者根本没在同一个契约上。直接「接线」会立刻打断小程序页面。
- 另有**表/实体错配**:`user_privacy_settings` 主键是 `user_id`、且**无 `id` / `deleted_at` 列**,
而实体 `UserPrivacySettings extends BaseEntity`(带 `@TableId` 的 `id` 与 `deletedAt`)→
`PrivacyService.updatePrivacySettings` 里的 `privacySettingsMapper.insert(...)` 会尝试写 `id` 列 →
**必然失败**(且它对该插入路径用 `insert` 而非 upsert,即使列对齐也存在「已存在记录再插入」的问题)。
- **需要用户/产品三选一**:以表为准改造前端 UI / 以小程序为准补迁移加列 / 加一层映射。
在此之前 Task 3.1 第 1 项**不应动手**。
- 附带:`user_privacy_settings` / `user_preferences` / `review_reasons` / `review_items`
**四张表均不在 H2 测试库**(同附 B-8 的表漂移盲区),即上表所有方法在测试期都无法被触达验证。
---
*创建: 2026-09-18 — 承接 `docs/文档治理报告-2026-09-18.md` 与 STATUS.md 的两处悬空引用(D1)*
*审阅校正: 2026-09-19 — 按实测推翻 4 处失实断言(见附 A),登记 7 项新发现(见附 B)*
*审阅校正: 2026-09-19 — 按实测推翻 7 处失实断言(见附 A,含 1 处**对本文档自身初稿的更正**),登记 11 项新发现(见附 B)*
*执行进展: 2026-09-19 — Task 2.1(E2E 16 场景)、Task 2.2(INV-011 守卫)均已完成并验证*