|
|
@@ -0,0 +1,242 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.PaymentOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.AccountCleanupService;
|
|
|
+import io.jsonwebtoken.Claims;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
+import java.io.PrintWriter;
|
|
|
+import java.io.StringWriter;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:账号注销(能力键 {@code auth.account.deactivate})。
|
|
|
+ *
|
|
|
+ * <p><b>契约来源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:tests/user-scenarios/应用场景总览.md SC-P-71(账号注销:申请 → 身份验证 → 冷静期 → 数据归档/删除;
|
|
|
+ * 边界:有进行中订单/未结清拦截;注销后不可登录);TC-P-71 / TC-P-71-E / TC-P-71-P;业务域 J 账号与数据主权。</li>
|
|
|
+ * <li>设计:docs/superpowers/api/API_REFERENCE.md §4.1 认证与用户(/api/auth、/api/user)、§4.65.5;
|
|
|
+ * 现有 {@code POST /api/user/deactivate} → {@link AccountCleanupService#deactivateAccount(Long)}
|
|
|
+ * (立即匿名化 + 未完成订单/测评拦截)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>契约缺口(RED 预期)</b>:SC-P-71 要求「申请 → 身份验证 → 冷静期 → 数据归档/删除」四段闭环,
|
|
|
+ * 而 src/main 现状为<b>单段立即匿名化</b>:缺少<b>身份验证前置</b>、缺少<b>冷静期</b>(申请后即 deleted=1,
|
|
|
+ * 无等待/撤回窗口)、注销后不可登录仅靠「清空 phone + 哈希 openid」incidental 达成,
|
|
|
+ * {@code JwtInterceptor} 无 {@code deleted=1} 拦截(TC-P-71-E 要求注销后原 token 失效 401)。
|
|
|
+ * 本测试以「反射结构探测 + 真实行为验证」混合表达该缺口:能编译、能运行、相关断言失败(红),
|
|
|
+ * 失败断言原文可贴;GREEN 阶段补齐两阶段注销与 deleted 拦截后转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>已落地部分(GREEN 基线)</b>:进行中订单/未结清测评拦截为契约边界之一,src/main 已实现,
|
|
|
+ * 本测试同时固化其行为(防回归)。参考风格:FamilyJoinValidationRulesContractTest(红门禁 + 绿正常流混合)。</p>
|
|
|
+ *
|
|
|
+ * <p>纯 Mockito/反射,无 Spring 容器 / 无 DB / 无外部服务。</p>
|
|
|
+ */
|
|
|
+class AuthAccountDeactivateContractTest {
|
|
|
+
|
|
|
+ private static final Long USER_ID = 200L;
|
|
|
+ private static final Long FAMILY_ID = 100L;
|
|
|
+
|
|
|
+ // ---------------- 被测:账号清理服务(真实实现 + mock mappers) ----------------
|
|
|
+ @Mock
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private PaymentOrderMapper paymentOrderMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private AssessmentOrderMapper assessmentOrderMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private AccountCleanupService accountCleanupService;
|
|
|
+
|
|
|
+ // ---------------- 被测:登录门禁(真实实现 + mock 依赖) ----------------
|
|
|
+ @Mock
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // GREEN 基线:进行中订单 / 未结清测评 拦截(契约边界,已落地)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("GREEN基线-1:有未完成支付订单 → 拦截注销,不匿名化")
|
|
|
+ void 有未完成支付订单_拦截注销_不匿名化() {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(USER_ID);
|
|
|
+ user.setFamilyId(FAMILY_ID);
|
|
|
+ when(userMapper.selectById(USER_ID)).thenReturn(user);
|
|
|
+ when(paymentOrderMapper.selectCount(any())).thenReturn(1L); // 有 pending 支付订单
|
|
|
+ when(assessmentOrderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+
|
|
|
+ Map<String, Object> result = accountCleanupService.deactivateAccount(USER_ID);
|
|
|
+
|
|
|
+ assertEquals(Boolean.FALSE, result.get("canDeactivate"),
|
|
|
+ "有进行中订单必须拦截注销(SC-P-71 边界)");
|
|
|
+ assertEquals("您有未完成的订单,请先处理后再注销账号", result.get("message"));
|
|
|
+ // 未通过门禁不得匿名化/软删
|
|
|
+ verify(userMapper, never()).updateById(any(User.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("GREEN基线-2:有未结清测评订单 → 拦截注销,不匿名化")
|
|
|
+ void 有未结清测评订单_拦截注销_不匿名化() {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(USER_ID);
|
|
|
+ user.setFamilyId(null); // 无家庭支付订单,走测评检查
|
|
|
+ when(userMapper.selectById(USER_ID)).thenReturn(user);
|
|
|
+ when(assessmentOrderMapper.selectCount(any())).thenReturn(1L); // 有 pending 测评订单
|
|
|
+
|
|
|
+ Map<String, Object> result = accountCleanupService.deactivateAccount(USER_ID);
|
|
|
+
|
|
|
+ assertEquals(Boolean.FALSE, result.get("canDeactivate"),
|
|
|
+ "有未结清测评必须拦截注销(SC-P-71 边界)");
|
|
|
+ assertEquals("您有未完成的测评,请先处理后再注销账号", result.get("message"));
|
|
|
+ verify(userMapper, never()).updateById(any(User.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // RED:身份验证 + 冷静期 —— 两阶段注销入口应存在(申请 / 确认)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED-1:应提供「申请注销+身份验证」入口 applyDeactivate(身份验证前置)")
|
|
|
+ void 应提供申请注销入口_含身份验证() {
|
|
|
+ Class<?> svc = loadClass("com.etotem.cfc.service.AccountCleanupService");
|
|
|
+ assertNotNull(svc, "AccountCleanupService 应存在");
|
|
|
+ assertTrue(hasMethod(svc, "applyDeactivate"),
|
|
|
+ "[auth.account.deactivate] AccountCleanupService 应提供 applyDeactivate(...) 入口承载"
|
|
|
+ + "「申请 + 身份验证」阶段:SC-P-71 要求 申请 → 身份验证 → 冷静期 → 数据归档/删除 两阶段闭环,"
|
|
|
+ + "当前仅有立即匿名化的 deactivateAccount(userId),缺少身份验证前置与申请阶段。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED-2:应提供「冷静期满确认注销」入口 confirmDeactivate(数据归档/删除)")
|
|
|
+ void 应提供确认注销入口_冷静期满后归档删除() {
|
|
|
+ Class<?> svc = loadClass("com.etotem.cfc.service.AccountCleanupService");
|
|
|
+ assertNotNull(svc, "AccountCleanupService 应存在");
|
|
|
+ assertTrue(hasMethod(svc, "confirmDeactivate"),
|
|
|
+ "[auth.account.deactivate] AccountCleanupService 应提供 confirmDeactivate(...) 入口承载"
|
|
|
+ + "「冷静期满 → 数据归档/删除」阶段:申请进入冷静期后,须在冷静期结束才执行最终删除,"
|
|
|
+ + "当前缺失确认阶段。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED-3:User 实体应承载注销冷静期状态与申请时间")
|
|
|
+ void 用户实体应承载冷静期状态与申请时间() {
|
|
|
+ Class<?> user = loadClass("com.etotem.cfc.entity.User");
|
|
|
+ assertNotNull(user, "User 实体应存在");
|
|
|
+ boolean hasStatus = hasField(user, "deactivateStatus");
|
|
|
+ boolean hasApplyAt = hasField(user, "deactivateApplyAt");
|
|
|
+ assertTrue(hasStatus && hasApplyAt,
|
|
|
+ "[auth.account.deactivate] User 实体应承载注销冷静期状态(deactivateStatus)与申请时间(deactivateApplyAt),"
|
|
|
+ + "以支撑「申请 → 冷静期 → 确认」两阶段注销;当前实体无任何冷静期字段,"
|
|
|
+ + "无法在申请后保留可撤回的等待窗口。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // RED:注销后不可登录 —— 已注销(deleted=1)账号持有效 token 应被 401 拒绝
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED-4:已注销账号携有效 token 访问受保护路径 → 401 拒绝(注销后不可登录)")
|
|
|
+ void 已注销账号携有效token_访问受保护路径应401拒绝() throws Exception {
|
|
|
+ HttpServletRequest request = mock(HttpServletRequest.class);
|
|
|
+ HttpServletResponse response = mock(HttpServletResponse.class);
|
|
|
+ StringWriter sw = new StringWriter();
|
|
|
+ when(response.getWriter()).thenReturn(new PrintWriter(sw));
|
|
|
+
|
|
|
+ when(request.getMethod()).thenReturn("POST");
|
|
|
+ when(request.getRequestURI()).thenReturn("/api/family/member/list");
|
|
|
+ when(request.getHeader("Authorization")).thenReturn("Bearer valid-token");
|
|
|
+
|
|
|
+ Claims claims = mock(Claims.class);
|
|
|
+ when(claims.getSubject()).thenReturn(String.valueOf(USER_ID));
|
|
|
+ when(claims.get("role")).thenReturn("parent");
|
|
|
+ when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
|
|
|
+ when(jwtConfig.parseToken("valid-token")).thenReturn(claims);
|
|
|
+
|
|
|
+ User deletedUser = new User();
|
|
|
+ deletedUser.setId(USER_ID);
|
|
|
+ deletedUser.setDeleted(1); // 已注销
|
|
|
+ when(userMapper.selectById(USER_ID)).thenReturn(deletedUser);
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertFalse(result,
|
|
|
+ "[auth.account.deactivate] 已注销(deleted=1)账号即使持有效 token 也应被拒绝"
|
|
|
+ + "(SC-P-71 / TC-P-71-E:注销后不可登录、原 token 失效 401);"
|
|
|
+ + "当前 JwtInterceptor 未校验 deleted 标记,已注销用户仍可访问受保护路径。");
|
|
|
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 反射辅助(与 TeamProfitHookContractTest 同风格)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasMethod(Class<?> clazz, String name) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(name)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasField(Class<?> clazz, String name) {
|
|
|
+ for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
|
|
|
+ for (Field f : c.getDeclaredFields()) {
|
|
|
+ if (f.getName().equals(name)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|