test(phase23): KEEP-VERIFY — remove 44 pass shells + fake INV tests, add shell guard
Phase 23 "可信系统测试重建 (REFACTOR)" re-audit. STATUS claimed "10 E2E
files / ~80+ cases" and "INV-011~020 ✅"; measurement showed the opposite.
D-23-A (Critical): V11InvariantsTest's 14 cases asserted only Constants
strings. INV-016 "Banner target types" actually asserted ARTICLE_STATUS_*;
INV-017 "home 8 entries" declared local arrays then asserted
firstRow.length == 4 (tautology, never called the home service);
INV-018/019/020 used appointment/article/account status constants as
stand-ins for permission and data-scope checks. Rewritten to 12 cases:
4 real service-behavior guards (INV-013 via real CounselingService.getDetail
— Matchmaker sees null userRequestSummary, with an assigned-Counselor
counter-proof so global sanitization cannot fake a pass; INV-016 reads the
production ALLOWED_TARGET_TYPES whitelist and asserts no external/http/url/
web entry; INV-017 calls real getHome and pins all 8 codes + order;
INV-019 asserts content_editor reads null), plus assertBehaviorTestExists
turning "行为验证见 XxxTest" pointers into executable Class.forName contracts.
D-23-B (High): 44 `pass` shells across 6 files in tests/e2e (4 files were
100% shells). Gate forbids skip/pass shells. Rewritten in place as honest
placeholders with the full scenario lists preserved.
D-23-C (High, residual): the pytest E2E suite is not executable at all —
no conftest.py anywhere, ~15 fixtures (api_client/verified_user/
banner_factory/...) do not exist. Only run_v11_tests.py really runs
(16 cases, needs a live stack + E2E_PASSWORD).
D-23-D (High, residual, NOT fixed): CI does not chain E2E as the plan
requires. Needs an infrastructure decision on how CI provisions a live
stack; not implemented rather than written unverified.
D-23-E (Medium): the CI migration job claimed to "验证 migration SQL 语法"
but only checked file non-emptiness, while the real syntax/baseline checks
live in MigrationValidationTest (already run by the backend job). Replaced
with a real sequence check (naming / non-empty / unique versions / no
gaps); verified by extracting the exact inline script and running it.
New gate: scripts/check-no-empty-tests.py (AST scan, blocks pass-only and
unconditionally skipped tests) + a CI test-shell-guard job. Verified both
ways: 0 findings on the real tree, 2 precise findings on injected shells.
Negative validation: 3 injected production defects (external_url in the
banner whitelist, swapped home entry order, isMatchmaker returning the full
record) turned exactly inv016/inv017/inv013 red, 9 others green, zero
unrelated failures; md5sum -c confirmed byte-identical restore.
Full regression: 655 tests / 652 passed / 0 failures / 0 errors / 3 skipped
(baseline 657; -2 because 14 vacuous cases were merged into 12). The 3
skips carry explicit reasons (2x @EnabledIfEnvironmentVariable(CI=true),
1x @Disabled "需要完整数据库连接") — none is a shell.
Phase 23 is PARTIALLY closed: fake coverage is gone, but the UAT gate
requires the end-to-end chain to actually execute, so "失败为零可进入 UAT"
is NOT claimed.
This commit is contained in:
+194
-51
@@ -1,29 +1,83 @@
|
||||
package com.danyang.match.platform.invariants;
|
||||
|
||||
import com.danyang.match.platform.banner.controller.HomeBannerOperatorController;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import com.danyang.match.platform.home.dto.HomeDTO;
|
||||
import com.danyang.match.platform.home.service.HomeApplicationService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.*;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* V1.1 不变量测试 — INV-011 ~ INV-020
|
||||
*
|
||||
* <p><b>Phase 23 KEEP-VERIFY 重写说明(原文件为假测试)</b></p>
|
||||
* 原文件 10 个测试全部只断言 {@code Constants} 里的字符串存在,不触碰任何服务行为,
|
||||
* 其中三处属于明确错误:
|
||||
* <ul>
|
||||
* <li>INV-016「Banner 目标类型」实际断言的是 {@code ARTICLE_STATUS_*}(文章状态常量),
|
||||
* 与 Banner {@code target_type} 毫无关系;</li>
|
||||
* <li>INV-017「首页 8 入口」声明两个局部数组后断言 {@code firstRow.length == 4},
|
||||
* 是对自己刚写的字面量做断言的重言式,从未调用首页服务;</li>
|
||||
* <li>INV-018/019/020 分别用预约状态、文章状态、账号状态常量充当「数据范围 / 零用户数据 /
|
||||
* 组织关系」的验证,同样不触及任何权限判定。</li>
|
||||
* </ul>
|
||||
* 计划 Phase 23 Gate 明文要求:<b>INV-001~020 必须测试真实服务行为,不能只断言常量字符串存在。</b>
|
||||
*
|
||||
* <p>现按「能在本文件内做真行为验证的就地做,已被其它真实测试类覆盖的给出可校验的指针」重写:
|
||||
* <ul>
|
||||
* <li><b>本文件内真实行为</b>:INV-013({@code CounselingService.getDetail} 脱敏与越权返回 null)、
|
||||
* INV-016(反射读取生产 {@code ALLOWED_TARGET_TYPES} 白名单,断言不含外链类型)、
|
||||
* INV-017(真实 {@code getHome(userId)} 的 8 核心入口与固定顺序);</li>
|
||||
* <li><b>指针 + 常量口径</b>:其余不变量的行为验证在各自真实测试类中,
|
||||
* 本文件用 {@link #assertBehaviorTestExists} 断言该测试类确实存在,防止指针腐烂。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class V11InvariantsTest {
|
||||
|
||||
@Autowired private HomeApplicationService homeApplicationService;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private CounselingService counselingService;
|
||||
|
||||
private static final String USER = "u-inv-v11";
|
||||
private static final String COUNSELOR = "op-inv-counselor";
|
||||
private static final String MATCHMAKER = "op-inv-matchmaker";
|
||||
private static final String CONTENT_EDITOR = "op-inv-content-editor";
|
||||
|
||||
// ==================== INV-011 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-011: 本地资格佐证访问需要特定权限")
|
||||
void testLocalEvidenceRequiresPermission() {
|
||||
assertEquals("local_eligibility", Constants.NOTIFICATION_TYPE_LOCAL_ELIGIBILITY);
|
||||
assertNotNull(Constants.LOCAL_ELIGIBILITY_TYPE_HUKOU);
|
||||
@DisplayName("常量口径 INV-011: 本地资格类型取值固定(行为验证见 LocalEligibilityServiceTest / LocalEligibilityStatusVocabularyGuardTest)")
|
||||
void inv011LocalEligibilityVocabulary() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.local_eligibility.LocalEligibilityServiceTest");
|
||||
assertBehaviorTestExists("com.danyang.match.platform.account.LocalEligibilityStatusVocabularyGuardTest");
|
||||
assertEquals("household_registration", Constants.LOCAL_ELIGIBILITY_TYPE_HUKOU);
|
||||
assertNotNull(Constants.LOCAL_ELIGIBILITY_TYPE_WORK);
|
||||
assertNotNull(Constants.LOCAL_ELIGIBILITY_TYPE_RESIDENCE);
|
||||
}
|
||||
|
||||
// ==================== INV-012 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: Counselor咨询状态机验证")
|
||||
void testCounselorStatusMachine() {
|
||||
@DisplayName("常量口径 INV-012: 咨询服务状态机取值固定(行为验证见 CounselingPhase13ATest 的 4 个 INV-012 用例)")
|
||||
void inv012CounselingStatusMachine() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.counseling.CounselingPhase13ATest");
|
||||
assertEquals("pending", Constants.COUNSELING_STATUS_PENDING);
|
||||
assertEquals("assigned", Constants.COUNSELING_STATUS_ASSIGNED);
|
||||
assertEquals("scheduled", Constants.COUNSELING_STATUS_SCHEDULED);
|
||||
@@ -31,27 +85,56 @@ class V11InvariantsTest {
|
||||
assertEquals("completed", Constants.COUNSELING_STATUS_COMPLETED);
|
||||
assertEquals("cancelled", Constants.COUNSELING_STATUS_CANCELLED);
|
||||
assertEquals("closed", Constants.COUNSELING_STATUS_CLOSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 咨询类型枚举完整")
|
||||
void testCounselingTypeEnums() {
|
||||
assertEquals("relationship_guidance", Constants.COUNSELING_TYPE_RELATIONSHIP_GUIDANCE);
|
||||
assertEquals("emotional_support", Constants.COUNSELING_TYPE_EMOTIONAL_SUPPORT);
|
||||
assertEquals("premarital_guidance", Constants.COUNSELING_TYPE_PREMARITAL_GUIDANCE);
|
||||
assertEquals("anti_fraud_guidance", Constants.COUNSELING_TYPE_ANTI_FRAUD_GUIDANCE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 咨询服务业务边界")
|
||||
void testCounselingBusinessBoundary() {
|
||||
assertEquals("counseling", Constants.NOTIFICATION_TYPE_COUNSELING);
|
||||
assertEquals("premarital", Constants.NOTIFICATION_TYPE_PREMARITAL);
|
||||
}
|
||||
// ==================== INV-013(真实服务行为) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-014: 活动互选状态机验证")
|
||||
void testActivityMutualStateMachine() {
|
||||
@DisplayName("INV-013: Matchmaker 只读到脱敏版(无 user_request_summary);无关角色读到 null")
|
||||
void inv013MatchmakerCannotReadCounselingBody() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, Constants.COUNSELING_TYPE_RELATIONSHIP_GUIDANCE, "不愿被红娘看到的求助正文",
|
||||
"user_self", "online", "工作日晚间", null, null);
|
||||
assertNotNull(svc, "申请应成功");
|
||||
String serviceId = svc.getId();
|
||||
assertNotNull(serviceId);
|
||||
|
||||
// 申请者本人:完整视图(含正文)
|
||||
CounselingServiceRecord mine = counselingService.getDetail(USER, "user", serviceId);
|
||||
assertNotNull(mine, "申请者本人应能看到自己的服务");
|
||||
assertEquals("不愿被红娘看到的求助正文", mine.getUserRequestSummary(),
|
||||
"申请者本人应能读到自己填写的求助正文");
|
||||
|
||||
// INV-013 核心:Matchmaker / 普通 operator 只能看脱敏版
|
||||
CounselingServiceRecord forMatchmaker =
|
||||
counselingService.getDetail(MATCHMAKER, "matchmaker", serviceId);
|
||||
assertNotNull(forMatchmaker, "Matchmaker 可见服务条目本身(脱敏版)");
|
||||
assertNull(forMatchmaker.getUserRequestSummary(),
|
||||
"INV-013: Matchmaker 不得读取咨询正文 user_request_summary");
|
||||
|
||||
// 兜底:与咨询无关的角色(内容编辑员)一律无权限
|
||||
assertNull(counselingService.getDetail(CONTENT_EDITOR, "content_editor", serviceId),
|
||||
"INV-013: 与咨询无关的角色不得读取咨询服务详情");
|
||||
|
||||
// assigned Counselor 属完整视图,用于确认上面的脱敏不是「全局脱敏」造成的假阳性
|
||||
counselingService.assign(serviceId, COUNSELOR);
|
||||
CounselingServiceRecord forAssigned =
|
||||
counselingService.getDetail(COUNSELOR, "counselor", serviceId);
|
||||
assertNotNull(forAssigned);
|
||||
assertEquals("不愿被红娘看到的求助正文", forAssigned.getUserRequestSummary(),
|
||||
"assigned Counselor 应能读到正文,用于反证 Matchmaker 的脱敏是真实生效的");
|
||||
}
|
||||
|
||||
// ==================== INV-014 / INV-015 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("常量口径 INV-014/015: 活动互选状态取值固定(行为验证见 ActivityMutualConsentServiceTest 的双确认 Gate 用例)")
|
||||
void inv014015MutualConsentVocabulary() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.activity_mutual.ActivityMutualConsentServiceTest");
|
||||
assertEquals("pending", Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
assertEquals("accepted", Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
assertEquals("declined", Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||
@@ -59,62 +142,108 @@ class V11InvariantsTest {
|
||||
assertEquals("mutual", Constants.ACTIVITY_INTEREST_STATUS_MUTUAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-014: 活动意向不自动创建Match")
|
||||
void testActivityInterestNoAutoMatch() {
|
||||
assertEquals("submitted", Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
assertEquals("in_service", Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
}
|
||||
// ==================== INV-016(真实生产数据校验) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-015: 双方确认后才有沟通权限")
|
||||
void testBothConfirmBeforeChat() {
|
||||
assertEquals("pending", Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
assertEquals("accepted", Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
@DisplayName("INV-016: Banner 跳转类型白名单不含任何外链类型(读取生产 ALLOWED_TARGET_TYPES)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void inv016BannerTargetTypeWhitelistExcludesExternal() throws Exception {
|
||||
Field f = HomeBannerOperatorController.class.getDeclaredField("ALLOWED_TARGET_TYPES");
|
||||
f.setAccessible(true);
|
||||
Set<String> allowed = (Set<String>) f.get(null);
|
||||
|
||||
assertNotNull(allowed, "ALLOWED_TARGET_TYPES 应存在");
|
||||
// AGENTS.md 规则 16:target_type 仅限以下站内类型
|
||||
assertTrue(allowed.containsAll(List.of(
|
||||
"activity", "matchmaker", "counseling", "premarital",
|
||||
"service_location", "article", "internal_page", "none")),
|
||||
"Banner 白名单应包含全部 8 个站内跳转类型,实际为: " + allowed);
|
||||
// 禁外链:任何形如 external / http / url / web 的类型都不得出现
|
||||
List<String> externalish = allowed.stream()
|
||||
.filter(t -> t.contains("external") || t.contains("http")
|
||||
|| t.contains("url") || t.contains("web"))
|
||||
.toList();
|
||||
assertTrue(externalish.isEmpty(),
|
||||
"INV-016: Banner 白名单不得包含外链类型,发现: " + externalish);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-016: Banner目标类型验证")
|
||||
void testBannerTargetTypes() {
|
||||
assertEquals("published", Constants.ARTICLE_STATUS_PUBLISHED);
|
||||
assertEquals("draft", Constants.ARTICLE_STATUS_DRAFT);
|
||||
}
|
||||
// ==================== INV-017(真实服务行为) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-017: 首页8核心入口编码固定")
|
||||
void testQuickEntryCodesFixed() {
|
||||
String[] firstRow = {"find_object", "matchmaker", "activities", "counseling"};
|
||||
String[] secondRow = {"classroom", "premarital", "anti_fraud", "service_station"};
|
||||
assertEquals(4, firstRow.length);
|
||||
assertEquals(4, secondRow.length);
|
||||
assertEquals(8, firstRow.length + secondRow.length);
|
||||
@DisplayName("INV-017: 首页 8 核心入口固定且顺序不可更改(真实 getHome)")
|
||||
void inv017QuickEntriesFixedOrder() {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("inv017_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
HomeDTO dto = homeApplicationService.getHome(u.getId());
|
||||
assertNotNull(dto, "首页聚合响应不应为 null");
|
||||
List<HomeDTO.QuickEntry> entries = dto.getQuickEntries();
|
||||
assertNotNull(entries, "quickEntries 不应为 null");
|
||||
|
||||
List<String> codes = entries.stream().map(HomeDTO.QuickEntry::getCode).toList();
|
||||
// AGENTS.md 规则 15:第一排 找对象/公益红娘/公益活动/婚恋咨询;第二排 婚恋课堂/婚前辅导/婚恋反诈/善缘服务站
|
||||
assertEquals(
|
||||
List.of("find_object", "matchmaker", "activities", "counseling",
|
||||
"classroom", "premarital", "anti_fraud", "service_station"),
|
||||
codes,
|
||||
"INV-017: 首页 8 核心入口的编码与顺序必须完全固定(实得 " + codes + ")");
|
||||
assertEquals(8, codes.size(), "INV-017: 核心入口必须恰好 8 个");
|
||||
}
|
||||
|
||||
// ==================== INV-018 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-018: 服务站运营人员数据范围")
|
||||
void testStationOperatorDataScope() {
|
||||
@DisplayName("常量口径 INV-018: 服务预约状态取值固定(行为验证见 ServiceAppointmentDataScopeTest / ServiceAppointmentOwnerGuardTest)")
|
||||
void inv018AppointmentStatusVocabulary() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.service_appointment.ServiceAppointmentDataScopeTest");
|
||||
assertBehaviorTestExists("com.danyang.match.platform.service_appointment.ServiceAppointmentOwnerGuardTest");
|
||||
assertEquals("submitted", Constants.APPOINTMENT_STATUS_SUBMITTED);
|
||||
assertEquals("confirmed", Constants.APPOINTMENT_STATUS_CONFIRMED);
|
||||
assertEquals("completed", Constants.APPOINTMENT_STATUS_COMPLETED);
|
||||
assertEquals("cancelled", Constants.APPOINTMENT_STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
// ==================== INV-019 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-019: 内容编辑员零用户数据权限")
|
||||
void testContentEditorZeroUserAccess() {
|
||||
@DisplayName("常量口径 INV-019: 内容状态取值固定(行为验证见 V11PermissionMatrixContractTest 的角色基线用例)")
|
||||
void inv019ContentStatusVocabulary() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.security.V11PermissionMatrixContractTest");
|
||||
assertEquals("draft", Constants.ARTICLE_STATUS_DRAFT);
|
||||
assertEquals("published", Constants.ARTICLE_STATUS_PUBLISHED);
|
||||
assertEquals("offline", Constants.ARTICLE_STATUS_OFFLINE);
|
||||
assertEquals("scheduled", Constants.ARTICLE_STATUS_SCHEDULED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-020: 组织关系不自动授予数据权限")
|
||||
void testOrgRelationshipNoAutoPermission() {
|
||||
@DisplayName("INV-019: 内容编辑员角色不得持有任何用户婚恋数据权限(真实角色-权限基线)")
|
||||
void inv019ContentEditorHasNoUserDataPermission() {
|
||||
// 与 INV-013 相互印证:content_editor 既读不到咨询详情,也不在咨询可见角色集合内
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, Constants.COUNSELING_TYPE_EMOTIONAL_SUPPORT, "内容编辑员不应看到的正文",
|
||||
"user_self", "online", "工作日晚间", null, null);
|
||||
assertNull(counselingService.getDetail(CONTENT_EDITOR, "content_editor", svc.getId()),
|
||||
"INV-019: 内容编辑员不得读取用户婚恋咨询数据");
|
||||
}
|
||||
|
||||
// ==================== INV-020 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("常量口径 INV-020: 账号状态取值固定(行为验证见 AuthServiceAccountClosedGuardTest / V11MethodSecurityMatrixTest)")
|
||||
void inv020AccountStatusVocabulary() {
|
||||
assertBehaviorTestExists("com.danyang.match.platform.auth.AuthServiceAccountClosedGuardTest");
|
||||
assertBehaviorTestExists("com.danyang.match.platform.security.V11MethodSecurityMatrixTest");
|
||||
assertEquals("normal", Constants.ACCOUNT_STATUS_NORMAL);
|
||||
assertEquals("banned", Constants.ACCOUNT_STATUS_BANNED);
|
||||
assertEquals("suspended", Constants.ACCOUNT_STATUS_SUSPENDED);
|
||||
}
|
||||
|
||||
// ==================== 常量完整性(回归保护) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V1.1常量完整性: 所有新增枚举值存在且非空")
|
||||
void testV11ConstantsExist() {
|
||||
@@ -160,6 +289,20 @@ class V11InvariantsTest {
|
||||
Constants.COUNSELING_STATUS_CLOSED
|
||||
};
|
||||
assertEquals(7, statuses.length);
|
||||
assertEquals(7, (long) Arrays.stream(statuses).distinct().count());
|
||||
assertEquals(7, java.util.Arrays.stream(statuses).distinct().count(),
|
||||
"咨询状态常量不得出现重复值(手工维护易复制粘贴出错)");
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
/**
|
||||
* 断言「行为验证见 XxxTest」指向的测试类真实存在。
|
||||
*
|
||||
* <p>本文件曾用常量断言冒充不变量验证,且未指明真正的验证位置。
|
||||
* 这里把指针变成可执行的契约:一旦被引用的测试类被重命名或删除,本用例立即失败。
|
||||
*/
|
||||
private void assertBehaviorTestExists(String fqcn) {
|
||||
assertDoesNotThrow(() -> Class.forName(fqcn),
|
||||
"被引用的行为验证测试类必须存在: " + fqcn);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user