test(matchmaker,referral): ZC-MM-003/004 纯护栏——B1 联系规则锁定 + 禁止代用户决定固化
依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-003(B1
平台内优先、授权例外)与 DEC-MATCHMAKER-004(红娘不得代替用户执行交友决策)。
本阶段生产代码零改动,交付物为护栏测试(MM-CONTACT-001~004、MM-ACTION-001~003
全部有自动化用例)。
MatchmakerContactGuardTest(4 用例,standalone 源码扫描,FieldPrivacyGuardTest 范式):
- MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 16 键白名单(逐键对账吻合),
禁词 {emergencycontact,phone,mobile,wechat,weixin,address,idnumber,id_card}——
锁定红娘侧零联系方式回显(紧急联系人信息纯入口 write-only,连红娘本人都无读回面);
含防清空自检(三关键键必须在场)
- MM-CONTACT-003:红娘/转介 Controller 无 contact/call/phone/channel/offline 端点;
全仓无 ContactAuthorization 类、无 matchmaker_contact_authorizations 迁移表——
锁定「无授权机制」;未来引入联系功能必须连同本守卫+治理同步更新
- MM-CONTACT-004:机制不存在 ⇒ 无授权到期场景(与 003 同源,未来引入同时变红)
- 004 佐证:matchmaker/referral 包 @Scheduled=0(无批处理代决策面)
MatchmakerActionGuardTest(6 用例,@SpringBootTest 行为锁定):
- MM-ACTION-001(3 例负向):operator token 调用户决策端点
(/matchmaker/recommendations/{id}/accept、/pass、/referrals/{id}/confirm)→ 403
且业务状态零变化(403 来源于 SecurityConfig hasRole("USER") 身份类型防线,
与 PermissionService mock 无关,变异验证锚定真实)
- MM-ACTION-002:operator token 开启会话端点 → 403
- MM-ACTION-003 must-confirm 语义锁定:红娘代建转介 → needsUserConfirmation=true/
userConfirmedAt=null/status=pending,后续 recommend/followup/complete 不改确认态
(「代填草稿、用户确认」灰色点永久锁定)
- MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → 平台内服务通知落库,
title/content 无手机号/微信模式,红娘 operatorId 不在通知收件面
负向注入自证:
- 注入 A:ReferralService 临时加 emergencyContactPhoneEnc 回显 → 守卫三重命中判红 → 还原
- 注入 B:SecurityConfig hasRole("USER")→authenticated() → 4/4 负向用例判红
(403→401/500)→ 还原复绿
验证:定向 42/42、全量 861/861(3 预存 skip)、UI 契约 24/24。
003 方案 A(纯测试锁定)决策记录与五项 LEGAL_VERIFY open questions 随 ZC-MM-007 登记;
ContactAuthorizationPolicy 桩 + matchmaker_contact_authorizations 表推迟至 ZC-MM-006
(仅当产品确认开放受控联系时立项)。
This commit is contained in:
+332
@@ -0,0 +1,332 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.auth.token.TokenBlacklistService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.referral.service.ReferralService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
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.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* ZC-MM-004(禁止代替用户决定复核)+ MM-CONTACT-001 行为锁定
|
||||
* ({@code @SpringBootTest} 走真实安全过滤器链,沿用 MatchmakerReferralPhase13Test 数据构造范式)。
|
||||
*
|
||||
* <p>锁定四条行为不变量(架构师设计 T02):
|
||||
* <ol>
|
||||
* <li><b>MM-ACTION-001 强化(负向)</b>:operator 主体 token 打用户专属决定端点
|
||||
* (推荐 accept / pass、转介 confirm)→ 认证层 403
|
||||
* (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
|
||||
* SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
|
||||
* 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。</li>
|
||||
* <li><b>MM-ACTION-002</b>:会话开启端点(POST /conversations/from-match/{matchId})同为
|
||||
* UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
|
||||
* 无 operator 触达面)。</li>
|
||||
* <li><b>MM-ACTION-003 / must-confirm 语义</b>:红娘创建转介 → 响应
|
||||
* needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
|
||||
* 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
|
||||
* 「转介代建 = 代填草稿、用户确认」语义被永久锁定。</li>
|
||||
* <li><b>MM-CONTACT-001(正向)</b>:红娘对本人分配对象创建推荐/转介 → notifications 表
|
||||
* 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
|
||||
* 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
|
||||
* 红娘与用户之间不存在直连联系方式交换。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
@AutoConfigureMockMvc
|
||||
class MatchmakerActionGuardTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
@Autowired private JwtTokenService jwtTokenService;
|
||||
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
|
||||
@MockBean private TokenBlacklistService tokenBlacklistService;
|
||||
/**
|
||||
* 测试 H2 库无 permissions/operator_roles 表:JwtAuthFilter 对 operator token 会调
|
||||
* PermissionService.getPermissions → 打桩为空权限(测试只断言 ROLE_OPERATOR 与
|
||||
* hasRole("USER") 的身份类型 403,不依赖具体权限装载)。
|
||||
*/
|
||||
@MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService;
|
||||
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper;
|
||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||
@Autowired private NotificationMapper notificationMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
|
||||
@Autowired private UserIdentityMapper userIdentityMapper;
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private ReferralService referralService;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "op-ag-owner";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
private String serviceId;
|
||||
private String operatorToken;
|
||||
|
||||
/** 手机号模式(中国大陆 11 位) */
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("1[3-9]\\d{9}");
|
||||
/** 微信号/微信引导词模式 */
|
||||
private static final List<String> WECHAT_TOKENS = List.of("wechat", "weixin", "微信", "vx", "加v", "v信");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
user = createUser("ag-user");
|
||||
target = createUser("ag-target");
|
||||
// 一条归属 MATCHMAKER_OWNER 的红娘服务(assigned),并补生效分配(E1 纯判定器口径)
|
||||
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
MatchmakerAssignment assignment = new MatchmakerAssignment();
|
||||
assignment.setUserId(user);
|
||||
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
|
||||
assignment.setServiceId(serviceId);
|
||||
assignment.setAssignedBy(MATCHMAKER_OWNER);
|
||||
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||
assignment.setAssignmentReason("003/004 守卫前置分配");
|
||||
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||
assignment.setAssignedAt(Instant.now());
|
||||
matchmakerAssignmentMapper.insert(assignment);
|
||||
|
||||
// operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR
|
||||
when(permissionService.getPermissions(anyString())).thenReturn(List.of());
|
||||
operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造(Phase13 范式) ====================
|
||||
|
||||
/** 基础资格满足的用户:正常账号 + 实名 + 资料通过(RelationService.like 路径可走) */
|
||||
private String createUser(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("ag_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
|
||||
profile.setUserId(u.getId());
|
||||
profile.setDisplayName(tag);
|
||||
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||
userProfileMapper.insert(profile);
|
||||
|
||||
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
|
||||
identity.setUserId(u.getId());
|
||||
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
userIdentityMapper.insert(identity);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createService(String userId, String operatorId, String status) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setStatus(status);
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
matchmakerServiceMapper.insert(svc);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
private MatchmakerRecommendation insertRecommendation(String recUser, String recTarget) {
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setId(UUID.randomUUID().toString());
|
||||
rec.setServiceId(serviceId);
|
||||
rec.setUserId(recUser);
|
||||
rec.setTargetUserId(recTarget);
|
||||
rec.setOperatorId(MATCHMAKER_OWNER);
|
||||
rec.setReason("004 守卫测试推荐");
|
||||
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||
recommendationMapper.insert(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
private ActivityRegistration insertReferralRegistration(String regUser, String activityId) {
|
||||
ActivityRegistration reg = new ActivityRegistration();
|
||||
reg.setId(UUID.randomUUID().toString());
|
||||
reg.setActivityId(activityId);
|
||||
reg.setUserId(regUser);
|
||||
reg.setStatus(Constants.REGISTRATION_STATUS_PENDING);
|
||||
reg.setSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||
reg.setRequestSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||
reg.setCreatedByOperatorId(MATCHMAKER_OWNER);
|
||||
reg.setCreatedAt(Instant.now());
|
||||
activityRegistrationMapper.insert(reg);
|
||||
return reg;
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-001:红娘不能替用户做 Like/Pass/确认决定 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 accept → 403,推荐状态零变化")
|
||||
void operatorCannotAcceptRecommendationOnBehalfOfUser() throws Exception {
|
||||
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||
|
||||
mockMvc.perform(post("/matchmaker/recommendations/{id}/accept", rec.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||
"越权 accept 被认证层 403 拒绝后,推荐状态必须零变化");
|
||||
assertNull(after.getRespondedAt(), "越权 accept 不得写入 respondedAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 pass → 403,推荐状态零变化")
|
||||
void operatorCannotPassRecommendationOnBehalfOfUser() throws Exception {
|
||||
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||
|
||||
mockMvc.perform(post("/matchmaker/recommendations/{id}/pass", rec.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||
"越权 pass 被拒绝后,推荐状态必须零变化(仍 pending_view)");
|
||||
assertNull(after.getRespondedAt(), "越权 pass 不得写入 respondedAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打转介 confirm → 403,用户确认状态零变化")
|
||||
void operatorCannotConfirmReferralOnBehalfOfUser() throws Exception {
|
||||
ActivityRegistration reg = insertReferralRegistration(user, "act-ag-001");
|
||||
|
||||
mockMvc.perform(post("/referrals/{id}/confirm", reg.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken)
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"activity\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
ActivityRegistration after = activityRegistrationMapper.selectById(reg.getId());
|
||||
assertNull(after.getUserConfirmedAt(),
|
||||
"越权 confirm 被拒绝后,userConfirmedAt 必须仍为 null(用户决定权)");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||
"越权 confirm 后报名状态必须停留 pending");
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
|
||||
void operatorCannotOpenConversationFromMatch() throws Exception {
|
||||
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
|
||||
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-003: 转介代建=代填草稿(needsUserConfirmation=true、userConfirmedAt=null);红娘后续动作不改变确认状态")
|
||||
void referralCreationStaysPendingUntilUserConfirms() {
|
||||
// 红娘创建转介(本人分配对象)
|
||||
Map<String, Object> result = referralService.referToActivity(
|
||||
MATCHMAKER_OWNER, user, "act-ag-002", "紧急联系人", "enc-phone");
|
||||
|
||||
// must-confirm 语义:响应标记待确认,库中确认字段为空、状态停留 pending
|
||||
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
|
||||
"红娘创建转介必须标记 needsUserConfirmation=true(不替用户完成报名)");
|
||||
String regId = (String) result.get("registrationId");
|
||||
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
|
||||
assertNotNull(reg, "转介必须真实落库");
|
||||
assertNull(reg.getUserConfirmedAt(), "创建时 userConfirmedAt 必须为 null");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, reg.getStatus(),
|
||||
"创建时报名状态必须停留 pending");
|
||||
|
||||
// 红娘侧后续动作:recommend / followup / complete —— 均不得改变用户确认状态
|
||||
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "合适人选");
|
||||
matchmakerServiceService.addFollowup(serviceId, MATCHMAKER_OWNER, "note", "003 守卫跟进", null);
|
||||
|
||||
MatchmakerServiceRecord svc = matchmakerServiceMapper.selectById(serviceId);
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
matchmakerServiceMapper.updateById(svc);
|
||||
matchmakerServiceService.completeService(serviceId, MATCHMAKER_OWNER);
|
||||
|
||||
ActivityRegistration after = activityRegistrationMapper.selectById(regId);
|
||||
assertNull(after.getUserConfirmedAt(),
|
||||
"recommend/followup/complete 红娘侧动作不得代替用户确认(userConfirmedAt 必须仍为 null)");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||
"红娘侧动作不得推动报名状态(必须停留 pending 等待用户确认)");
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-001:平台内联系 = 通知中转,无联系方式交换 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-001(正向): 推荐/转介经平台通知触达用户,文案无手机号/微信号模式,收件面仅用户")
|
||||
void platformNotificationsCarryNoContactPatternsAndOnlyTargetUsers() {
|
||||
// 推荐路径:recommendPartner → sendAsync(type=matchmaker, businessType=matchmaker_recommendation)
|
||||
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "003 正向推荐");
|
||||
// 转介路径:referToServiceStation → sendMatchmakerReferralCreated(type=matchmaker, businessType=service_station)
|
||||
referralService.referToServiceStation(MATCHMAKER_OWNER, user, "loc-ag-001",
|
||||
"counselor_consultation", null, "备注");
|
||||
|
||||
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, user)
|
||||
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER));
|
||||
assertFalse(notes.isEmpty(), "推荐/转介必须为用户落平台内服务通知(type=matchmaker)");
|
||||
|
||||
for (Notification note : notes) {
|
||||
String title = note.getTitle() == null ? "" : note.getTitle();
|
||||
String content = note.getContent() == null ? "" : note.getContent();
|
||||
assertFalse(PHONE_PATTERN.matcher(title).find(),
|
||||
"通知标题不得含手机号模式: " + title);
|
||||
assertFalse(PHONE_PATTERN.matcher(content).find(),
|
||||
"通知正文不得含手机号模式: " + content);
|
||||
for (String token : WECHAT_TOKENS) {
|
||||
assertFalse(title.toLowerCase().contains(token),
|
||||
"通知标题不得含微信引导词「" + token + "」: " + title);
|
||||
assertFalse(content.toLowerCase().contains(token),
|
||||
"通知正文不得含微信引导词「" + token + "」: " + content);
|
||||
}
|
||||
}
|
||||
|
||||
// 负向佐证:通知收件面只有 C 端用户 —— 红娘 operatorId 不在 notification 收件面
|
||||
long operatorInbox = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, MATCHMAKER_OWNER));
|
||||
assertEquals(0L, operatorInbox,
|
||||
"红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-003(B1 联系规则护栏)+ ZC-MM-004 佐证 —— standalone 源码扫描守卫
|
||||
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
|
||||
*
|
||||
* <p>锁定四条不变量(架构师设计 T01):
|
||||
* <ol>
|
||||
* <li><b>MM-CONTACT-002</b>:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
|
||||
* 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
|
||||
* mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
|
||||
* emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
|
||||
* 连红娘本人都无读回面。</li>
|
||||
* <li><b>MM-CONTACT-003</b>:matchmaker / referral Controller 无 contact / call / phone /
|
||||
* channel / offline 类端点;全仓无 ContactAuthorization 类、无
|
||||
* matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
|
||||
* 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。</li>
|
||||
* <li><b>MM-CONTACT-004</b>:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
|
||||
* 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
|
||||
* 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。</li>
|
||||
* <li><b>ZC-MM-004 佐证</b>:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
|
||||
* 代操作面(禁止代替用户决定的自动化通道不存在)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class MatchmakerContactGuardTest {
|
||||
|
||||
private static final Path SRC_ROOT = resolveSourceRoot();
|
||||
private static final Path REFERRAL_SERVICE =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/service/ReferralService.java");
|
||||
private static final List<Path> CONTROLLER_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"));
|
||||
private static final List<Path> PACKAGE_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
|
||||
|
||||
/**
|
||||
* ReferralService 响应键白名单(ZC-MM-003 / T01:以实际代码逐键核对后核定):
|
||||
* <ul>
|
||||
* <li>referToServiceStation → appointmentId / status / referralType /
|
||||
* referralFromOperatorId / needsUserConfirmation</li>
|
||||
* <li>referToActivity → registrationId / activityId / status / referralType /
|
||||
* referralFromOperatorId / needsUserConfirmation</li>
|
||||
* <li>confirmReferral → id / status / confirmedAt</li>
|
||||
* <li>getReferralHistory → type / id / serviceType|activityId / status /
|
||||
* createdByOperatorId / userConfirmedAt / createdAt|registeredAt</li>
|
||||
* <li>getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
|
||||
* 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
|
||||
* 不落入 B1 禁放范畴)</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
|
||||
"appointmentId", "registrationId", "activityId", "id", "status", "type",
|
||||
"serviceType", "referralType", "referralFromOperatorId", "needsUserConfirmation",
|
||||
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
|
||||
"registeredAt", "userId");
|
||||
|
||||
/** 联系方式字样(出现在响应键名中即违规;emergencyContact* 仅允许作为入口 write-only 存在) */
|
||||
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
|
||||
"idnumber", "id_card");
|
||||
|
||||
/** MM-CONTACT-003:联系/触达类禁用端点词 */
|
||||
private static final List<String> FORBIDDEN_ENDPOINT_TOKENS = List.of(
|
||||
"contact", "call", "phone", "channel", "offline");
|
||||
|
||||
// ==================== MM-CONTACT-002:红娘侧零联系方式回显 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-002: ReferralService 全部响应 put 键 ⊆ 白名单,无任何联系方式回显")
|
||||
void referralResponseKeysAreWhitelistedWithNoContactEcho() throws IOException {
|
||||
String src = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
|
||||
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
|
||||
while (put.find()) {
|
||||
keys.add(put.group(1));
|
||||
}
|
||||
// 防止"装配被搬空后守卫永真":核心语义键必须仍在
|
||||
assertTrue(keys.contains("needsUserConfirmation"),
|
||||
"must-confirm 语义键必须存在(守卫非永真自检)");
|
||||
assertTrue(keys.contains("referralFromOperatorId"),
|
||||
"来源追溯键必须存在(守卫非永真自检)");
|
||||
assertTrue(keys.contains("status"), "状态键必须存在(守卫非永真自检)");
|
||||
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
|
||||
violations.add("非白名单响应键: " + key);
|
||||
}
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token)) {
|
||||
violations.add("响应键含联系方式 token: " + key + "(含 " + token + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"B1 联系规则契约被破坏(MM-CONTACT-002):响应键出现白名单外键或联系方式字样。\n"
|
||||
+ "实际输出键=" + keys + "\n违规项:\n - " + String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-003:无联系端点 / 无授权机制 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-003: matchmaker/referral Controller 无 contact 类端点;全仓无 ContactAuthorization 机制")
|
||||
void noContactEndpointsAndNoContactAuthorizationMechanism() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
|
||||
for (Path dir : CONTROLLER_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = Files.readString(f, StandardCharsets.UTF_8);
|
||||
Matcher mapping = Pattern.compile(
|
||||
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||
while (mapping.find()) {
|
||||
String args = mapping.group(2).toLowerCase();
|
||||
for (String banned : FORBIDDEN_ENDPOINT_TOKENS) {
|
||||
if (args.contains(banned)) {
|
||||
violations.add(f.getFileName() + " → " + mapping.group(0)
|
||||
.replaceAll("\\s+", " ") + " 含联系类端点: " + banned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b) 机制扫描:全仓 main 源码无 ContactAuthorization 类;迁移目录无建表
|
||||
violations.addAll(scanForContactAuthorizationMechanism());
|
||||
|
||||
assertEquals(List.of(), violations,
|
||||
"B1 联系规则契约被破坏(MM-CONTACT-003):出现联系类端点或联系授权机制。\n"
|
||||
+ "契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-004:无机制即无到期场景 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-004: 无联系授权机制 ⇒ 不存在授权到期场景(同源断言承载)")
|
||||
void noContactAuthorizationMechanismImpliesNoExpiryScenario() throws IOException {
|
||||
/*
|
||||
* ZC-MM-003(B1)「临时授权到期」场景的守卫:授权机制本身不存在(MM-CONTACT-003),
|
||||
* 因此不存在"到期后权限回收"场景。本用例与 MM-CONTACT-003 共享同一底层断言 ——
|
||||
* 若未来引入 ContactAuthorizationPolicy / matchmaker_contact_authorizations 表,
|
||||
* 两条守卫同时判红,迫使实现者补齐「授权期限 + 到期回收 + 审计」语义,
|
||||
* 并同步 007 治理登记(安全默认 = 不开放完整联系方式 / 未授权线下联系)。
|
||||
*/
|
||||
List<String> violations = scanForContactAuthorizationMechanism();
|
||||
assertEquals(List.of(), violations,
|
||||
"MM-CONTACT-004:联系授权机制出现 ⇒ 必须同步实现到期回收与审计,并过 007 评审。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ZC-MM-004 佐证:无批处理代操作面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("ZC-MM-004 佐证: matchmaker/referral 包无 @Scheduled(无批处理代操作通道)")
|
||||
void noScheduledTasksInMatchmakerAndReferralPackages() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Path dir : PACKAGE_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.walk(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (src.contains("@Scheduled")) {
|
||||
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
|
||||
+ "(ZC-MM-004:禁止引入代替用户决定/代操作的自动化通道)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"红娘/转介域出现定时任务必须先过「禁止代用户决定」评审(ZC-MM-004)");
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 全仓 main 源码 + 迁移脚本扫描 ContactAuthorization 机制(003/004 共享)。 */
|
||||
private List<String> scanForContactAuthorizationMechanism() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String name = f.getFileName().toString();
|
||||
if (name.contains("ContactAuthorization")) {
|
||||
violations.add("出现联系授权类(机制被引入): " + name);
|
||||
continue;
|
||||
}
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (src.contains("ContactAuthorization")) {
|
||||
violations.add("源码引用联系授权机制: " + f.getFileName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Path migrationDir : migrationDirCandidates()) {
|
||||
if (!Files.isDirectory(migrationDir)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(migrationDir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".sql")).toList()) {
|
||||
String src = Files.readString(f, StandardCharsets.UTF_8).toLowerCase();
|
||||
if (src.contains("matchmaker_contact_authorizations")) {
|
||||
violations.add("出现 matchmaker_contact_authorizations 建表迁移: " + f.getFileName()
|
||||
+ "(安全默认=不开放;仅当产品确认开放联系功能时随 006 评审引入)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** 迁移脚本目录(兼容仓库根与单模块两种布局)。 */
|
||||
private List<Path> migrationDirCandidates() {
|
||||
Path parent = SRC_ROOT.getParent();
|
||||
return List.of(
|
||||
parent != null ? parent.resolve("resources/db/migration") : Path.of("backend/src/main/resources/db/migration"),
|
||||
Path.of("backend/src/main/resources/db/migration"),
|
||||
Path.of("src/main/resources/db/migration"));
|
||||
}
|
||||
|
||||
private static String stripComments(String src) {
|
||||
return src.replaceAll("//[^\\n]*", "").replaceAll("/\\*.*?\\*/", "");
|
||||
}
|
||||
|
||||
/** 定位源码根目录(与 SecurityPathCoverageTest 同款双候选)。 */
|
||||
private static Path resolveSourceRoot() {
|
||||
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
|
||||
Path path = Path.of(candidate);
|
||||
if (Files.isDirectory(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
|
||||
+ Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user