Browse Source

test(S-cfc-211) child.auth.login RED contract: logout/device/binding not implemented

TDD-RED contract tests for capability child.auth.login (S-cfc-211):

- SC-C-17 logout: POST /api/auth/logout endpoint missing; server-side token
  invalidation (TokenBlacklistService) missing -> assertions RED
- SC-C-18 device/account binding: login DTO deviceId, /api/auth/device/verify,
  device_bind table, DeviceBindService all missing -> assertions RED
- SC-C-01 / SC-C-16 status anchors: login family endpoints + LoginResultDTO.role
  + maskChildSensitiveFields already implemented (GREEN, to be registered by orchestration)

Expected RED: 11 tests, 6 failures (logout x2 / device x4); GREEN phase implements
to turn green.
E2E Test Bot 7 giờ trước cách đây
mục cha
commit
e35292d013

+ 297 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildAuthLoginContractTest.java

@@ -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 "";
+        }
+    }
+}