diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java new file mode 100644 index 0000000..943142f --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java @@ -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 数据构造范式)。 + * + *

锁定四条行为不变量(架构师设计 T02): + *

    + *
  1. MM-ACTION-001 强化(负向):operator 主体 token 打用户专属决定端点 + * (推荐 accept / pass、转介 confirm)→ 认证层 403 + * (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller, + * SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()), + * 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。
  2. + *
  3. MM-ACTION-002:会话开启端点(POST /conversations/from-match/{matchId})同为 + * UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径, + * 无 operator 触达面)。
  4. + *
  5. MM-ACTION-003 / must-confirm 语义:红娘创建转介 → 响应 + * needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending; + * 红娘侧 recommend / followup / complete 动作不改变用户确认状态 —— + * 「转介代建 = 代填草稿、用户确认」语义被永久锁定。
  6. + *
  7. MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → notifications 表 + * 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式; + * 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转, + * 红娘与用户之间不存在直连联系方式交换。
  8. + *
+ */ +@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 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 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 notes = notificationMapper.selectList(new LambdaQueryWrapper() + .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() + .eq(Notification::getUserId, MATCHMAKER_OWNER)); + assertEquals(0L, operatorInbox, + "红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java new file mode 100644 index 0000000..af45d44 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java @@ -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 上下文)。 + * + *

锁定四条不变量(架构师设计 T01): + *

    + *
  1. MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码 + * 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone / + * mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」; + * emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段), + * 连红娘本人都无读回面。
  2. + *
  3. MM-CONTACT-003:matchmaker / referral Controller 无 contact / call / phone / + * channel / offline 类端点;全仓无 ContactAuthorization 类、无 + * matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。 + * 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。
  4. + *
  5. MM-CONTACT-004:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言 + * 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations, + * 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。
  6. + *
  7. ZC-MM-004 佐证:matchmaker / referral 包无 @Scheduled —— 全仓无批处理 + * 代操作面(禁止代替用户决定的自动化通道不存在)。
  8. + *
+ */ +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 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 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:以实际代码逐键核对后核定): + *
    + *
  • referToServiceStation → appointmentId / status / referralType / + * referralFromOperatorId / needsUserConfirmation
  • + *
  • referToActivity → registrationId / activityId / status / referralType / + * referralFromOperatorId / needsUserConfirmation
  • + *
  • confirmReferral → id / status / confirmedAt
  • + *
  • getReferralHistory → type / id / serviceType|activityId / status / + * createdByOperatorId / userConfirmedAt / createdAt|registeredAt
  • + *
  • getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的 + * 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式, + * 不落入 B1 禁放范畴)
  • + *
+ */ + private static final Set 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 FORBIDDEN_KEY_TOKENS = List.of( + "emergencycontact", "phone", "mobile", "wechat", "weixin", "address", + "idnumber", "id_card"); + + /** MM-CONTACT-003:联系/触达类禁用端点词 */ + private static final List 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 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 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 violations = new ArrayList<>(); + + // a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径 + for (Path dir : CONTROLLER_DIRS) { + assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir); + try (Stream 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 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 violations = new ArrayList<>(); + for (Path dir : PACKAGE_DIRS) { + assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir); + try (Stream 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 scanForContactAuthorizationMechanism() throws IOException { + List violations = new ArrayList<>(); + try (Stream 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 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 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()); + } +}