|
|
@@ -0,0 +1,297 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.controller.auth.AuthController;
|
|
|
+import com.etotem.cfc.dto.LoginResultDTO;
|
|
|
+import com.etotem.cfc.dto.PhoneLoginDTO;
|
|
|
+import com.etotem.cfc.dto.WechatLoginDTO;
|
|
|
+import com.etotem.cfc.dto.WechatPhoneLoginDTO;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import java.io.InputStream;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Scanner;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code child.auth.login}(S-cfc-211,TDD-RED 阶段)。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-01(孩子端登录·无家长入口)、
|
|
|
+ * SC-C-16(个人资料查看受限)、SC-C-17(退出登录)、SC-C-18(设备/账号绑定校验);
|
|
|
+ * {@code tests/test-cases/child.md} TC-C-01 / TC-C-16 / TC-C-17 / TC-C-18(含 -E 异常流)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.1(/api/auth 登录族)与 §4.61.1
|
|
|
+ * (SC-C-01/16/17/18 端点口径);{@code docs/superpowers/specs/2026-11-07-child-zone-contract.md}
|
|
|
+ * G-03(无服务端 logout,JWT 无状态)、G-04(无设备维度)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(origin/cfclub @ 3fa4eb7db 实测)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>✅ 已有:登录族端点(send-code/phone-login/silent-login/wechat-login/verify)、
|
|
|
+ * {@code LoginResultDTO} 含 role/currentMemberId(SC-C-01 角色分流载体)、
|
|
|
+ * {@code UserService.maskChildSensitiveFields}(SC-C-16 已实现)。</li>
|
|
|
+ * <li>❌ 缺失(本文断言点):SC-C-17 服务端 logout 端点 + token 失效载体;SC-C-18 设备维度
|
|
|
+ * (登录 DTO 无 deviceId、无 device_bind 表、无 /api/auth/device/verify、无 DeviceBindService)。</li>
|
|
|
+ * <li>说明:{@code /api/auth/auto-login} 因 2026-09 安全修复(明文 openid 换 JWT 越权)已 410 废弃,
|
|
|
+ * 契约以 silent-login 作为受认可的自动登录入口,本文不要求 auto-login 恢复可签发 token。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:SC-C-17 / SC-C-18 断言在实现补齐前应失败(红);SC-C-01 / SC-C-16 为现状锚点
|
|
|
+ * (已实现则绿),仅用于向编排侧登记「已存在、无需实现」。</p>
|
|
|
+ *
|
|
|
+ * <p>纯反射 + schema.sql 静态读取(参考 TeamProfitHookContractTest 风格),无 Spring / 无 DB / 无外部服务。</p>
|
|
|
+ */
|
|
|
+class ChildAuthLoginContractTest {
|
|
|
+
|
|
|
+ private static final String AUTH_CONTROLLER = "com.etotem.cfc.controller.auth.AuthController";
|
|
|
+ private static final String DEVICE_VERIFY_PATH = "/device/verify";
|
|
|
+ private static final String LOGOUT_PATH = "/logout";
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 正常流·SC-C-01 孩子端登录(无家长入口)—— 现状锚点(已实现,绿)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1(SC-C-01 现状锚点):登录族端点齐全(send-code/phone-login/silent-login/wechat-login)")
|
|
|
+ void authController_loginFamilyEndpointsExist() {
|
|
|
+ assertNotNull(AuthController.class.getAnnotation(RestController.class),
|
|
|
+ "[child.auth.login/SC-C-01] AuthController 应为 @RestController(/api/auth)。");
|
|
|
+ RequestMapping classMapping = AuthController.class.getAnnotation(RequestMapping.class);
|
|
|
+ assertNotNull(classMapping, "[child.auth.login/SC-C-01] AuthController 应有 @RequestMapping(\"/api/auth\")。");
|
|
|
+ assertEquals("/api/auth", classMapping.value()[0],
|
|
|
+ "[child.auth.login/SC-C-01] AuthController 类映射应为 /api/auth。");
|
|
|
+
|
|
|
+ String[] loginFamily = {"/send-code", "/phone-login", "/silent-login", "/wechat-login"};
|
|
|
+ StringBuilder missing = new StringBuilder();
|
|
|
+ for (String path : loginFamily) {
|
|
|
+ if (!methodMappedToPost(AuthController.class, path)) {
|
|
|
+ missing.append(path).append(' ');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertEquals("", missing.toString(),
|
|
|
+ "[child.auth.login/SC-C-01] 登录族端点缺失(孩子端独立登录入口,SC-C-01):"
|
|
|
+ + missing.toString().trim() + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2(SC-C-01 现状锚点):LoginResultDTO 携带 role/currentMemberId 供孩子端按角色分流")
|
|
|
+ void loginResultDto_carriesRoleAndCurrentMemberId() {
|
|
|
+ assertTrue(hasField(LoginResultDTO.class, "role"),
|
|
|
+ "[child.auth.login/SC-C-01] LoginResultDTO 应携带 role(孩子端据此进入孩子端布局,无家长入口)。");
|
|
|
+ assertTrue(hasField(LoginResultDTO.class, "currentMemberId"),
|
|
|
+ "[child.auth.login/SC-C-01] LoginResultDTO 应携带 currentMemberId(当前成员视角)。");
|
|
|
+ assertTrue(hasField(LoginResultDTO.class, "token"),
|
|
|
+ "[child.auth.login/SC-C-01] LoginResultDTO 应携带 token(登录会话凭证)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 正常流·SC-C-16 受限资料投影 —— 现状锚点(已实现,绿)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3(SC-C-16 现状锚点·已实现):child 角色资料敏感字段被投影遮蔽")
|
|
|
+ void userService_maskChildSensitiveFields_masksChildSensitiveFields() {
|
|
|
+ UserService userService = new UserService();
|
|
|
+ User child = new User();
|
|
|
+ child.setId(1L);
|
|
|
+ child.setRole("child");
|
|
|
+ child.setIdCard("110101199001011234");
|
|
|
+ child.setPhone("13800000000");
|
|
|
+ child.setRealName("小明");
|
|
|
+ child.setOpenid("openid-x");
|
|
|
+ child.setUnionid("unionid-x");
|
|
|
+
|
|
|
+ userService.maskChildSensitiveFields(child);
|
|
|
+
|
|
|
+ assertNull(child.getIdCard(), "[child.auth.login/SC-C-16] 孩子端资料应隐藏 idCard(家长代管字段)。");
|
|
|
+ assertNull(child.getPhone(), "[child.auth.login/SC-C-16] 孩子端资料应隐藏 phone。");
|
|
|
+ assertNull(child.getRealName(), "[child.auth.login/SC-C-16] 孩子端资料应隐藏 realName。");
|
|
|
+ assertNull(child.getOpenid(), "[child.auth.login/SC-C-16] 孩子端资料应隐藏 openid。");
|
|
|
+ assertNull(child.getUnionid(), "[child.auth.login/SC-C-16] 孩子端资料应隐藏 unionid。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4(SC-C-16 现状锚点·已实现):家长/规划师角色不受投影影响")
|
|
|
+ void userService_maskChildSensitiveFields_parentRoleUnaffected() {
|
|
|
+ UserService userService = new UserService();
|
|
|
+ User parent = new User();
|
|
|
+ parent.setRole("parent");
|
|
|
+ parent.setPhone("13800000000");
|
|
|
+ parent.setRealName("爸爸");
|
|
|
+ parent.setIdCard("110101198001011234");
|
|
|
+
|
|
|
+ userService.maskChildSensitiveFields(parent);
|
|
|
+
|
|
|
+ assertEquals("13800000000", parent.getPhone(),
|
|
|
+ "[child.auth.login/SC-C-16] 家长角色不应被遮蔽 phone。");
|
|
|
+ assertEquals("爸爸", parent.getRealName(),
|
|
|
+ "[child.auth.login/SC-C-16] 家长角色不应被遮蔽 realName。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 边界流·SC-C-17 退出登录 —— 契约缺口(RED)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1(SC-C-17 RED):应有服务端退出端点 POST /api/auth/logout")
|
|
|
+ void authController_shouldHaveLogoutEndpoint() {
|
|
|
+ assertTrue(methodMappedToPost(AuthController.class, LOGOUT_PATH),
|
|
|
+ "[child.auth.login/SC-C-17] AuthController 应声明 POST " + LOGOUT_PATH
|
|
|
+ + "(孩子端安全退出、清除登录态;TC-C-17 主流程)。当前 src/main 无任何 /logout 端点"
|
|
|
+ + "(child-zone-contract G-03:SecurityConfig.logout().disable(),JWT 无状态)。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2(SC-C-17 RED):退出后 token 应服务端失效(黑名单/版本号载体)")
|
|
|
+ void logout_tokenShouldBeInvalidatableServerSide() {
|
|
|
+ Class<?> blacklist = loadClass("com.etotem.cfc.service.TokenBlacklistService");
|
|
|
+ boolean hasInvalidate = false;
|
|
|
+ if (blacklist != null) {
|
|
|
+ for (Method m : blacklist.getDeclaredMethods()) {
|
|
|
+ String n = m.getName();
|
|
|
+ if (n.equals("invalidate") || n.equals("isInvalid") || n.equals("isBlacklisted")) {
|
|
|
+ hasInvalidate = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(blacklist != null && hasInvalidate,
|
|
|
+ "[child.auth.login/SC-C-17] 退出后旧 token 调用接口应 401(TC-C-17-E 异常流)。"
|
|
|
+ + "契约要求 TokenBlacklistService(invalidate/isInvalid)或等价 token 版本号/黑名单载体;"
|
|
|
+ + "当前 src/main 无任何 token 失效机制(JwtConfig 仅 userId/role/roles claims,"
|
|
|
+ + "JWT 无状态、SecurityConfig.logout().disable())。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 边界流·SC-C-18 设备/账号绑定校验 —— 契约缺口(RED)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-5(SC-C-18 RED):登录 DTO 应携带 deviceId 供设备维度校验")
|
|
|
+ void loginDtos_shouldCarryDeviceId() {
|
|
|
+ StringBuilder missing = new StringBuilder();
|
|
|
+ for (Class<?> dto : new Class[]{PhoneLoginDTO.class, WechatLoginDTO.class, WechatPhoneLoginDTO.class}) {
|
|
|
+ if (!hasField(dto, "deviceId")) {
|
|
|
+ missing.append(simpleName(dto.getName())).append(' ');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertEquals("", missing.toString().trim(),
|
|
|
+ "[child.auth.login/SC-C-18] 登录 DTO 应含 deviceId 字段(登录设备校验输入,TC-C-18 主流程;"
|
|
|
+ + "child-zone-contract G-04:AuthController 全部端点无设备参数)。"
|
|
|
+ + "当前缺失:" + missing.toString().trim() + "。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-4(SC-C-18 RED):异常设备应有二次验证端点 POST /api/auth/device/verify")
|
|
|
+ void authController_shouldHaveDeviceVerifyEndpoint() {
|
|
|
+ assertTrue(methodMappedToPost(AuthController.class, DEVICE_VERIFY_PATH),
|
|
|
+ "[child.auth.login/SC-C-18] AuthController 应声明 POST " + DEVICE_VERIFY_PATH
|
|
|
+ + "(异常设备二次验证/风控提示,TC-C-18 边界:异常设备二次验证;"
|
|
|
+ + "child-zone-contract G-04 建议端点)。当前 src/main 无设备维度端点。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-5(SC-C-18 RED):schema.sql 应有 device_bind 设备绑定表")
|
|
|
+ void schemaSql_shouldHaveDeviceBindTable() {
|
|
|
+ assertTrue(readSchemaSql().contains("device_bind"),
|
|
|
+ "[child.auth.login/SC-C-18] schema.sql 应含 device_bind 建表(设备/账号绑定记录载体,"
|
|
|
+ + "TC-C-18:绑定校验失败拒登录;child-zone-contract G-04)。当前缺失。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-6(SC-C-18 RED):应有设备绑定校验服务 DeviceBindService")
|
|
|
+ void deviceBindService_shouldExistWithVerifyCapability() {
|
|
|
+ Class<?> service = loadClass("com.etotem.cfc.service.DeviceBindService");
|
|
|
+ boolean hasVerify = false;
|
|
|
+ if (service != null) {
|
|
|
+ for (Method m : service.getDeclaredMethods()) {
|
|
|
+ String n = m.getName().toLowerCase();
|
|
|
+ if (n.contains("verify") || n.contains("check") || n.contains("abnormal") || n.contains("risk")) {
|
|
|
+ hasVerify = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(service != null && hasVerify,
|
|
|
+ "[child.auth.login/SC-C-18] 应有 DeviceBindService 承载设备绑定/异常设备风控判定"
|
|
|
+ + "(登录时设备校验 → 正常设备放行 / 异常设备二次验证,SC-C-18 主流程)。"
|
|
|
+ + "当前服务类不存在。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1(SC-C-18 说明):设备校验属登录链路预检,无独立权限维度")
|
|
|
+ void permissionFlow_deviceVerifyIsPreLogin() {
|
|
|
+ // 说明性用例:设备/账号绑定校验发生在登录时(匿名阶段),是登录准入预检而非登录后接口,
|
|
|
+ // 故「权限」流以该说明记录(与 TeamProfitHookContractTest 的 permissionFlow_notApplicable 同风格)。
|
|
|
+ assertTrue(true, "权限流说明:SC-C-18 设备校验为登录预检(匿名可触达),无登录后权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 反射 / 静态资源辅助
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ /** 遍历类中所有声明方法,判断是否存在映射到指定路径的 @PostMapping 方法。 */
|
|
|
+ private boolean methodMappedToPost(Class<?> clazz, String path) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ PostMapping pm = m.getAnnotation(PostMapping.class);
|
|
|
+ if (pm == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (String value : pm.value()) {
|
|
|
+ if (path.equals(value)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasField(Class<?> clazz, String name) {
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (f.getName().equals(name)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String simpleName(String fqcn) {
|
|
|
+ int idx = fqcn.lastIndexOf('.');
|
|
|
+ return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String readSchemaSql() {
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
+ if (is == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|