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:
+388
@@ -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 显式行为守卫 —— 本地资格佐证「不可越权读取 + 访问必留痕」。
|
||||
*
|
||||
* <h3>为什么需要本文件</h3>
|
||||
* INV-011 此前只有<b>常量口径 + 指针</b>(见 {@link V11InvariantsTest#inv011LocalEligibilityVocabulary}):
|
||||
* 指向的两个测试类都不验证 INV-011 语义本身 ——
|
||||
* {@code LocalEligibilityServiceTest} 测的是用户侧声明/上传流程,
|
||||
* {@code LocalEligibilityStatusVocabularyGuardTest} 测的是状态词表。
|
||||
* 于是「非授权角色能否读到他人本地资格佐证」「读取是否真的留痕」这两个 INV-011 的核心命题
|
||||
* <b>在测试上完全悬空</b>,与本仓库反复出现的「名不副实的测试」同族。
|
||||
*
|
||||
* <h3>两条独立命题,分别咬住不同的落点</h3>
|
||||
* <ol>
|
||||
* <li><b>不可越权读取</b>:佐证没有独立的服务端读取入口。唯一入口是
|
||||
* {@code POST /operator/reviews/{taskId}/evidences/{evidenceId}/access},
|
||||
* 它有两级权限注解(类级 {@code review.view} + 方法级 {@code local_eligibility.evidence.view}),
|
||||
* 业务层再由 {@code ReviewService.accessLocalEligibilityEvidence} 复核四件事:
|
||||
* {@code purpose} 非空 → 任务类型为 {@code local_eligibility} → 领取人本人 →
|
||||
* 佐证归属该任务用户的资格记录。权限与归属<b>都不允许</b>「换个 taskId 就读到别人的佐证」。</li>
|
||||
* <li><b>访问必留痕</b>:成功与拒绝都写 {@code sensitive_access_logs}
|
||||
* ({@code access_result = success | denied})。本文件的断言一律<b>回查真实落库</b>,
|
||||
* 不复用 {@code ReviewServicePhase05Test} 那种 {@code verify(mapper).insert(...)} 的 mock 断言 ——
|
||||
* mock 只能证明「调用了某个方法」,证明不了「审计真的进了库、字段真的对」。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>与本文件同时补齐的一处测试基建缺口</h3>
|
||||
* {@code sensitive_access_logs} 此前在 H2 测试库<b>完全不存在</b>(见 {@code schema-h2.sql}),
|
||||
* 而该表是上面第 2 条命题的唯一落点 —— 表缺失直接导致这条命题无法被断言。
|
||||
* 已按 V001 + V034 + V035 补齐,列定义与生产对齐。
|
||||
*
|
||||
* <h3>本文件<b>如实</b>固化而非粉饰的两处边界</h3>
|
||||
* {@code purpose} 为空、{@code taskId} 不存在这两条早退路径<b>不写审计</b>。它们在业务上早于
|
||||
* 「触及具体用户的数据」发生(前者连参数都不合法,后者根本定位不到用户),
|
||||
* 因此不构成「读了却没留痕」;本文件把它们写成显式断言,是为了让这个边界成为<b>被声明的契约</b>,
|
||||
* 而不是后人眼中的漏测。若后续产品要求「任何调用都留痕」,这两条断言会立刻变红,成为改动提示。
|
||||
*/
|
||||
@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<String, Object> 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<SensitiveAccessLog> auditsOf(String evidenceId) {
|
||||
return sensitiveAccessLogMapper.selectList(
|
||||
new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.eq(SensitiveAccessLog::getAccessTarget, evidenceId));
|
||||
}
|
||||
|
||||
private SensitiveAccessLog singleAudit(String evidenceId) {
|
||||
List<SensitiveAccessLog> logs = auditsOf(evidenceId);
|
||||
assertEquals(1, logs.size(),
|
||||
"本次访问应恰好落库 1 条审计(实得 " + logs.size() + " 条)"
|
||||
+ " —— 0 条说明审计缺失,多条说明重复记账");
|
||||
return logs.get(0);
|
||||
}
|
||||
|
||||
/** 取出该控制器声明的全部 HTTP 端点方法(含各类 Mapping 注解)。 */
|
||||
private static Method[] declaredHandlerMethods(Class<?> controller) {
|
||||
List<Method> 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,否则该守卫形同虚设");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user