diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java new file mode 100644 index 0000000..2bdf707 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java @@ -0,0 +1,51 @@ +package com.involutionhell.backend.common.config; + +import cn.dev33.satoken.stp.StpInterface; +import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import java.util.List; +import org.springframework.stereotype.Component; + +/** + * Sa-Token 权限与角色加载实现。 + * + * 项目原来缺少 StpInterface 实现,Sa-Token 找不到实现 Bean 时会回退到默认的空列表, + * 导致所有 @SaCheckPermission / @SaCheckRole 注解永远校验失败(403), + * 不管数据库里给用户配了什么权限都没用。这是个生产 Bug,加上这个类才算把权限体系真正接通。 + * + * 关于 loginId 类型:Sa-Token 内部把登录 ID 序列化成 String 存储, + * 即使调用 StpUtil.login(Long) 传入的是 Long,回调这里时运行时类型也是 String, + * 所以不能直接强转,要先 toString() 再 Long.valueOf()。 + */ +@Component +public class SaTokenPermissionImpl implements StpInterface { + + private final UserAccountRepository userAccountRepository; + + public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) { + this.userAccountRepository = userAccountRepository; + } + + /** + * 返回用户拥有的权限码列表,Sa-Token 执行 @SaCheckPermission 时会调用此方法。 + * + * @param loginId 登录 ID,运行时实际类型是 String,不是 Long + * @param loginType 登录类型,单端场景下为 "login",此处忽略 + */ + @Override + public List getPermissionList(Object loginId, String loginType) { + // Sa-Token 回传的 loginId 是 String,必须先 toString() 再转 Long + return userAccountRepository.findById(Long.valueOf(loginId.toString())) + .map(account -> List.copyOf(account.permissions())) + .orElse(List.of()); + } + + /** + * 返回用户拥有的角色列表,供 @SaCheckRole 使用,逻辑和 getPermissionList 对称。 + */ + @Override + public List getRoleList(Object loginId, String loginType) { + return userAccountRepository.findById(Long.valueOf(loginId.toString())) + .map(account -> List.copyOf(account.roles())) + .orElse(List.of()); + } +} diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java index bb55665..0671a09 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -41,12 +41,17 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti } /** - * Sa-Token: 拦截权限不足异常 + * Sa-Token: 拦截权限不足异常。 + * + * 注意要用 e.getPermission() 而不是 e.getCode()。 + * getCode() 是父类 SaTokenException 的方法,返回的是整数场景码(比如 -1), + * 而权限字符串(比如 "user:center:read")在 NotPermissionException 自己的 permission 字段里, + * 要调 getPermission() 才能拿到。用错了的话错误消息会变成 "拒绝访问: 缺少权限 [-1]",没有任何意义。 */ @ExceptionHandler(NotPermissionException.class) public ResponseEntity> handleNotPermissionException(NotPermissionException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getCode() + "]")); + .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getPermission() + "]")); } /** diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java index 58d6d4f..9673245 100644 --- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java +++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java @@ -4,13 +4,36 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest +/** + * Spring Boot 上下文加载冒烟测试。 + * + * 原来用的是裸 @SpringBootTest,没覆盖任何属性。 + * 测试服务器上有 SPRING_DATASOURCE_URL 环境变量指向生产 Neon PostgreSQL, + * 环境变量优先级高于 application-test.properties,Spring 就会去连 PostgreSQL, + * H2 驱动拒绝 jdbc:postgresql:// 格式,直接报: + * Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://... + * + * @SpringBootTest(properties) 的优先级比环境变量还高,所以在这里覆盖就能保证始终跑 H2。 + * + * JustAuth 那几个属性也是同理:JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri, + * 它不接受 localhost,不覆盖的话 AuthGithubRequest 初始化直接抛异常。 + */ +@SpringBootTest(properties = { + // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2 + "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:test-schema.sql", + // JustAuth UrlValidator 不接受 localhost,用合法占位 URL 绕过 + "justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github", + "justauth.type.github.client-id=test-client-id", + "justauth.type.github.client-secret=test-client-secret" +}) @ActiveProfiles("test") class BackendApplicationTests { - /** - * 验证 Spring Boot 测试上下文可以正常启动。 - */ @Test void contextLoads() { } diff --git a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java index 9e5cb2b..9f0a031 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -1,49 +1,66 @@ package com.involutionhell.backend.openai.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.involutionhell.backend.openai.dto.OpenAiStreamRequest; import com.involutionhell.backend.openai.service.OpenAiStreamGateway; -import com.involutionhell.backend.openai.service.OpenAiStreamService; import com.involutionhell.backend.support.AbstractWebIntegrationTest; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; import org.springframework.http.MediaType; -import org.springframework.boot.test.context.TestConfiguration; import org.springframework.test.web.servlet.MvcResult; +/** + * OpenAiStreamController 集成测试。 + * + * 旧测试有三个问题: + * + * 1. 请求体字段写错了。DTO 已从单字段 message 改为多轮对话列表 messages, + * 旧测试还发 {"message":"..."}, 服务端 messages 为空,直接 400 校验失败,根本进不了 SSE 流程。 + * + * 2. 匿名请求的期望消息过时了。未带 token 是 Sa-Token NOT_TOKEN 场景, + * GlobalExceptionHandler 现在返回 "未提供 Token",不是旧版的通用文案。 + * + * 3. StreamingResponseBody 不能用轮询 getContentAsString() 来读响应体。 + * Spring 把实际写入分派到异步线程,MockMvc 必须用两步走: + * 先 perform + asyncStarted 拿到 MvcResult,再 perform(asyncDispatch(mvcResult)) 才能读到内容。 + * 旧版轮询方式始终读到空,超时报错。 + */ @Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class) class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest { @Test void streamReturnsSseEventsForAuthenticatedUser() throws Exception { String token = loginAsAdmin(); + + // 第一步:发起请求,确认异步处理已启动(StreamingResponseBody 异步写入) MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream") .header("satoken", token) .contentType(MediaType.APPLICATION_JSON) .content(""" { - "message": "你好" + "messages": [{"role": "user", "content": "你好"}] } """)) .andExpect(request().asyncStarted()) .andReturn(); - waitForSseBody(mvcResult); - - Assertions.assertThat(mvcResult.getResponse().getStatus()).isEqualTo(200); - Assertions.assertThat(mvcResult.getResponse().getContentType()).startsWith(MediaType.TEXT_EVENT_STREAM_VALUE); - Assertions.assertThat(mvcResult.getResponse().getContentAsString()).contains("response.output_text.delta"); - Assertions.assertThat(mvcResult.getResponse().getContentAsString()).contains("response.completed"); + // 第二步:触发异步派发,此时响应体才真正写入,relayEvents() 把内容转成 0:"hello"\n 格式 + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("0:"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("hello"))); } @Test @@ -52,16 +69,16 @@ void streamRejectsAnonymousRequest() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(""" { - "message": "你好" + "messages": [{"role": "user", "content": "你好"}] } """)) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("未登录或登录状态已失效")); + .andExpect(jsonPath("$.message").value("未提供 Token")); } @Test - void streamValidatesBlankMessage() throws Exception { + void streamValidatesEmptyMessages() throws Exception { String token = loginAsAdmin(); mockMvc.perform(post("/openai/responses/stream") @@ -69,33 +86,18 @@ void streamValidatesBlankMessage() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(""" { - "message": "" + "messages": [] } """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("message: 消息不能为空")); - } - - /** - * 等待模拟的 SSE 推送线程把事件内容写入响应体。 - */ - private void waitForSseBody(MvcResult mvcResult) throws Exception { - for (int attempt = 0; attempt < 20; attempt++) { - if (!mvcResult.getResponse().getContentAsString().isBlank()) { - return; - } - Thread.sleep(25L); - } - throw new IllegalStateException("SSE 响应内容未按预期写入"); + .andExpect(jsonPath("$.message").value("messages: 对话历史不能为空")); } @TestConfiguration static class OpenAiTestConfiguration { - /** - * 提供一个稳定的测试桩网关,避免控制器测试依赖真实 OpenAI 或 Mockito。 - */ + // 用 stub 替换真实 Gateway,避免集成测试依赖外部 OpenAI 服务 @Bean @Primary OpenAiStreamGateway openAiStreamGateway() { @@ -105,22 +107,18 @@ OpenAiStreamGateway openAiStreamGateway() { private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway { - /** - * 测试环境下跳过外部 OpenAI 配置校验。 - */ @Override public void validateConfiguration(OpenAiStreamRequest request) { } - /** - * 返回固定的 SSE 事件流,供控制器测试验证输出格式。 - */ + // 必须用 OpenAI 实际的 SSE 格式,relayEvents() 从 choices[0].delta.content 读文本。 + // 旧 stub 用的是自定义 {"type":"...","delta":"..."} 格式,relayEvents() 解析不到,输出为空。 @Override public InputStream openStream(OpenAiStreamRequest request) { return new ByteArrayInputStream(""" - data: {"type":"response.output_text.delta","delta":"hello"} + data: {"choices":[{"delta":{"content":"hello"}}]} - data: {"type":"response.completed"} + data: [DONE] """.getBytes(StandardCharsets.UTF_8)); } diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java index bc9c6ae..c53b57c 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -13,7 +13,36 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -@SpringBootTest +/** + * Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。 + * + * 为什么要在 @SpringBootTest 里显式覆盖数据源属性? + * Spring Boot 属性优先级:@SpringBootTest(properties) > 环境变量 > application-test.properties。 + * 测试服务器上存在 SPRING_DATASOURCE_URL 环境变量,指向生产 Neon PostgreSQL, + * 它的优先级高于 application-test.properties 里的 H2 配置, + * 导致测试启动时直接去连 PostgreSQL,H2 驱动拒绝 jdbc:postgresql:// 格式,上下文崩掉。 + * 把数据源写进 @SpringBootTest(properties) 就能盖过环境变量,保证测试始终跑 H2。 + * + * 为什么还要覆盖 JustAuth redirect-uri? + * JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri, + * 而 UrlValidator 默认不接受 localhost 域名。 + * application.properties 里默认是 http://localhost:3000/..., + * 不覆盖的话 OAuthController 初始化 AuthGithubRequest 时直接抛 AuthException, + * 所有 OAuth 相关测试都会 500。换成格式合法的占位 URL 就好了,测试里不会真的发请求。 + */ +@SpringBootTest(properties = { + // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2 内存库 + "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:test-schema.sql", + // JustAuth UrlValidator 不接受 localhost,用合法占位 URL 绕过 + "justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github", + "justauth.type.github.client-id=test-client-id", + "justauth.type.github.client-secret=test-client-secret" +}) @AutoConfigureMockMvc @ActiveProfiles("test") public abstract class AbstractWebIntegrationTest { @@ -22,7 +51,7 @@ public abstract class AbstractWebIntegrationTest { protected MockMvc mockMvc; /** - * 使用指定账号登录并提取 Sa-Token 值。 + * 使用指定账号登录并提取 Sa-Token 值,供子类测试方法携带 token 调用受保护接口。 */ protected String loginAndGetToken(String username, String password) throws Exception { MvcResult result = mockMvc.perform(post("/auth/login") @@ -40,23 +69,17 @@ protected String loginAndGetToken(String username, String password) throws Excep return JsonPath.read(result.getResponse().getContentAsString(), "$.data.tokenValue"); } - /** - * 以管理员身份登录。 - */ + /** 以管理员身份登录(拥有全部权限:user:profile:read, user:center:read, user:center:manage)。 */ protected String loginAsAdmin() throws Exception { return loginAndGetToken("admin", "Admin@123456"); } - /** - * 以普通用户身份登录。 - */ + /** 以普通用户身份登录(仅有 user:profile:read 权限,无法访问用户中心管理接口)。 */ protected String loginAsAlice() throws Exception { return loginAndGetToken("alice", "Alice@123456"); } - /** - * 以审计员身份登录。 - */ + /** 以审计员身份登录(拥有 user:profile:read, user:center:read,无 user:center:manage)。 */ protected String loginAsAuditor() throws Exception { return loginAndGetToken("auditor", "Audit@123456"); } diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java index e5232ef..600dc90 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java @@ -9,6 +9,15 @@ import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +/** + * AuthController 集成测试(账号密码登录、退出、当前用户查询)。 + * + * 旧测试断言的匿名请求错误消息是 "未登录或登录状态已失效",这是早期 GlobalExceptionHandler 的通用文案。 + * 现在 GlobalExceptionHandler 对 NotLoginException 按场景值细分: + * 完全没带 token 是 NOT_TOKEN 场景,对应 "未提供 Token"; + * token 格式非法是 INVALID_TOKEN,对应 "Token 无效";以此类推。 + * 匿名请求属于 NOT_TOKEN,所以正确消息是 "未提供 Token"。 + */ class AuthControllerIntegrationTests extends AbstractWebIntegrationTest { @Test @@ -70,12 +79,13 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception { .andExpect(jsonPath("$.data.permissions[0]").isNotEmpty()); } + // 未带 token 是 NOT_TOKEN 场景,GlobalExceptionHandler 返回 "未提供 Token" @Test void meRejectsAnonymousRequest() throws Exception { mockMvc.perform(get("/auth/me")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("未登录或登录状态已失效")); + .andExpect(jsonPath("$.message").value("未提供 Token")); } @Test @@ -87,16 +97,18 @@ void logoutSucceedsAndMakesTokenInvalid() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("退出成功")); + // 退出后原 token 应失效,再次访问 /me 返回 401 mockMvc.perform(get("/auth/me").header("satoken", token)) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)); } + // 同上,匿名 logout 也是 NOT_TOKEN 场景 @Test void logoutRejectsAnonymousRequest() throws Exception { mockMvc.perform(post("/auth/logout")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("未登录或登录状态已失效")); + .andExpect(jsonPath("$.message").value("未提供 Token")); } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java new file mode 100644 index 0000000..53baa4f --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java @@ -0,0 +1,95 @@ +package com.involutionhell.backend.usercenter.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +/** + * OAuthController 集成测试。 + * + * 设计说明: + * OAuthController 在内部通过 @Value 属性直接 new AuthGithubRequest(), + * 无法通过依赖注入替换 JustAuth 的 AuthRequest 实现。 + * 因此本测试仅覆盖以下可验证的行为: + * 1. renderAuth —— JustAuth 在本地构建授权 URL,无实际 HTTP 调用,可直接验证 302 重定向。 + * 2. callback 失败路径 —— 携带无效 state/code 时,JustAuth 返回失败响应, + * 控制器重定向至前端错误页。 + * + * callback 成功路径(需要真实 GitHub code + state)超出集成测试范围, + * 该路径的业务逻辑已由 AuthServiceTests.loginByGithub*() 系列单元测试覆盖。 + */ +class OAuthControllerIntegrationTests extends AbstractWebIntegrationTest { + + // ============================================= + // GET /oauth/render/github — 发起授权跳转 + // ============================================= + + @Test + void renderAuthRedirectsToGitHubAuthorizationUrl() throws Exception { + MvcResult result = mockMvc.perform(get("/oauth/render/github")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location) + .as("授权重定向地址应指向 GitHub OAuth 授权端点") + .isNotNull() + .contains("github.com/login/oauth/authorize") + // 应携带测试环境配置的 dummy client_id + .contains("client_id="); + } + + @Test + void renderAuthIncludesRedirectUriInAuthorizationUrl() throws Exception { + MvcResult result = mockMvc.perform(get("/oauth/render/github")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + // 授权 URL 必须携带 redirect_uri,否则 GitHub 会拒绝 + assertThat(location) + .as("授权 URL 必须携带 redirect_uri 参数") + .contains("redirect_uri"); + } + + // ============================================= + // GET /api/auth/callback/github — OAuth 回调 + // ============================================= + + @Test + void callbackRedirectsToFrontendErrorPageWhenOAuthFails() throws Exception { + // 不携带合法的 code 和 state,JustAuth 会返回失败响应 + // 控制器应将其重定向至前端错误页(/login?error=oauth_failed) + MvcResult result = mockMvc.perform( + get("/api/auth/callback/github") + .param("code", "invalid-code") + .param("state", "invalid-state")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location) + .as("OAuth 失败时应重定向至前端错误页") + .isNotNull() + .endsWith("/login?error=oauth_failed"); + } + + @Test + void callbackWithoutParametersRedirectsToFrontendErrorPage() throws Exception { + // 完全不携带任何参数,模拟用户直接访问回调地址 + MvcResult result = mockMvc.perform(get("/api/auth/callback/github")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location) + .as("无参数请求时应重定向至前端错误页") + .isNotNull() + .endsWith("/login?error=oauth_failed"); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java index 718b851..620aad9 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java @@ -10,13 +10,30 @@ import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; +/** + * UserCenterController + AuthController(/auth/me)集成测试。 + * + * 旧测试用的 URL 是 /api/user-center/profile、/api/user-center/users 等, + * 这些路径在重构中已经删掉了(服务以前有全局 context-path,后来去掉了,前缀 /api 也跟着没了)。 + * 现在的实际路由是:GET /auth/me、GET /users、GET /users/{id}、PUT /users/{id}/authorization。 + * 旧路径访问时 Spring 抛 NoResourceFoundException,被 GlobalExceptionHandler 兜底返回 500, + * 所以所有测试都失败了。把 URL 改对就好了。 + * + * 权限错误消息也变了:旧测试期望 "无权限访问: user:center:read", + * 但 GlobalExceptionHandler 实际输出是 "拒绝访问: 缺少权限 [user:center:read]",对齐即可。 + * + * @SaCheckPermission 之前一直 403 的原因:项目缺少 StpInterface 实现, + * Sa-Token 找不到实现 Bean 就用空列表兜底,所有权限校验必然失败。 + * 新增 SaTokenPermissionImpl 后才真正把权限数据接进来。 + */ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest { + // 旧测试访问的 /api/user-center/profile 已不存在,现在是 /auth/me @Test void profileReturnsCurrentUserForAuthorizedUser() throws Exception { String token = loginAsAlice(); - mockMvc.perform(get("/api/user-center/profile").header("satoken", token)) + mockMvc.perform(get("/auth/me").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.username").value("alice")); @@ -26,55 +43,63 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception { void usersListReturnsAllUsersForAdmin() throws Exception { String token = loginAsAdmin(); - mockMvc.perform(get("/api/user-center/users").header("satoken", token)) + mockMvc.perform(get("/users").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.length()").value(3)); } + // alice 只有 user:profile:read,没有 user:center:read,访问 /users 会被拦截 + // 旧测试期望的消息 "无权限访问: ..." 和 GlobalExceptionHandler 实际输出不一致,已修正 @Test void usersListRejectsUserWithoutReadPermission() throws Exception { String token = loginAsAlice(); - mockMvc.perform(get("/api/user-center/users").header("satoken", token)) + mockMvc.perform(get("/users").header("satoken", token)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("无权限访问: user:center:read")); + .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]")); } @Test void getUserReturnsRequestedUserForAuditor() throws Exception { String token = loginAsAuditor(); - mockMvc.perform(get("/api/user-center/users/2").header("satoken", token)) + mockMvc.perform(get("/users/2").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.username").value("alice")); } + /** 匿名访问 /users/1 触发 NOT_TOKEN,返回 401。 */ @Test void getUserRejectsAnonymousRequest() throws Exception { - mockMvc.perform(get("/api/user-center/users/1")) + mockMvc.perform(get("/users/1")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)); } + /** + * 用户不存在时,UserCenterService 抛出 IllegalArgumentException, + * GlobalExceptionHandler 将其映射为 400 BAD_REQUEST。 + */ @Test void getUserReturnsBusinessErrorWhenUserMissing() throws Exception { String token = loginAsAdmin(); - mockMvc.perform(get("/api/user-center/users/999").header("satoken", token)) + mockMvc.perform(get("/users/999").header("satoken", token)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.message").value("用户不存在: 999")); } + // @DirtiesContext 确保本测试对 alice 的修改不会污染其他测试的预期数据 @Test @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) void updateAuthorizationAllowsAdmin() throws Exception { String token = loginAsAdmin(); - mockMvc.perform(put("/api/user-center/users/2/authorization") + mockMvc.perform(put("/users/2/authorization") .header("satoken", token) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -89,15 +114,17 @@ void updateAuthorizationAllowsAdmin() throws Exception { .andExpect(jsonPath("$.data.roles.length()").value(2)) .andExpect(jsonPath("$.data.permissions.length()").value(2)); - mockMvc.perform(get("/api/user-center/users/2").header("satoken", token)) + // 二次查询,验证数据库已持久化 + mockMvc.perform(get("/users/2").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.roles.length()").value(2)) .andExpect(jsonPath("$.data.permissions.length()").value(2)); } + /** 匿名 PUT 请求触发 NOT_TOKEN,返回 401。 */ @Test void updateAuthorizationRejectsAnonymousRequest() throws Exception { - mockMvc.perform(put("/api/user-center/users/2/authorization") + mockMvc.perform(put("/users/2/authorization") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -113,7 +140,7 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception { void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception { String token = loginAsAlice(); - mockMvc.perform(put("/api/user-center/users/2/authorization") + mockMvc.perform(put("/users/2/authorization") .header("satoken", token) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -124,6 +151,6 @@ void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception { """)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("无权限访问: user:center:manage")); + .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:manage]")); } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java new file mode 100644 index 0000000..3961acb --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java @@ -0,0 +1,208 @@ +package com.involutionhell.backend.usercenter.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.involutionhell.backend.usercenter.model.UserAccount; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * JdbcUserAccountRepository 集成测试。 + * 使用 H2 内存库(PostgreSQL MODE),种子数据:admin(id=1)、alice(id=2)、auditor(id=3)。 + * 每个 @Test 都在事务中执行并自动回滚,保证测试间互不干扰。 + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:test-schema.sql" +}) +@ActiveProfiles("test") +@Transactional +class JdbcUserAccountRepositoryTests { + + @Autowired + private JdbcUserAccountRepository repository; + + // ============================================= + // findById + // ============================================= + + @Test + void findByIdReturnsUserWhenExists() { + Optional result = repository.findById(1L); + + assertThat(result).isPresent(); + assertThat(result.get().username()).isEqualTo("admin"); + assertThat(result.get().enabled()).isTrue(); + assertThat(result.get().roles()).contains("admin"); + } + + @Test + void findByIdReturnsEmptyWhenUserMissing() { + Optional result = repository.findById(999L); + + assertThat(result).isEmpty(); + } + + // ============================================= + // findByUsername + // ============================================= + + @Test + void findByUsernameReturnsUserWhenExists() { + Optional result = repository.findByUsername("alice"); + + assertThat(result).isPresent(); + assertThat(result.get().id()).isEqualTo(2L); + assertThat(result.get().displayName()).isEqualTo("Alice"); + // alice 没有 github_id,应为 null + assertThat(result.get().githubId()).isNull(); + } + + @Test + void findByUsernameReturnsEmptyWhenUserMissing() { + Optional result = repository.findByUsername("nobody"); + + assertThat(result).isEmpty(); + } + + // ============================================= + // findAll + // ============================================= + + @Test + void findAllReturnsSeedUsersOrderedById() { + List all = repository.findAll(); + + // 种子数据:admin、alice、auditor + assertThat(all).hasSize(3); + assertThat(all.get(0).username()).isEqualTo("admin"); + assertThat(all.get(1).username()).isEqualTo("alice"); + assertThat(all.get(2).username()).isEqualTo("auditor"); + } + + // ============================================= + // insert + // ============================================= + + @Test + void insertCreatesUserAndReturnsWithGeneratedId() { + UserAccount toInsert = new UserAccount( + null, "newuser", "hash-value", "新用户", + true, Set.of("user"), Set.of("user:profile:read"), + "https://avatar.example.com", "newuser@example.com", 99999L + ); + + UserAccount saved = repository.insert(toInsert); + + assertThat(saved.id()).isNotNull().isPositive(); + assertThat(saved.username()).isEqualTo("newuser"); + assertThat(saved.displayName()).isEqualTo("新用户"); + assertThat(saved.enabled()).isTrue(); + assertThat(saved.roles()).containsExactly("user"); + assertThat(saved.permissions()).containsExactly("user:profile:read"); + assertThat(saved.avatarUrl()).isEqualTo("https://avatar.example.com"); + assertThat(saved.email()).isEqualTo("newuser@example.com"); + assertThat(saved.githubId()).isEqualTo(99999L); + } + + @Test + void insertHandlesNullGithubIdAndEmail() { + // GitHub 用户邮箱可能设为私密(null),github_id 也可能无法解析 + UserAccount toInsert = new UserAccount( + null, "github_user", "random-hash", "GitHub 用户", + true, Set.of("user"), Set.of(), + null, null, null + ); + + UserAccount saved = repository.insert(toInsert); + + assertThat(saved.id()).isNotNull(); + assertThat(saved.username()).isEqualTo("github_user"); + assertThat(saved.githubId()).isNull(); + assertThat(saved.email()).isNull(); + assertThat(saved.avatarUrl()).isNull(); + } + + @Test + void insertPersistsEmptyRolesAsEmptySet() { + UserAccount toInsert = new UserAccount( + null, "norole_user", "hash", "无角色用户", + true, Set.of(), Set.of(), null, null, null + ); + + UserAccount saved = repository.insert(toInsert); + + assertThat(saved.roles()).isEmpty(); + assertThat(saved.permissions()).isEmpty(); + } + + // ============================================= + // updateAuthorization + // ============================================= + + @Test + void updateAuthorizationChangesRolesAndPermissions() { + Set newRoles = Set.of("editor", "reviewer"); + Set newPermissions = Set.of("user:profile:read", "user:center:read"); + + UserAccount updated = repository.updateAuthorization(2L, newRoles, newPermissions); + + assertThat(updated.username()).isEqualTo("alice"); + assertThat(updated.roles()).containsExactlyInAnyOrder("editor", "reviewer"); + assertThat(updated.permissions()).containsExactlyInAnyOrder("user:profile:read", "user:center:read"); + } + + @Test + void updateAuthorizationThrowsWhenUserMissing() { + assertThatThrownBy(() -> repository.updateAuthorization(999L, Set.of("user"), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("用户不存在: 999"); + } + + // ============================================= + // updateProfile + // ============================================= + + @Test + void updateProfileUpdatesGithubFields() { + UserAccount updated = repository.updateProfile( + 2L, "Alice Updated", "https://new-avatar.com", "alice@github.com", 12345L + ); + + assertThat(updated.username()).isEqualTo("alice"); + assertThat(updated.displayName()).isEqualTo("Alice Updated"); + assertThat(updated.avatarUrl()).isEqualTo("https://new-avatar.com"); + assertThat(updated.email()).isEqualTo("alice@github.com"); + assertThat(updated.githubId()).isEqualTo(12345L); + } + + @Test + void updateProfileHandlesNullEmailAndGithubId() { + // 邮箱私密、uuid 无法解析 → 允许 null + UserAccount updated = repository.updateProfile(2L, "Alice", null, null, null); + + assertThat(updated.username()).isEqualTo("alice"); + assertThat(updated.displayName()).isEqualTo("Alice"); + assertThat(updated.avatarUrl()).isNull(); + assertThat(updated.email()).isNull(); + assertThat(updated.githubId()).isNull(); + } + + @Test + void updateProfileThrowsWhenUserMissing() { + assertThatThrownBy(() -> repository.updateProfile(999L, "Name", null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("用户不存在: 999"); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java new file mode 100644 index 0000000..d8509ea --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -0,0 +1,299 @@ +package com.involutionhell.backend.usercenter.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.dev33.satoken.stp.StpUtil; +import com.involutionhell.backend.usercenter.dto.LoginRequest; +import com.involutionhell.backend.usercenter.dto.LoginResponse; +import com.involutionhell.backend.usercenter.dto.UserView; +import com.involutionhell.backend.usercenter.model.UserAccount; +import java.util.Optional; +import java.util.Set; +import me.zhyd.oauth.model.AuthUser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * AuthService 单元测试。 + * StpUtil 是静态工具类,使用 Mockito.mockStatic 进行隔离,避免依赖 Sa-Token 容器。 + */ +@ExtendWith(MockitoExtension.class) +class AuthServiceTests { + + @Mock + private UserCenterService userCenterService; + + @Mock + private PasswordService passwordService; + + @InjectMocks + private AuthService authService; + + // ============================================= + // 辅助方法 + // ============================================= + + /** 创建一个已启用的标准用户。 */ + private UserAccount enabledUser(Long id, String username, String passwordHash) { + return new UserAccount(id, username, passwordHash, "显示名称", true, + Set.of("user"), Set.of("user:profile:read"), null, null, null); + } + + /** 创建一个已停用的用户。 */ + private UserAccount disabledUser(Long id, String username) { + return new UserAccount(id, username, "hash", "显示名称", false, + Set.of("user"), Set.of(), null, null, null); + } + + /** + * 创建 AuthUser Mock,模拟 JustAuth 返回的 GitHub 用户信息。 + * nickname 为 null 时 AuthService 回退使用 username 字段。 + */ + private AuthUser githubUser(String uuid, String nickname, String avatar, String email) { + AuthUser user = mock(AuthUser.class); + when(user.getUuid()).thenReturn(uuid); + when(user.getNickname()).thenReturn(nickname); + if (nickname == null) { + when(user.getUsername()).thenReturn("github-login-" + uuid); + } + when(user.getAvatar()).thenReturn(avatar); + when(user.getEmail()).thenReturn(email); + return user; + } + + // ============================================= + // login() - 账号密码登录 + // ============================================= + + @Test + void loginSucceedsWithCorrectCredentials() { + UserAccount account = enabledUser(1L, "alice", "correct-hash"); + when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(account)); + when(passwordService.matches("Alice@123", "correct-hash")).thenReturn(true); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token-abc"); + + LoginResponse response = authService.login(new LoginRequest("alice", "Alice@123")); + + // 验证 Sa-Token 登录以正确的用户 ID 被调用 + stpUtil.verify(() -> StpUtil.login(1L)); + assertThat(response.tokenName()).isEqualTo("satoken"); + assertThat(response.tokenValue()).isEqualTo("token-abc"); + assertThat(response.user().username()).isEqualTo("alice"); + } + } + + @Test + void loginThrowsWhenUsernameDoesNotExist() { + when(userCenterService.findByUsername("nobody")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.login(new LoginRequest("nobody", "pass"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("用户名或密码错误"); + } + + @Test + void loginThrowsWhenAccountIsDisabled() { + when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(disabledUser(2L, "alice"))); + + assertThatThrownBy(() -> authService.login(new LoginRequest("alice", "Alice@123"))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("账号已被禁用"); + } + + @Test + void loginThrowsWhenPasswordIsWrong() { + UserAccount account = enabledUser(1L, "alice", "correct-hash"); + when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(account)); + when(passwordService.matches("wrong-pass", "correct-hash")).thenReturn(false); + + assertThatThrownBy(() -> authService.login(new LoginRequest("alice", "wrong-pass"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("用户名或密码错误"); + } + + // ============================================= + // loginByGithub() - GitHub OAuth 登录 + // ============================================= + + @Test + void loginByGithubAutoRegistersNewUser() { + // GitHub UUID 为纯数字,可被解析为 Long + AuthUser ghUser = githubUser("12345", "GitHubNick", "https://avatar.url", "user@github.com"); + + UserAccount createdAccount = enabledUser(10L, "github_12345", "random-hash"); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("random-hash"); + when(userCenterService.createUser(any())).thenReturn(createdAccount); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); + + LoginResponse response = authService.loginByGithub(ghUser); + + // 应调用 createUser 而非 updateProfile + verify(userCenterService).createUser(any()); + assertThat(response.user().username()).isEqualTo("github_12345"); + } + } + + @Test + void loginByGithubSetsCorrectGithubIdOnNewUser() { + AuthUser ghUser = githubUser("99999", "Nick", null, null); + + when(userCenterService.findByUsername("github_99999")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenAnswer(inv -> { + UserAccount arg = inv.getArgument(0); + // 验证 githubId 被正确解析为 Long + assertThat(arg.githubId()).isEqualTo(99999L); + return enabledUser(10L, "github_99999", "hash"); + }); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); + authService.loginByGithub(ghUser); + } + } + + @Test + void loginByGithubSetsNullGithubIdWhenUuidIsNotNumeric() { + // GitHub UUID 非纯数字(极罕见,但代码中有 try-catch 处理) + AuthUser ghUser = githubUser("not-a-number", "Nick", null, null); + + when(userCenterService.findByUsername("github_not-a-number")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenAnswer(inv -> { + UserAccount arg = inv.getArgument(0); + // UUID 无法解析为数字时,githubId 应为 null + assertThat(arg.githubId()).isNull(); + return enabledUser(10L, "github_not-a-number", "hash"); + }); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); + authService.loginByGithub(ghUser); + } + } + + @Test + void loginByGithubUsesUsernameAsFallbackWhenNicknameIsNull() { + // GitHub 用户昵称为 null 时,回退使用 username 字段作为 displayName + AuthUser ghUser = githubUser("12345", null, null, null); + + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenAnswer(inv -> { + UserAccount arg = inv.getArgument(0); + // displayName 应来自 getUsername(),即 "github-login-12345" + assertThat(arg.displayName()).isEqualTo("github-login-12345"); + return enabledUser(10L, "github_12345", "hash"); + }); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); + authService.loginByGithub(ghUser); + } + } + + @Test + void loginByGithubUpdatesProfileWhenUserAlreadyExists() { + AuthUser ghUser = githubUser("12345", "UpdatedNick", "https://new-avatar.url", "new@github.com"); + + UserAccount existing = enabledUser(10L, "github_12345", "hash"); + UserAccount afterUpdate = new UserAccount( + 10L, "github_12345", "hash", "UpdatedNick", true, + Set.of("user"), Set.of("user:profile:read"), + "https://new-avatar.url", "new@github.com", 12345L + ); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(existing)); + when(userCenterService.updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L)) + .thenReturn(afterUpdate); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); + + LoginResponse response = authService.loginByGithub(ghUser); + + // 已有用户应走 updateProfile 而非 createUser + verify(userCenterService).updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L); + assertThat(response.user().displayName()).isEqualTo("UpdatedNick"); + assertThat(response.user().githubId()).isEqualTo(12345L); + } + } + + @Test + void loginByGithubThrowsWhenExistingAccountIsDisabled() { + AuthUser ghUser = githubUser("12345", "Nick", null, null); + + UserAccount disabledAccount = disabledUser(10L, "github_12345"); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(disabledAccount)); + // updateProfile 仍会被调用(刷新资料),但返回的账号仍是禁用状态 + when(userCenterService.updateProfile(any(), any(), any(), any(), any())) + .thenReturn(disabledAccount); + + assertThatThrownBy(() -> authService.loginByGithub(ghUser)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("账号已被禁用"); + } + + @Test + void loginByGithubThrowsWhenNewlyRegisteredAccountIsDisabled() { + // 理论上不会发生(新注册账号默认启用),但防御性测试 + AuthUser ghUser = githubUser("12345", "Nick", null, null); + + UserAccount disabledAccount = disabledUser(10L, "github_12345"); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenReturn(disabledAccount); + + assertThatThrownBy(() -> authService.loginByGithub(ghUser)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("账号已被禁用"); + } + + // ============================================= + // logout() 和 currentUser() + // ============================================= + + @Test + void logoutInvalidatesCurrentSession() { + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + authService.logout(); + + // 验证 Sa-Token 的 logout 方法被正确调用 + stpUtil.verify(StpUtil::logout); + } + } + + @Test + void currentUserDelegatesToUserCenterService() { + UserView expectedView = new UserView( + 1L, "alice", "Alice", true, + Set.of("user"), Set.of("user:profile:read"), + null, null, null + ); + when(userCenterService.currentUser()).thenReturn(expectedView); + + UserView result = authService.currentUser(); + + assertThat(result).isSameAs(expectedView); + } +} diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index acb88ae..aea8bca 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -6,7 +6,10 @@ CREATE TABLE IF NOT EXISTS user_accounts ( display_name VARCHAR(255), enabled BOOLEAN NOT NULL DEFAULT TRUE, roles TEXT NOT NULL DEFAULT '', - permissions TEXT NOT NULL DEFAULT '' + permissions TEXT NOT NULL DEFAULT '', + avatar_url VARCHAR(500), + email VARCHAR(255), + github_id BIGINT UNIQUE ); -- 种子账号(与生产保持一致)逐行插入,H2 兼容写法