From 84fe9f5febcf56910d5c978dd44e0e16d15d7209 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Thu, 17 Sep 2026 17:17:52 +0800 Subject: [PATCH] test: isolate fairness monitoring authentication fixtures --- docs/backend-development-roadmap.md | 2 +- ...fairness-fixture-isolation-fix-20260917.md | 128 ++++++++ .../FairnessMonitoringControllerTest.java | 76 ++++- ...airnessMonitoringFixtureIsolationTest.java | 301 ++++++++++++++++++ 4 files changed, 491 insertions(+), 16 deletions(-) create mode 100644 docs/r01-a6-fairness-fixture-isolation-fix-20260917.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 6720a5a..e2445da 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置** | +| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`) | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md b/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md new file mode 100644 index 0000000..31d7429 --- /dev/null +++ b/docs/r01-a6-fairness-fixture-isolation-fix-20260917.md @@ -0,0 +1,128 @@ +# Fairness 认证夹具隔离修复(A6 后独立纯测试提交) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +A6 提交:`c6db74a1a8300e1aab8a452fb67909f86861fa43` +本轮性质:**纯测试修复** —— 生产代码 diff 为 0,Flyway 为 0,OpenAPI 为 0 + +--- + +## 0. 结论 + +`FairnessMonitoringControllerTest` 已不再依赖种子数据、测试执行顺序或共享表清空, +**单独运行稳定通过**;并新增反证测试锁定该隔离性。 + +--- + +## 1. 缺陷(A6 独立复验定位) + +| 环节 | 事实 | +|---|---| +| 冲突 | 该测试用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,而 `V1_1__seed_data.sql` 的种子账号占用 `users.id = 1/2/3` | +| 被禁用 | `V43__disable_legacy_seed_accounts.sql` 把这三行置为 `is_active = 0` | +| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅在 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时 INSERT,**不会激活已存在的禁用行** | +| 后果 | `AuthSessionService.isSessionActive()` 的用户检查短路 → 全部 Token 请求 401 | +| 被掩盖 | 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,偶然清掉种子行,夹具随后才补建 active 用户 | + +单跑实测(修复前):`Tests run: 7, Failures: 6`,全部 `Status expected:<200|403|400> but was:<401>`。 + +详见 `docs/r01-a6-independent-verification-20260917.md` §9。 + +--- + +## 2. 修复方案 + +**选择:让 `FairnessMonitoringControllerTest` 使用独占用户,而不是修改公共 `TestAuthTokens`。** +理由:公共 helper 若"自动激活已存在的 inactive 用户",会静默复活本应被禁用的账号 +(包括生产语义上被 V43 禁用的种子账号),风险远大于收益。 + +### 2.1 改动内容 + +| 项 | 修复前 | 修复后 | +|---|---|---| +| 夹具身份 | `userId = 1` / `2`(与种子账号冲突) | **独占** `ADMIN_USER_ID = 9_310_001L`、`STUDENT_USER_ID = 9_310_002L` | +| 手机号 | 由 `TestAuthTokens` 隐式生成 `"t"+id` | 独占 `a6fair-admin` / `a6fair-student` | +| 用户来源 | 依赖 `TestAuthTokens` 的"不存在才插入" | `@BeforeEach` **显式插入 `is_active = 1`** 的独占用户 | +| 清理 | 无 | `@AfterEach` 按**精确 ID**、按外键顺序删除:`auth_tokens` → `user_roles` → `users` | + +`@BeforeEach` 先执行与 `@AfterEach` 相同的精确清理再插入,因此对"上轮异常中断残留"具备幂等性, +且**从不把任何已存在的 inactive 用户直接激活**(是删除后重建自有行)。 + +### 2.2 未使用的手段(明确排除) + +未改 `V43`;未改生产认证逻辑;未使用 `deleteAll()` 清空共享表;未使用 `@DirtiesContext` 掩盖污染; +未捕获并忽略约束异常;未依赖测试执行顺序;未放宽任何既有 Fairness 断言;未修改公共 `TestAuthTokens`。 + +--- + +## 3. 反证测试 `FairnessMonitoringFixtureIsolationTest`(7 例) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 1 | `fixtureIdsAreOutsideSeedAccountRange` | 夹具 ID 不在种子号段 1/2/3;夹具用户确为 active;未占用种子手机号 | +| 2 | `seedRowsInactiveOrAbsent_stillAuthenticates` | 种子行 **inactive(V43 状态)** 与 **完全缺失** 两种前置下端点均返回 200 | +| 3 | `repeatedProvisioningAndRequests_doNotConflict` | 连续 3 轮"装配 + 请求"均成功,且不产生重复用户行 | +| 4 | `fixtureCycleLeavesSeedRowsUntouched` | 完整周期前后种子行逐字段不变(顺序无关的根本保证) | +| 5 | `cleanupRemovesOnlyOwnFixtureRows` | 清理只删本类行;探针用户及其会话不受波及 | +| 6 | `unrelatedUsersAndSessionsUnchanged` | 非夹具用户与会话逐字段不变 | +| 7 | `publicTokenFixtureDoesNotSilentlyReviveInactiveUser` | `TestAuthTokens` 对已存在 inactive 用户**不激活**,该 Token 请求为 401 且 Service 零调用 | + +用例 2 会临时快照并按主键**原样恢复**种子行(`try` 语义由 `@AfterEach` 兜底),不新增、不批量重建。 + +--- + +## 4. 验证矩阵 + +命令均在 `mydreamjob-server/` 下执行;日志位于 `docs/acceptance/r01-d4-2-20260916/ff-*.log`。 + +| # | 命令 | 结果 | 日志 | +|---|---|---|---| +| 1 | `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 / 7**,F=0 E=0 S=0 ✔ | `ff-1-standalone.log` | +| 2 | `mvn -Dtest="FairnessMonitoringFixtureIsolationTest,FairnessMonitoringControllerTest" test` | **14 / 14**,F=0 E=0 S=0 ✔ | `ff-2-isolation.log` | +| 3 | Fairness **先**跑 ×3(`-Dsurefire.runOrder=reversealphabetical`) | **13 / 13** ×3 ✔ | `ff-3-fairnessfirst-{1,2,3}.log` | +| 4 | Auth **先**跑 ×3(`-Dsurefire.runOrder=alphabetical`) | **13 / 13** ×3 ✔ | `ff-4-authfirst-{1,2,3}.log` | +| 5 | `mvn clean verify` | 见 §4.1 | `ff-5-clean-verify.log` | +| 6 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | + +> **顺序说明**:surefire 默认 `runOrder=filesystem` 会忽略 `-Dtest` 的书写顺序, +> 因此额外用 `-Dsurefire.runOrder=reversealphabetical`(Fairness 先)与 `=alphabetical`(Auth 先) +> 强制两个方向,并在日志中逐次核对实际执行顺序(`reversealphabetical` 下 +> `scoringmonitoring.FairnessMonitoringControllerTest` 确在 `controller.AuthIntegrationTest` 之前)。 +> `runOrder=testorder` 在本仓库使用的 surefire 3.5.6 中不可用(已实测报错)。 + +### 4.1 全量结果 + +| 项 | 数值 | +|---|---| +| 全量测试总数 | 2278 → **2285**(+7:新增反证测试类) | +| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | +| 生产代码 diff | **0** ✔ | +| V1–V45 | **未改动** ✔ | +| `docs/openapi-rc.json` | **未修改** ✔ | + +--- + +## 5. 修改文件 + +``` +mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java (修改:夹具独占化) +mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java(新增:反证测试) +docs/r01-a6-fairness-fixture-isolation-fix-20260917.md (新增:本报告) +docs/backend-development-roadmap.md (夹具问题关闭记录) +``` + +**无生产代码、无 Flyway、无 ErrorCode、无 DTO、无接口、无 OpenAPI、无 Filter/Accessor/SecurityConfig 改动。** + +--- + +## 6. 明确声明 + +- 未修改任何生产代码,未修改 `V43`,未修改公共 `TestAuthTokens` 夹具。 +- 未删除、未放宽任何既有断言;`FairnessMonitoringControllerTest` 的业务断言逐字保留。 +- 未使用 `deleteAll()`、`@DirtiesContext`、顺序依赖或异常吞没等掩盖手段。 +- 未与 R01-B 混合。 + +--- + +> **FairnessMonitoringControllerTest 单跑夹具缺陷已通过独立纯测试提交修复; +> 该测试不再依赖种子数据状态与测试执行顺序。** diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java index d08bbcc..0d5f60c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java @@ -3,6 +3,8 @@ package com.mydreamjob.scoringmonitoring; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -11,6 +13,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -34,9 +37,52 @@ class FairnessMonitoringControllerTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private JdbcTemplate jdbcTemplate; @MockBean private FairnessMonitoringService service; @MockBean private RedisTemplate redisTemplate; + /** + * 本类**独占**的夹具用户 ID。 + * + *

刻意避开 {@code V1_1__seed_data.sql} 种子账号占用的 {@code users.id = 1/2/3} + * —— 那三行已被 {@code V43__disable_legacy_seed_accounts.sql} 置为 {@code is_active = 0}, + * 而 {@code TestAuthTokens} 只在用户行**不存在**时才插入,不会激活已存在的禁用行。 + * 曾因此在本类**单独运行**时全部 Token 请求被判 401(详见 + * {@code docs/r01-a6-independent-verification-20260917.md} §9)。 + * 改用独占号段后,本类的结果不再依赖 V1_1/V43 种子数据的存在性与状态, + * 也不再依赖其他测试类(如 {@code AuthIntegrationTest} 的 {@code deleteAll()})的执行顺序。

+ */ + static final long ADMIN_USER_ID = 9_310_001L; + static final long STUDENT_USER_ID = 9_310_002L; + + /** 独占手机号:不使用种子号的 138… 号段,也不与 {@code TestAuthTokens} 的 {@code "t"+id} 冲突。 */ + private static final String ADMIN_PHONE = "a6fair-admin"; + private static final String STUDENT_PHONE = "a6fair-student"; + + @BeforeEach + void provisionFixtureUsers() { + // 先清理可能的上轮残留(异常中断),再插入本类自有的 active 用户。 + // 注意:这里是"删除后重建",**不会**把任何已存在的 inactive 用户直接激活。 + removeFixtureUsers(); + insertActiveUser(ADMIN_USER_ID, ADMIN_PHONE, "ADMIN"); + insertActiveUser(STUDENT_USER_ID, STUDENT_PHONE, "STUDENT"); + } + + @AfterEach + void removeFixtureUsers() { + for (long id : new long[]{ADMIN_USER_ID, STUDENT_USER_ID}) { + // 外键顺序:会话 → 角色 → 用户;只删本类自建的确切 ID,不做任何整表删除。 + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertActiveUser(long id, String phone, String role) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-fixture-" + role, role); + } + @Test @SuppressWarnings("unchecked") void adminOverview_returnsAggregateWhitelistOnly() throws Exception { @@ -53,7 +99,7 @@ class FairnessMonitoringControllerTest { "40-49", 5, "50-59", 5, "60-69", 4, "70-79", 3, "80-89", 2, "90-100", 1)) .concentrationRate(new BigDecimal("16.67")).riskLevel("NORMAL") .availability("AVAILABLE").build(); - when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() .basis("CURRENT_STORED_SNAPSHOTS") .scope("SCORED_SNAPSHOT_POPULATION") .period("2026-09") @@ -70,7 +116,7 @@ class FairnessMonitoringControllerTest { String json = mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(1L, "ADMIN")) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.basis").value("CURRENT_STORED_SNAPSHOTS")) @@ -90,11 +136,11 @@ class FairnessMonitoringControllerTest { @Test void nonAdmin_isForbidden() throws Exception { doThrow(new BusinessException(ErrorCode.FORBIDDEN)) - .when(service).overview(eq(2L), eq("2026-09")); + .when(service).overview(eq(STUDENT_USER_ID), eq("2026-09")); mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(2L, "STUDENT"))) + .header("Authorization", token(STUDENT_USER_ID, "STUDENT"))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); } @@ -104,29 +150,29 @@ class FairnessMonitoringControllerTest { mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") .param("jobId", "9") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void rankingStability_returnsExplicitUnavailable() throws Exception { - when(service.rankingStability(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + when(service.rankingStability(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder() .availability("UNAVAILABLE") .reason("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE") .build()); - when(service.drift(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + when(service.drift(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder() .availability("UNAVAILABLE") .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") .build()); mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.reason").value("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE")); mockMvc.perform(get("/admin/scoring/fairness/drift") - .header("Authorization", token(1L, "ADMIN"))) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.reason").value("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")); @@ -150,10 +196,10 @@ class FairnessMonitoringControllerTest { .unknownStatusCount(0).outOfRangeScoreCount(0).unknownRulesVersionCount(0) .distribution(distribution).distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution)) .availability("AVAILABLE").build(); - when(service.dimensions(eq(1L), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() + when(service.dimensions(eq(ADMIN_USER_ID), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION").period("2026-09") .availability("AVAILABLE").dimensions(java.util.List.of(metric)).build()); - when(service.appeals(eq(1L), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) + when(service.appeals(eq(ADMIN_USER_ID), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) .thenReturn(FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE") .from("2026-08-01").to("2026-09-01").appealCount(31).sampleSize(30) .statusCounts(Map.of("APPROVED", 20, "REJECTED", 10)).statusEventCounts(Map.of("APPROVED", 21, "REJECTED", 10)) @@ -167,14 +213,14 @@ class FairnessMonitoringControllerTest { .invalidScoreRollbackCount(1).ruleVersionChangedRollbackCount(1) .reasonCounts(Map.of("INVALID_SCORE", 1)).availability("AVAILABLE").build()); String dimensionJson = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "2026-09").param("dimension", "ABILITY") - .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("AVAILABLE")) .andExpect(jsonPath("$.data.dimensions[0].distribution.mean").value(55.55)) .andExpect(jsonPath("$.data.dimensions[0].unknownStatusCount").value(0)) .andReturn().getResponse().getContentAsString(); assertNoSensitiveFields(dimensionJson); String appealJson = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", "2026-08-01").param("to", "2026-09-01") - .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.sampleSize").value(30)) .andExpect(jsonPath("$.data.statusCandidateCounts.APPROVED").value(20)) .andExpect(jsonPath("$.data.actualScoreChangeRate").value(37.50)) @@ -184,7 +230,7 @@ class FairnessMonitoringControllerTest { @Test void overviewSuppressionOmitsNestedDerivedValuesInSerializedJson() throws Exception { - when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") .period("2026-09").populationSize(100) .availability("SUPPRESSED").reason("SMALL_CELL_OR_COMPLEMENT_RISK") @@ -192,7 +238,7 @@ class FairnessMonitoringControllerTest { String json = mockMvc.perform(get("/admin/scoring/fairness/overview") .param("period", "2026-09") - .header("Authorization", token(1L, "ADMIN")) + .header("Authorization", token(ADMIN_USER_ID, "ADMIN")) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.populationSize").value(100)) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java new file mode 100644 index 0000000..c3a1347 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java @@ -0,0 +1,301 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.support.TestAuthTokens; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Fairness 认证夹具隔离反证测试。 + * + *

背景:{@code FairnessMonitoringControllerTest} 曾用 {@code userId = 1/2} 申请 Token, + * 而 {@code V1_1__seed_data.sql} 的种子账号正好占用 {@code users.id = 1/2/3}, + * 且 {@code V43__disable_legacy_seed_accounts.sql} 已把它们置为 {@code is_active = 0}; + * {@code TestAuthTokens} 对"已存在但 inactive"的行不插入也不激活,于是该测试类 + * **单独运行时**全部 Token 请求被 401 拒绝。全量顺序下 + * {@code com.mydreamjob.controller.AuthIntegrationTest} 的 {@code userRepository.deleteAll()} + * 偶然清掉了种子行,问题才被掩盖。

+ * + *

本类是该缺陷的**反证**:证明修复后的夹具既不依赖种子行的存在性/状态, + * 也不依赖测试执行顺序,并且在清理时只删除自己创建的数据。

+ * + *

本类自身不使用种子 ID,全部操作按精确 ID 限定,不做任何整表删除。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class FairnessMonitoringFixtureIsolationTest { + + /** 与 {@code FairnessMonitoringControllerTest} 相同的独占夹具 ID(同包可见)。 */ + private static final long ADMIN_USER_ID = FairnessMonitoringControllerTest.ADMIN_USER_ID; + + /** V1_1 种子账号占用的 ID;本类只读、只做快照与恢复,绝不把它们当作夹具。 */ + private static final List SEED_IDS = List.of(1L, 2L, 3L); + + /** 本类自有的探针 ID,不与其他测试共用。 */ + private static final long SENTINEL_USER_ID = 9_310_009L; + private static final long INACTIVE_USER_ID = 9_310_008L; + + private static final String DRIFT = "/admin/scoring/fairness/drift"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private FairnessMonitoringService service; + @MockBean private RedisTemplate redisTemplate; + + /** 种子行快照;非 null 时 {@link #restoreSeedRows()} 负责原样恢复。 */ + private List> seedSnapshot; + + @BeforeEach + void provision() { + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") + .build()); + } + + @AfterEach + void cleanup() { + restoreSeedRows(); + cleanupOwnRows(); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", SENTINEL_USER_ID); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", SENTINEL_USER_ID); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", SENTINEL_USER_ID); + } + + private void cleanupOwnRows() { + for (long id : new long[]{ADMIN_USER_ID, INACTIVE_USER_ID}) { + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); + } + } + + private void insertActiveUser(long id, String phone, String role) { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-isolation-" + role, role); + } + + private int isActive(long id) { + Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id); + return v == null ? -1 : v; + } + + private String bearer() { + return TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN"); + } + + // ── 1. 夹具 ID 不与被禁用的种子账号冲突 ──────────────────── + + @Test + @DisplayName("反证:夹具 ID 位于种子账号号段之外,且夹具用户确为 active") + void fixtureIdsAreOutsideSeedAccountRange() { + assertThat(ADMIN_USER_ID).as("夹具 ID 不得落在 V1_1 种子账号的 1/2/3 上") + .isNotIn(SEED_IDS); + assertThat(FairnessMonitoringControllerTest.STUDENT_USER_ID).isNotIn(SEED_IDS); + + assertThat(isActive(ADMIN_USER_ID)).as("夹具用户必须由本类显式插入为 active").isEqualTo(1); + + // 夹具手机号不得占用种子账号的手机号(唯一约束 + 语义隔离) + Integer squat = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone IN ('13800000001','13800000002','13800000003') " + + "AND id IN (?, ?)", Integer.class, ADMIN_USER_ID, + FairnessMonitoringControllerTest.STUDENT_USER_ID); + assertThat(squat).as("夹具不得占用种子账号手机号").isZero(); + } + + // ── 2. 种子行为 inactive(V43 状态)或缺失时,夹具仍可认证 ── + + @Test + @DisplayName("反证:种子行 inactive(V43 状态)或完全缺失时,Fairness 端点仍返回 200") + void seedRowsInactiveOrAbsent_stillAuthenticates() throws Exception { + snapshotAndTakeSeedRows(); + + // 阶段 A:种子行存在但 inactive(即 V43 执行后的真实状态,也是单跑时的状态) + jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id IN (1, 2, 3)"); + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + + // 阶段 B:种子行完全缺失(全量顺序下 AuthIntegrationTest.deleteAll() 之后的真实状态) + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (1, 2, 3)"); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id IN (1, 2, 3)"); + jdbcTemplate.update("DELETE FROM users WHERE id IN (1, 2, 3)"); + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + } + + // ── 3. 连续多次夹具装配与请求不冲突 ──────────────────────── + + @Test + @DisplayName("反证:连续三次装配 + 三次请求均成功,无唯一约束或残留冲突") + void repeatedProvisioningAndRequests_doNotConflict() throws Exception { + for (int round = 1; round <= 3; round++) { + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") + .build()); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())) + .andExpect(status().isOk()); + } + + Integer users = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID); + assertThat(users).as("重复装配不得产生重复用户行").isEqualTo(1); + } + + // ── 4. 夹具不触碰种子数据(顺序无关的根本保证)──────────── + + @Test + @DisplayName("反证:一次完整装配→请求→清理周期后,种子行逐字段不变") + void fixtureCycleLeavesSeedRowsUntouched() throws Exception { + List> before = seedRows(); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); + cleanupOwnRows(); + insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN"); + + assertThat(seedRows()).as("夹具周期不得改写任何种子行").isEqualTo(before); + } + + // ── 5. 清理只删除本类自有数据 ────────────────────────────── + + @Test + @DisplayName("反证:清理只删除本类夹具行,探针用户与会话保持不变") + void cleanupRemovesOnlyOwnFixtureRows() { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 1, 1)", SENTINEL_USER_ID, "a6fair-sentinel", + "a6-fairness-isolation-sentinel", "STUDENT"); + jdbcTemplate.update("INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked) " + + "VALUES (?, ?, 'REFRESH', ?, 0)", SENTINEL_USER_ID, "a6fair-sentinel-hash", + Timestamp.valueOf(java.time.LocalDateTime.now().plusDays(1))); + + cleanupOwnRows(); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID)) + .as("本类夹具行应已被清理").isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, SENTINEL_USER_ID)) + .as("探针用户不得被清理波及").isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Integer.class, SENTINEL_USER_ID)) + .as("探针会话不得被清理波及").isEqualTo(1); + } + + // ── 6. 其他用户与会话不受影响 ────────────────────────────── + + @Test + @DisplayName("反证:装配→请求→清理周期前后,非夹具用户与会话逐字段不变") + void unrelatedUsersAndSessionsUnchanged() throws Exception { + List> usersBefore = nonFixtureUsers(); + List> tokensBefore = nonFixtureTokens(); + + mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk()); + cleanupOwnRows(); + + assertThat(nonFixtureUsers()).as("非夹具用户必须逐字段不变").isEqualTo(usersBefore); + assertThat(nonFixtureTokens()).as("非夹具会话必须逐字段不变").isEqualTo(tokensBefore); + } + + // ── 7. 公共夹具不会静默复活 inactive 用户 ───────────────── + + @Test + @DisplayName("反证:TestAuthTokens 不得把已存在的 inactive 用户静默激活") + void publicTokenFixtureDoesNotSilentlyReviveInactiveUser() throws Exception { + jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, ?, ?, 0, 1)", INACTIVE_USER_ID, "a6fair-inactive", + "a6-fairness-isolation-inactive", "ADMIN"); + + String token = TestAuthTokens.bearer(INACTIVE_USER_ID, "ADMIN"); + + assertThat(isActive(INACTIVE_USER_ID)) + .as("公共夹具必须保留该用户被禁用的状态,不得静默复活") + .isEqualTo(0); + mockMvc.perform(get(DRIFT).header("Authorization", token)) + .andExpect(status().isUnauthorized()); + verify(service, never()).drift(any()); + } + + // ── 工具 ─────────────────────────────────────────────────── + + private List> seedRows() { + return jdbcTemplate.queryForList( + "SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified " + + "FROM users WHERE id IN (1, 2, 3) ORDER BY id"); + } + + private List> nonFixtureUsers() { + return jdbcTemplate.queryForList( + "SELECT id, phone, nickname, role, is_active FROM users WHERE id NOT IN (?, ?) ORDER BY id", + ADMIN_USER_ID, INACTIVE_USER_ID); + } + + private List> nonFixtureTokens() { + return jdbcTemplate.queryForList( + "SELECT id, user_id, token_type, revoked FROM auth_tokens " + + "WHERE user_id NOT IN (?, ?) ORDER BY id", + ADMIN_USER_ID, INACTIVE_USER_ID); + } + + /** 快照种子行(含主键与状态),供缺失场景后原样恢复。 */ + private void snapshotAndTakeSeedRows() { + seedSnapshot = jdbcTemplate.queryForList( + "SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified, " + + "created_at, updated_at FROM users WHERE id IN (1, 2, 3) ORDER BY id"); + } + + /** + * 按精确主键恢复种子行。 + * + *

只在测试**主动删除**过种子行时才执行({@code seedSnapshot} 非 null), + * 且严格还原全部字段,不新增、不批量重建、不影响其他行。

+ */ + private void restoreSeedRows() { + if (seedSnapshot == null) { + return; + } + List> snapshot = new ArrayList<>(seedSnapshot); + seedSnapshot = null; + for (Map row : snapshot) { + Long id = ((Number) row.get("id")).longValue(); + if (jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id) > 0) { + continue; + } + jdbcTemplate.update("INSERT INTO users (id, phone, email, nickname, password_hash, role, " + + "is_active, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, row.get("phone"), row.get("email"), row.get("nickname"), row.get("password_hash"), + row.get("role"), row.get("is_active"), row.get("is_verified"), + row.get("created_at"), row.get("updated_at")); + } + } +}