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:
WorkBuddy
2026-09-18 11:49:18 +08:00
parent 050d005c0a
commit ae1bd2b734
12 changed files with 798 additions and 415 deletions
@@ -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);
}
}