Преглед изворни кода

Merge origin/cfclub into cfclub: 同步并发合入

Sisyphus пре 18 часа
родитељ
комит
61b53920ff

+ 286 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AuthSessionMutexContractTest.java

@@ -0,0 +1,286 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+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.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:多端登录/会话互斥(能力键 {@code auth.session.mutex},S-cfc-301,TDD-RED 阶段)。
+ *
+ * <p><b>契约锚点(溯源)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-85(多端登录/会话互斥——
+ *       <b>主流程:新设备登录 → 旧设备踢下线/二次验证;边界:异常设备风控;退出清 token</b>);
+ *       {@code tests/user-scenarios/02-parent.md} SC-P-85、用例 TC-P-85。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.1(/api/auth 登录/token 生命周期)、
+ *       §4.63.9(SC-P-85 登录族 + SSO 票据)与契约缺口 <b>G-5</b>(无单端互斥/旧会话踢出,
+ *       JWT 无状态)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub @ d6dac931a 实测)</b>:src/main 无任何会话互斥载体 ——
+ * 无 {@code UserSessionService} 服务类、无 {@code user_session} 表(schema.sql 全库检索 0 命中)、
+ * {@code LoginResultDTO} 无 {@code sessionId}/{@code secondVerifyRequired} 字段、登录族服务方法
+ * ({@code UserService.wechatLogin/phoneLogin/silentLogin/ssoLogin/switchRoleForLogin})
+ * 未注入任何会话登记能力;退出为前端清 JWT(无服务端 logout,{@code SecurityConfig} STATELESS)。
+ * 会话互斥能力整体未落地(对应设计缺口 G-5)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地,GREEN 指引)</b></p>
+ * <ul>
+ *   <li>{@code com.etotem.cfc.service.UserSessionService}:
+ *       {@code String login(Long userId, String deviceId)} —— 新设备登录登记会话,返回 sessionId;
+ *       {@code void kick(Long userId, String excludedSessionId)} —— 老设备会话踢下线(互斥);
+ *       {@code void invalidate(Long userId, String sessionId)} —— 退出清 token(服务端失效);
+ *       {@code boolean isKnownDevice(Long userId, String deviceId)} / {@code RiskResult riskCheck(...)}
+ *       —— 异常设备风控判定。</li>
+ *   <li>{@code LoginResultDTO} 增补:{@code sessionId}(会话定位凭证)、
+ *       {@code secondVerifyRequired}(新设备触发二次验证标记)。</li>
+ *   <li>{@code schema.sql} 增补 {@code user_session} 建表({@code session_id}/{@code user_id}/
+ *       {@code device_id}/{@code status}(active/kicked)/{@code risk_flag})。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,反射锚定缺失实现 + schema.sql 静态读取
+ * (参考 TeamProfitHookContractTest / ChildAuthLoginContractTest 风格),
+ * 无 Spring / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>RED 预期</b>:会话互斥载体整体缺失,下述正常流/边界流/异常流断言应失败(红);
+ * 权限流为说明性用例(绿)。若实现已意外落地导致全绿,按编排约定交登记,不私改实现。</p>
+ */
+class AuthSessionMutexContractTest {
+
+    private static final String USER_SESSION_SERVICE = "com.etotem.cfc.service.UserSessionService";
+
+    // ====================================================================
+    // 正常流(主流程):新设备登录 → 旧设备踢下线
+    // ====================================================================
+
+    @Test
+    @DisplayName("正常流-1:UserSessionService 会话互斥服务类应存在")
+    void userSessionService_classExists() {
+        assertTrue(loadClass(USER_SESSION_SERVICE) != null,
+                "[auth.session.mutex/SC-P-85] UserSessionService 类不存在:契约要求服务端会话登记/互斥裁决核心"
+                        + "(新设备登录登记会话 → 旧设备踢下线,SC-P-85 主流程)。当前 src/main 无任何会话互斥"
+                        + "载体(设计缺口 G-5:JWT 无状态、无单端互斥)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:UserSessionService 应提供 login(登记新会话)与 kick/invalidate(旧会话踢下线)入口")
+    void userSessionService_hasLoginAndKickMethods() {
+        Class<?> clazz = loadClass(USER_SESSION_SERVICE);
+        if (clazz == null) {
+            fail("[auth.session.mutex/SC-P-85] UserSessionService 类不存在,无法验证 login/kick 入口"
+                    + "(新设备登录 → 旧设备踢下线,SC-P-85 主流程)。实现未落地。");
+        }
+        boolean hasLogin = hasMethodNamed(clazz, "login");
+        boolean hasKick = hasMethodNamed(clazz, "kick")
+                || hasMethodNamed(clazz, "invalidate")
+                || hasMethodNamed(clazz, "logout");
+        assertTrue(hasLogin,
+                "[auth.session.mutex/SC-P-85] UserSessionService 应提供 login(...) 在每次登录时登记新会话"
+                        + "(新设备登录触发,SC-P-85 主流程)。实现未落地。");
+        assertTrue(hasKick,
+                "[auth.session.mutex/SC-P-85] UserSessionService 应提供 kick/invalidate/logout(...) 使旧设备会话"
+                        + "踢下线(同账号仅保留新会话,SC-P-85 主流程)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:登录族服务方法应注入 UserSessionService 钩子(登录即登记会话,建立单端互斥)")
+    void userService_loginFlow_injectsUserSessionService() {
+        boolean injected = hasFieldOfType(UserService.class, USER_SESSION_SERVICE);
+        assertTrue(injected,
+                "[auth.session.mutex/SC-P-85] UserService 登录族方法(wechatLogin/phoneLogin/silentLogin/"
+                        + "ssoLogin/switchRoleForLogin)应注入 UserSessionService 钩子:每次登录登记新会话并使旧会话"
+                        + "失效(新设备登录 → 旧设备踢下线,SC-P-85 主流程;当前登录只签发 JWT,无会话维度)。"
+                        + "实现未落地。");
+    }
+
+    // ====================================================================
+    // 边界流:二次验证 / 退出清 token(旧设备踢下线结果需可观测、可失效)
+    // ====================================================================
+
+    @Test
+    @DisplayName("边界流-1:LoginResultDTO 应携带 sessionId 与 secondVerifyRequired(二次验证标记)")
+    void loginResultDto_carriesSessionContext() {
+        assertTrue(hasField(LoginResultDTO.class, "sessionId"),
+                "[auth.session.mutex/SC-P-85] LoginResultDTO 应携带 sessionId:登录返回的会话定位凭证,"
+                        + "供前端 logout/被踢下线时定位会话(SC-P-85 主流程+退出清 token)。"
+                        + "当前 LoginResultDTO 无任何会话字段。实现未落地。");
+        assertTrue(hasField(LoginResultDTO.class, "secondVerifyRequired"),
+                "[auth.session.mutex/SC-P-85] LoginResultDTO 应携带 secondVerifyRequired:新设备登录时服务端"
+                        + "可裁决「踢下线」或「二次验证」两种互斥策略(SC-P-85 主流程)。当前缺失。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:schema.sql 应有 user_session 会话登记表(承载旧设备踢下线结果)")
+    void schemaSql_hasUserSessionTable() {
+        assertTrue(readSchemaSql().contains("user_session"),
+                "[auth.session.mutex/SC-P-85] schema.sql 应含 user_session 建表:服务端会话登记载体,"
+                        + "记录每次登录的会话/设备,旧设备踢下线结果(status)可被判定与清理(SC-P-85 主流程+"
+                        + "退出清 token)。当前 schema.sql 无该表(仅 users.session_key、butler_sessions 等非认证"
+                        + "会话表)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:user_session 表应含 session_id/user_id/device_id/status 列(会话唯一标识与互斥状态)")
+    void userSessionTable_hasSessionColumns() {
+        String block = userSessionTableBlock();
+        assertTrue(block.contains("session_id"),
+                "[auth.session.mutex/SC-P-85] user_session 应含 session_id 列(会话唯一标识,踢除/退出的定位键)。"
+                        + "实现未落地。");
+        assertTrue(block.contains("user_id"),
+                "[auth.session.mutex/SC-P-85] user_session 应含 user_id 列(会话归属账号,互斥的粒度基准)。"
+                        + "实现未落地。");
+        assertTrue(block.contains("device_id"),
+                "[auth.session.mutex/SC-P-85] user_session 应含 device_id 列(设备维度,区分新旧设备登录)。"
+                        + "实现未落地。");
+        assertTrue(block.contains("status"),
+                "[auth.session.mutex/SC-P-85] user_session 应含 status 列(active/kicked 等会话状态,"
+                        + "承载「旧设备踢下线」结果判定)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:退出清 token 应服务端失效会话(UserSessionService 提供 logout/invalidate 能力)")
+    void userSessionService_hasLogoutInvalidateCapability() {
+        Class<?> clazz = loadClass(USER_SESSION_SERVICE);
+        boolean hasLogout = false;
+        if (clazz != null) {
+            for (Method m : clazz.getDeclaredMethods()) {
+                String n = m.getName().toLowerCase();
+                if (n.contains("logout") || n.contains("invalidate") || n.contains("revoke")) {
+                    hasLogout = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(clazz != null && hasLogout,
+                "[auth.session.mutex/SC-P-85] 退出清 token:UserSessionService 应提供 logout/invalidate/revoke"
+                        + "方法使服务端会话失效(SC-P-85 边界:退出清 token;当前退出为前端清 JWT,"
+                        + "SecurityConfig.logout().disable(),无服务端会话可清。设计缺口 G-5)。实现未落地。");
+    }
+
+    // ====================================================================
+    // 异常/风险流:异常设备风控
+    // ====================================================================
+
+    @Test
+    @DisplayName("异常流-1:UserSessionService 应提供异常设备风控判定能力")
+    void userSessionService_hasRiskControlJudgment() {
+        Class<?> clazz = loadClass(USER_SESSION_SERVICE);
+        boolean hasRisk = false;
+        if (clazz != null) {
+            for (Method m : clazz.getDeclaredMethods()) {
+                String n = m.getName().toLowerCase();
+                if (n.contains("risk") || n.contains("abnormal") || n.contains("known")) {
+                    hasRisk = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(clazz != null && hasRisk,
+                "[auth.session.mutex/SC-P-85] UserSessionService 应提供异常设备风控判定方法 "
+                        + "(如 isKnownDevice/riskCheck:陌生/高风险设备登录 → 二次验证或拦截,SC-P-85 异常/边界)。"
+                        + "当前服务类不存在。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("异常流-2:user_session 表应有风险标记列(risk_flag,异常设备风控结果落库)")
+    void userSessionTable_hasRiskControlColumn() {
+        String block = userSessionTableBlock();
+        assertTrue(block.contains("risk_flag") || block.contains("risk"),
+                "[auth.session.mutex/SC-P-85] user_session 应含 risk_flag(或等价风险标记)列:异常设备风控结论"
+                        + "落库,供二次验证/拦截策略判定(SC-P-85 异常/边界:异常设备风控)。实现未落地。");
+    }
+
+    // ====================================================================
+    // 权限流(适用性说明)
+    // ====================================================================
+
+    @Test
+    @DisplayName("权限流:会话互斥为登录链路预检 + 会话生命周期管理,无独立业务权限维度")
+    void permissionFlow_sessionMutexIsAuthLifecycle() {
+        // 说明性用例:登录会话登记/踢下线发生在登录准入与登出生命周期(匿名可触达 → 登录后自管理),
+        // 无独立于认证本身的业务权限维度(与 TeamProfitHookContractTest.permissionFlow_notApplicable 同风格)。
+        assertTrue(true, "权限流说明:auth.session.mutex 属认证生命周期能力,权限随 JWT 认证本身,无独立权限断言。");
+    }
+
+    // ====================================================================
+    // 反射 / 静态资源辅助
+    // ====================================================================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasMethodNamed(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 (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasFieldOfType(Class<?> clazz, String fieldTypeFqcn) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (fieldTypeFqcn.equals(f.getType().getName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 截取 schema.sql 中 user_session 建表块(起始于其前最近的 CREATE TABLE,止于首个 ");")。 */
+    private String userSessionTableBlock() {
+        String schema = readSchemaSql();
+        int idx = schema.indexOf("user_session");
+        if (idx < 0) {
+            return "";
+        }
+        int start = schema.lastIndexOf("CREATE TABLE", idx);
+        if (start < 0) {
+            return "";
+        }
+        int end = schema.indexOf(");", idx);
+        if (end < 0) {
+            return "";
+        }
+        return schema.substring(start, end + 2);
+    }
+
+    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 "";
+        }
+    }
+}