diff --git a/backend/src/test/java/com/danyang/match/platform/invariants/Inv011EvidenceAccessGuardTest.java b/backend/src/test/java/com/danyang/match/platform/invariants/Inv011EvidenceAccessGuardTest.java new file mode 100644 index 0000000..680175a --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/invariants/Inv011EvidenceAccessGuardTest.java @@ -0,0 +1,388 @@ +package com.danyang.match.platform.invariants; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.SensitiveAccessLog; +import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper; +import com.danyang.match.platform.local_eligibility.controller.LocalEligibilityController; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.review.controller.ReviewController; +import com.danyang.match.platform.review.entity.ReviewTask; +import com.danyang.match.platform.review.mapper.ReviewTaskMapper; +import com.danyang.match.platform.review.service.ReviewService; +import com.danyang.match.platform.shared.constant.Constants; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * INV-011 显式行为守卫 —— 本地资格佐证「不可越权读取 + 访问必留痕」。 + * + *

为什么需要本文件

+ * INV-011 此前只有常量口径 + 指针(见 {@link V11InvariantsTest#inv011LocalEligibilityVocabulary}): + * 指向的两个测试类都不验证 INV-011 语义本身 —— + * {@code LocalEligibilityServiceTest} 测的是用户侧声明/上传流程, + * {@code LocalEligibilityStatusVocabularyGuardTest} 测的是状态词表。 + * 于是「非授权角色能否读到他人本地资格佐证」「读取是否真的留痕」这两个 INV-011 的核心命题 + * 在测试上完全悬空,与本仓库反复出现的「名不副实的测试」同族。 + * + *

两条独立命题,分别咬住不同的落点

+ *
    + *
  1. 不可越权读取:佐证没有独立的服务端读取入口。唯一入口是 + * {@code POST /operator/reviews/{taskId}/evidences/{evidenceId}/access}, + * 它有两级权限注解(类级 {@code review.view} + 方法级 {@code local_eligibility.evidence.view}), + * 业务层再由 {@code ReviewService.accessLocalEligibilityEvidence} 复核四件事: + * {@code purpose} 非空 → 任务类型为 {@code local_eligibility} → 领取人本人 → + * 佐证归属该任务用户的资格记录。权限与归属都不允许「换个 taskId 就读到别人的佐证」。
  2. + *
  3. 访问必留痕:成功与拒绝都写 {@code sensitive_access_logs} + * ({@code access_result = success | denied})。本文件的断言一律回查真实落库, + * 不复用 {@code ReviewServicePhase05Test} 那种 {@code verify(mapper).insert(...)} 的 mock 断言 —— + * mock 只能证明「调用了某个方法」,证明不了「审计真的进了库、字段真的对」。
  4. + *
+ * + *

与本文件同时补齐的一处测试基建缺口

+ * {@code sensitive_access_logs} 此前在 H2 测试库完全不存在(见 {@code schema-h2.sql}), + * 而该表是上面第 2 条命题的唯一落点 —— 表缺失直接导致这条命题无法被断言。 + * 已按 V001 + V034 + V035 补齐,列定义与生产对齐。 + * + *

本文件如实固化而非粉饰的两处边界

+ * {@code purpose} 为空、{@code taskId} 不存在这两条早退路径不写审计。它们在业务上早于 + * 「触及具体用户的数据」发生(前者连参数都不合法,后者根本定位不到用户), + * 因此不构成「读了却没留痕」;本文件把它们写成显式断言,是为了让这个边界成为被声明的契约, + * 而不是后人眼中的漏测。若后续产品要求「任何调用都留痕」,这两条断言会立刻变红,成为改动提示。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class Inv011EvidenceAccessGuardTest { + + @MockBean + private StringRedisTemplate stringRedisTemplate; + + @Autowired private ReviewService reviewService; + @Autowired private ReviewTaskMapper reviewTaskMapper; + @Autowired private LocalEligibilityMapper localEligibilityMapper; + @Autowired private LocalEligibilityEvidenceMapper evidenceMapper; + @Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper; + + /** 审核任务的领取人:唯一有权读取本任务佐证的运营。 */ + private static final String CLAIM_OWNER = "op-inv011-claim-owner"; + /** 另一名运营。即便他同样持有全部相关权限,也不得读取「非自己领取」任务的佐证。 */ + private static final String OTHER_OPERATOR = "op-inv011-other"; + private static final String USER_A = "u-inv011-a"; + private static final String USER_B = "u-inv011-b"; + + // ========================================================================== + // 负向:越权读取一律拒绝,且必须留下 denied 审计 + // ========================================================================== + + @Test + @DisplayName("INV-011: 非领取人读取佐证 → 拒绝,且落库一条 denied 审计") + void inv011NonClaimantDeniedAndAudited() { + String eligibilityId = newEligibility(USER_A); + String evidenceId = newEvidence(eligibilityId); + String taskId = newTask(USER_A, Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY, CLAIM_OWNER); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> reviewService.accessLocalEligibilityEvidence( + taskId, evidenceId, OTHER_OPERATOR, "例行核验"), + "非领取人读取他人佐证必须被拒绝"); + + assertTrue(error.getMessage().contains("领取"), + "拒绝理由应指向「领取人」约束,实得: " + error.getMessage()); + + SensitiveAccessLog audit = singleAudit(evidenceId); + assertEquals("denied", audit.getAccessResult(), "拒绝访问也必须留痕"); + assertEquals(OTHER_OPERATOR, audit.getOperatorId(), "审计必须记录实际发起访问的运营"); + assertEquals(USER_A, audit.getUserId(), "审计必须记录数据归属的用户"); + assertEquals(Constants.SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE, audit.getDataType()); + } + + @Test + @DisplayName("INV-011: 领取人用自己的 taskId 读他人佐证 → 拒绝,且落库 denied") + void inv011CrossUserEvidenceDeniedAndAudited() { + // 同为「领取人本人」的合法身份,但佐证属于另一个用户 —— 归属校验必须拦住 + String eligibilityA = newEligibility(USER_A); + newEvidence(eligibilityA); + String eligibilityB = newEligibility(USER_B); + String evidenceBOfUserB = newEvidence(eligibilityB); + + String taskOfUserA = newTask(USER_A, Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY, CLAIM_OWNER); + + assertThrows(RuntimeException.class, + () -> reviewService.accessLocalEligibilityEvidence( + taskOfUserA, evidenceBOfUserB, CLAIM_OWNER, "例行核验"), + "领取人只能读本任务用户的佐证,不得跨用户读取"); + + SensitiveAccessLog audit = singleAudit(evidenceBOfUserB); + assertEquals("denied", audit.getAccessResult()); + assertEquals(CLAIM_OWNER, audit.getOperatorId()); + } + + @Test + @DisplayName("INV-011: 非 local_eligibility 类型的审核任务 → 拒绝,且落库 denied") + void inv011NonLocalEligibilityTaskDeniedAndAudited() { + String eligibilityId = newEligibility(USER_A); + String evidenceId = newEvidence(eligibilityId); + // 该任务是「资料审核」而非「本地资格审核」,同一套读取入口不得被复用去读佐证 + String profileTaskId = newTask(USER_A, "profile", CLAIM_OWNER); + + assertThrows(RuntimeException.class, + () -> reviewService.accessLocalEligibilityEvidence( + profileTaskId, evidenceId, CLAIM_OWNER, "例行核验"), + "佐证读取入口只服务于 local_eligibility 任务"); + + SensitiveAccessLog audit = singleAudit(evidenceId); + assertEquals("denied", audit.getAccessResult()); + assertEquals(USER_A, audit.getUserId(), "即便任务类型不符,审计也应挂到任务所属用户"); + } + + // ========================================================================== + // 正向反证:授权路径必须真的能读到 —— 否则上面三条「拒绝」可能只是「全局拒绝」 + // ========================================================================== + + @Test + @DisplayName("INV-011: 领取人读取本任务佐证 → 放行,且落库 success(反证非全局拒绝)") + void inv011ClaimOwnerSucceedsAndAudited() { + String eligibilityId = newEligibility(USER_A); + String evidenceId = newEvidence(eligibilityId); + String taskId = newTask(USER_A, Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY, CLAIM_OWNER); + + Map result = reviewService.accessLocalEligibilityEvidence( + taskId, evidenceId, CLAIM_OWNER, "核验居住证明"); + + assertNotNull(result, "领取人应能读到佐证"); + assertNotNull(result.get("url"), "应返回可用的签名地址,实得: " + result); + + SensitiveAccessLog audit = singleAudit(evidenceId); + assertEquals("success", audit.getAccessResult(), "成功访问同样必须留痕"); + assertEquals(CLAIM_OWNER, audit.getOperatorId()); + assertEquals(USER_A, audit.getUserId()); + assertEquals("核验居住证明", audit.getReason(), "审计必须留存访问目的"); + } + + // ========================================================================== + // 边界:两条早退路径不写审计 —— 作为「被声明的契约」固化,而非漏测 + // ========================================================================== + + @Test + @DisplayName("INV-011: purpose 为空 → 拒绝,且不产生审计(早退于触及任何用户数据之前)") + void inv011BlankPurposeRejectedWithoutAudit() { + String eligibilityId = newEligibility(USER_A); + String evidenceId = newEvidence(eligibilityId); + String taskId = newTask(USER_A, Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY, CLAIM_OWNER); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> reviewService.accessLocalEligibilityEvidence(taskId, evidenceId, CLAIM_OWNER, " "), + "查看佐证必须填写访问目的"); + + assertTrue(error.getMessage().contains("目的"), "实得: " + error.getMessage()); + assertEquals(0, auditsOf(evidenceId).size(), "参数非法即早退,未触及用户数据,故不留痕"); + } + + @Test + @DisplayName("INV-011: taskId 不存在 → 拒绝,且不产生审计(定位不到用户,无痕可言)") + void inv011UnknownTaskRejectedWithoutAudit() { + String eligibilityId = newEligibility(USER_A); + String evidenceId = newEvidence(eligibilityId); + String ghostTaskId = UUID.randomUUID().toString(); + + assertThrows(RuntimeException.class, + () -> reviewService.accessLocalEligibilityEvidence( + ghostTaskId, evidenceId, CLAIM_OWNER, "例行核验"), + "不存在的审核任务必须被拒绝"); + + assertEquals(0, auditsOf(evidenceId).size(), + "任务都不存在时无法定位数据归属,因此不写审计(边界契约,非漏测)"); + } + + // ========================================================================== + // 结构性守卫:INV-011 的「不越权」不只靠上面那几个 if,更靠入口形态本身 + // ========================================================================== + + @Test + @DisplayName("INV-011: 用户侧端点不接受任何用户可控的 userId 入参(身份只能来自 token)") + void inv011UserSideEndpointsTakeNoUserControllableUserId() { + Method[] handlers = declaredHandlerMethods(LocalEligibilityController.class); + assertTrue(handlers.length >= 4, + "本地资格用户侧端点应至少 4 个,实得 " + handlers.length + " 个 —— 端点被删改时本守卫应同步复核"); + + for (Method handler : handlers) { + for (Parameter parameter : handler.getParameters()) { + // 只要出现「非 principal 来源」的 userId 入参,用户就能自助读取他人资格 + assertNotEquals("userId", parameter.getName(), + handler.getName() + " 暴露了用户可控的 userId 入参 —— " + + "本地资格一律以 token 主体自限定,不得由请求指定用户"); + } + assertTrue(Arrays.stream(handler.getParameters()) + .anyMatch(p -> p.isAnnotationPresent(AuthenticationPrincipal.class)), + handler.getName() + " 必须从 @AuthenticationPrincipal 取用户身份," + + "否则无法保证「只能读自己」"); + } + } + + @Test + @DisplayName("INV-011: 佐证读取入口同时受类级与专用权限双重约束(缺一即失效)") + void inv011EvidenceReadEndpointRequiresDedicatedAuthority() { + PreAuthorize classLevel = ReviewController.class.getAnnotation(PreAuthorize.class); + assertNotNull(classLevel, "ReviewController 必须有类级权限约束"); + assertTrue(classLevel.value().contains("review.view"), + "类级约束应要求 review.view,实得: " + classLevel.value()); + + Method accessEvidence = Arrays.stream(ReviewController.class.getDeclaredMethods()) + .filter(m -> m.isAnnotationPresent(PostMapping.class)) + .filter(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()) + .anyMatch(path -> path.contains("evidences") && path.contains("access"))) + .findFirst() + .orElseThrow(() -> new AssertionError( + "未找到佐证读取端点 —— 该端点若被改名/移除,INV-011 需重新评估")); + + PreAuthorize methodLevel = accessEvidence.getAnnotation(PreAuthorize.class); + assertNotNull(methodLevel, + "佐证读取端点必须单独声明专用权限,不能只靠类级 review.view"); + assertTrue(methodLevel.value().contains("local_eligibility.evidence.view"), + "佐证读取需要专用权限 local_eligibility.evidence.view,实得: " + methodLevel.value()); + + // 端点必须从 token 主体取运营身份,不得由请求体指定 + assertTrue(Arrays.stream(accessEvidence.getParameters()) + .anyMatch(p -> p.isAnnotationPresent(AuthenticationPrincipal.class)), + "佐证读取必须从 @AuthenticationPrincipal 取运营身份(审计与归属都依赖它)"); + assertFalse(Arrays.stream(accessEvidence.getParameters()) + .anyMatch(p -> p.getName().equals("operatorId")), + "佐证读取不得暴露可伪造的 operatorId 入参"); + + // purpose 由请求体携带 —— 它是审计的必要字段,端点必须读它 + assertTrue(Arrays.stream(accessEvidence.getParameters()) + .anyMatch(p -> p.isAnnotationPresent(RequestBody.class)), + "佐证读取必须通过请求体接收 purpose(审计留痕的前置条件)"); + } + + // ========================================================================== + // 夹具与辅助 + // ========================================================================== + + private String newEligibility(String userId) { + LocalEligibility eligibility = new LocalEligibility(); + // 主键列是 VARCHAR(36)(对齐生产 UUID):只能用裸 UUID,加前缀会溢出。 + eligibility.setId(UUID.randomUUID().toString()); + eligibility.setUserId(userId); + eligibility.setEligibilityType(Constants.LOCAL_ELIGIBILITY_TYPE_HUKOU); + eligibility.setVerificationStatus("pending_review"); + localEligibilityMapper.insert(eligibility); + return eligibility.getId(); + } + + private String newEvidence(String localEligibilityId) { + LocalEligibilityEvidence evidence = new LocalEligibilityEvidence(); + evidence.setId(UUID.randomUUID().toString()); + evidence.setLocalEligibilityId(localEligibilityId); + // file_key 列是 VARCHAR(256),用真实的对象存储路径形态(fileId 字段映射到 file_key)。 + evidence.setFileId("user-photos/inv011/" + UUID.randomUUID() + ".jpg"); + evidence.setEvidenceType("household_proof"); + evidence.setStatus("submitted"); + evidenceMapper.insert(evidence); + return evidence.getId(); + } + + private String newTask(String userId, String reviewType, String assignedOperatorId) { + ReviewTask task = new ReviewTask(); + task.setId(UUID.randomUUID().toString()); + task.setUserId(userId); + task.setReviewType(reviewType); + task.setStatus("processing"); + task.setAssignedOperatorId(assignedOperatorId); + reviewTaskMapper.insert(task); + return task.getId(); + } + + /** + * 按 access_target 过滤本用例的审计 —— 每次 {@link #newEvidence} 都生成唯一 ID, + * 因此断言「恰好一条」不受其它用例/其它测试类的写入干扰。 + */ + private List auditsOf(String evidenceId) { + return sensitiveAccessLogMapper.selectList( + new LambdaQueryWrapper() + .eq(SensitiveAccessLog::getAccessTarget, evidenceId)); + } + + private SensitiveAccessLog singleAudit(String evidenceId) { + List logs = auditsOf(evidenceId); + assertEquals(1, logs.size(), + "本次访问应恰好落库 1 条审计(实得 " + logs.size() + " 条)" + + " —— 0 条说明审计缺失,多条说明重复记账"); + return logs.get(0); + } + + /** 取出该控制器声明的全部 HTTP 端点方法(含各类 Mapping 注解)。 */ + private static Method[] declaredHandlerMethods(Class controller) { + List handlers = new ArrayList<>(); + for (Method method : controller.getDeclaredMethods()) { + if (method.getAnnotation(RequestMapping.class) != null + || method.getAnnotation(GetMapping.class) != null + || method.getAnnotation(PostMapping.class) != null + || method.getAnnotation(PutMapping.class) != null + || method.getAnnotation(DeleteMapping.class) != null + || method.getAnnotation(PatchMapping.class) != null) { + handlers.add(method); + } + } + if (handlers.isEmpty()) { + throw new AssertionError(controller.getSimpleName() + + " 未解析到任何端点 —— 注解改名时本守卫需同步更新,不得静默通过"); + } + return handlers.toArray(new Method[0]); + } + + /** 保留:便于后续核对端点是否被整体删除时按 HTTP 方法枚举。 */ + @SuppressWarnings("unused") + private static RequestMethod[] allMethods() { + return RequestMethod.values(); + } + + @SuppressWarnings("unused") + private static String annotationName(Annotation annotation) { + return annotation.annotationType().getSimpleName(); + } + + /** 参数名断言的可读性依赖编译期 -parameters;此处做一次前置校验,避免守卫静默失效。 */ + @Test + @DisplayName("INV-011 守卫自检: 测试编译保留参数名(否则上一个用例会静默失效)") + void inv011GuardSelfCheckParameterNamesAreReadable() { + Method[] handlers = declaredHandlerMethods(LocalEligibilityController.class); + boolean anyNamed = Arrays.stream(handlers) + .flatMap(m -> Arrays.stream(m.getParameters())) + .anyMatch(p -> p.getName().equals("principal") || p.getName().equals("req")); + assertTrue(anyNamed, + "反射读不到参数名(形如 arg0/arg1)时,「无 userId 入参」的断言会变成永远为真 —— " + + "需确认编译参数含 -parameters,否则该守卫形同虚设"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/invariants/V11InvariantsTest.java b/backend/src/test/java/com/danyang/match/platform/invariants/V11InvariantsTest.java index 4e488df..38e23d4 100644 --- a/backend/src/test/java/com/danyang/match/platform/invariants/V11InvariantsTest.java +++ b/backend/src/test/java/com/danyang/match/platform/invariants/V11InvariantsTest.java @@ -63,8 +63,11 @@ class V11InvariantsTest { // ==================== INV-011 ==================== @Test - @DisplayName("常量口径 INV-011: 本地资格类型取值固定(行为验证见 LocalEligibilityServiceTest / LocalEligibilityStatusVocabularyGuardTest)") + @DisplayName("常量口径 INV-011: 本地资格类型取值固定(行为验证见 Inv011EvidenceAccessGuardTest / LocalEligibilityServiceTest)") void inv011LocalEligibilityVocabulary() { + // INV-011 的语义核心(越权读取被拒 + 访问必留痕)由同包 Inv011EvidenceAccessGuardTest + // 以真实落库断言固化;此处只守卫取值口径,并防止行为守卫被改名/删除后无人察觉。 + assertBehaviorTestExists("com.danyang.match.platform.invariants.Inv011EvidenceAccessGuardTest"); assertBehaviorTestExists("com.danyang.match.platform.local_eligibility.LocalEligibilityServiceTest"); assertBehaviorTestExists("com.danyang.match.platform.account.LocalEligibilityStatusVocabularyGuardTest"); assertEquals("household_registration", Constants.LOCAL_ELIGIBILITY_TYPE_HUKOU); diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 1c4f431..88acfc9 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -255,6 +255,24 @@ CREATE TABLE local_eligibility_evidences ( created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE ); +-- V001 + V034 + V035: sensitive_access_logs 敏感数据访问审计 +-- INV-011 的「可核查」落点正是本表(access_result = success / denied)。 +-- 本表此前在测试库**完全不存在**,而 ReviewService.logEvidenceAccess 一定会写它 —— +-- 后果:任何「审计真的落库了吗」的断言都无从编写,INV-011 只能退化成常量断言; +-- 生产上新接入的审计路径在测试期零覆盖。与 Phase 12 复验记录的 +-- user_identities / review_tasks 缺失同族(表漂移盲区),此处补齐。 +-- 列定义对齐:V001 建表(id/operator_id/user_id/data_type/reason/ip/created_at) +-- + V034(access_target/access_result) +-- + V035(BaseEntity 的 updated_at / deleted_at)。 +-- 不建 FK:生产 REFERENCES operators(id) / users(id),而测试库无 operators 表; +-- 本库 review_tasks / local_eligibility_evidences 同样只保留列、不建 FK。 +CREATE TABLE sensitive_access_logs ( + id VARCHAR(36) PRIMARY KEY, operator_id VARCHAR(36), user_id VARCHAR(36), + data_type VARCHAR(64), reason VARCHAR(500), ip VARCHAR(64), + access_target VARCHAR(128), access_result VARCHAR(32), + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE +); + -- V019: home_banners 首页 Banner CREATE TABLE home_banners ( id VARCHAR(36) PRIMARY KEY, title VARCHAR(200), subtitle VARCHAR(300), image_file_id VARCHAR(36), diff --git a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md index b01bd54..0a581aa 100644 --- a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md +++ b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md @@ -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:`,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('', 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(.)` 遍历全部 + 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 守卫)均已完成并验证*