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