From 749c21cb1c0797e2ff2cb24c33c157ce7ead7695 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 20:39:25 +0000 Subject: [PATCH 1/3] test: improve test coverage and fix pre-existing test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New test suites: - JdbcUserAccountRepositoryTests: 13 tests covering all CRUD operations, null handling, and error paths with H2 in-memory database - AuthServiceTests: 13 unit tests for login/loginByGithub/logout/currentUser using Mockito with mockStatic for Sa-Token isolation - OAuthControllerIntegrationTests: 4 tests covering GitHub OAuth redirect URL validation and callback failure paths Infrastructure fixes: - AbstractWebIntegrationTest: add @SpringBootTest(properties) override to prevent SPRING_DATASOURCE_URL env var from overriding H2 test config - BackendApplicationTests: same H2 override fix for context load test - test-schema.sql: add missing avatar_url, email, github_id columns that JdbcUserAccountRepository.ROW_MAPPER reads Production bug fixes revealed by tests: - SaTokenPermissionImpl: implement StpInterface so @SaCheckPermission annotations can resolve permissions from the database (was always 403) - GlobalExceptionHandler: use e.getPermission() instead of e.getCode() in handleNotPermissionException (getCode() returned -1, not the permission) Pre-existing test fixes: - AuthControllerIntegrationTests: update expected Sa-Token error messages from old generic message to current specific messages ("未提供 Token") - UserCenterControllerIntegrationTests: fix stale URL paths (/users instead of /api/user-center/users) and update expected permission error messages - OpenAiStreamControllerIntegrationTests: update for renamed DTO field (message -> messages), fix asyncDispatch pattern for StreamingResponseBody, and align stub gateway with OpenAI API format expected by relayEvents() Result: 78/78 tests pass (was 40/78 with 36 errors + 2 failures baseline) https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk --- .../common/config/SaTokenPermissionImpl.java | 35 ++ .../common/error/GlobalExceptionHandler.java | 2 +- .../backend/BackendApplicationTests.java | 14 +- ...penAiStreamControllerIntegrationTests.java | 52 ++- .../support/AbstractWebIntegrationTest.java | 16 +- .../AuthControllerIntegrationTests.java | 4 +- .../OAuthControllerIntegrationTests.java | 95 ++++++ .../UserCenterControllerIntegrationTests.java | 24 +- .../JdbcUserAccountRepositoryTests.java | 208 ++++++++++++ .../usercenter/service/AuthServiceTests.java | 299 ++++++++++++++++++ src/test/resources/test-schema.sql | 5 +- 11 files changed, 704 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java 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..e42409e --- /dev/null +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java @@ -0,0 +1,35 @@ +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 权限与角色加载实现。 + * 根据登录用户 ID 从数据库加载其权限集合与角色集合, + * 供 {@code @SaCheckPermission} / {@code @SaCheckRole} 等注解使用。 + */ +@Component +public class SaTokenPermissionImpl implements StpInterface { + + private final UserAccountRepository userAccountRepository; + + public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) { + this.userAccountRepository = userAccountRepository; + } + + @Override + public List getPermissionList(Object loginId, String loginType) { + return userAccountRepository.findById(Long.valueOf(loginId.toString())) + .map(account -> List.copyOf(account.permissions())) + .orElse(List.of()); + } + + @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..799f306 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -46,7 +46,7 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti @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..3595995 100644 --- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java +++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java @@ -4,7 +4,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest +// 显式覆盖数据源配置,防止 SPRING_DATASOURCE_URL 环境变量(指向生产 PostgreSQL) +// 优先于 application-test.properties,导致上下文加载失败。 +@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", + "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 { 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..d142597 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -1,24 +1,24 @@ 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; @Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class) @@ -27,23 +27,23 @@ class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest @Test void streamReturnsSseEventsForAuthenticatedUser() throws Exception { String token = loginAsAdmin(); + // StreamingResponseBody 采用异步派发:先获取 MvcResult,再通过 asyncDispatch 触发真实写入 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() 将 OpenAI choices[0].delta.content 转换为 Vercel Stream 格式 "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 +52,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,25 +69,12 @@ 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 @@ -113,14 +100,15 @@ public void validateConfiguration(OpenAiStreamRequest request) { } /** - * 返回固定的 SSE 事件流,供控制器测试验证输出格式。 + * 返回固定的 OpenAI SSE 格式事件流(与真实 API 格式保持一致)。 + * relayEvents() 提取 choices[0].delta.content 写入 Vercel Stream 格式。 */ @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..9bafcc6 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -13,7 +13,21 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -@SpringBootTest +// 显式指定 H2 内存库配置,优先级高于环境变量(如 SPRING_DATASOURCE_URL), +// 确保集成测试始终使用 H2 而非生产 PostgreSQL 连接。 +@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", + // JustAuth 使用 Apache Commons UrlValidator 校验 redirect-uri,默认拒绝 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 { 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..621903f 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java @@ -75,7 +75,7 @@ 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 @@ -97,6 +97,6 @@ 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..bcde63e 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java @@ -16,7 +16,7 @@ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest { 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,7 +26,7 @@ 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)); @@ -36,17 +36,17 @@ void usersListReturnsAllUsersForAdmin() throws Exception { 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")); @@ -54,7 +54,7 @@ void getUserReturnsRequestedUserForAuditor() throws Exception { @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)); } @@ -63,7 +63,7 @@ void getUserRejectsAnonymousRequest() throws Exception { 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")); @@ -74,7 +74,7 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception { 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,7 +89,7 @@ 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)); @@ -97,7 +97,7 @@ void updateAuthorizationAllowsAdmin() throws Exception { @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 +113,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 +124,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 兼容写法 From 1ed1c5cea9512bf487ff36917e5fc5fa4da29a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 00:41:13 +0000 Subject: [PATCH 2/3] fix: resolve 36 test failures and add detailed explanatory comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes and fixes: 1. SaTokenPermissionImpl (NEW) - Without StpInterface, Sa-Token returns empty permissions for all users, causing every @SaCheckPermission check to unconditionally fail with 403. - loginId must be parsed via toString() first: Sa-Token serializes it as String internally even when StpUtil.login(Long) was called. 2. GlobalExceptionHandler - NotPermissionException.getCode() returns the integer scene code (-1), not the permission string. Fixed to getPermission() which returns the actual missing permission name (e.g. "user:center:read"). 3. AbstractWebIntegrationTest + BackendApplicationTests - SPRING_DATASOURCE_URL env var (pointing to Neon PostgreSQL) has higher Spring priority than application-test.properties, causing H2 context load failure. Fixed via @SpringBootTest(properties) which overrides all env vars. - JustAuth UrlValidator rejects localhost redirect URIs; overridden with a syntactically valid placeholder URL. 4. AuthControllerIntegrationTests - Sa-Token NOT_TOKEN scenario now maps to "未提供 Token", not the old generic "未登录或登录状态已失效" message. 5. UserCenterControllerIntegrationTests - All URLs updated: /api/user-center/* paths were removed; current routes are /auth/me, /users, /users/{id}, /users/{id}/authorization. - Permission error message updated to match GlobalExceptionHandler output: "拒绝访问: 缺少权限 []". 6. OpenAiStreamControllerIntegrationTests - DTO field renamed: message (String) -> messages (List), aligning with Vercel AI SDK payload format. - Async test uses asyncDispatch two-step pattern (required for StreamingResponseBody); polling getContentAsString() never sees data. - Stub returns OpenAI SSE format choices[0].delta.content so relayEvents() can extract the text and emit Vercel Stream prefix "0:". All 78 tests now pass. https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk --- .../common/config/SaTokenPermissionImpl.java | 35 ++++++++++- .../common/error/GlobalExceptionHandler.java | 10 ++- .../backend/BackendApplicationTests.java | 24 ++++++- ...penAiStreamControllerIntegrationTests.java | 63 ++++++++++++++++--- .../support/AbstractWebIntegrationTest.java | 44 ++++++++----- .../AuthControllerIntegrationTests.java | 29 +++++++++ .../UserCenterControllerIntegrationTests.java | 59 +++++++++++++++++ 7 files changed, 236 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java index e42409e..49f286b 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java @@ -7,8 +7,25 @@ /** * Sa-Token 权限与角色加载实现。 - * 根据登录用户 ID 从数据库加载其权限集合与角色集合, - * 供 {@code @SaCheckPermission} / {@code @SaCheckRole} 等注解使用。 + * + *

为什么需要这个类?

+ *

Sa-Token 的 {@code @SaCheckPermission} / {@code @SaCheckRole} 注解在执行权限校验时, + * 会调用 {@link StpInterface#getPermissionList} / {@link StpInterface#getRoleList} + * 来获取当前登录用户的权限集合与角色集合。

+ * + *

在此类被添加之前,项目中缺少 {@code StpInterface} 的实现 Bean, + * Sa-Token 回退使用默认的空列表实现,导致所有 {@code @SaCheckPermission} 检查 + * 无论用户实际持有什么权限,一律抛出 {@code NotPermissionException}(HTTP 403)。 + * 这是一个生产代码 Bug:权限体系在数据库层面已设计完备,但因缺少加载桥梁而完全失效。

+ * + *

实现逻辑

+ *

以登录时写入 Sa-Token Session 的用户 ID 为键,从 {@code user_accounts} 表加载 + * {@code permissions} 与 {@code roles} 列(逗号分隔字符串已由 Repository 层解析为 Set)。

+ * + *

loginId 类型说明

+ *

Sa-Token 在内部将登录 ID 序列化为 {@code String} 存储,因此 + * {@code loginId} 参数的运行时类型是 {@code String},而非调用 {@code StpUtil.login(Long)} + * 时传入的 {@code Long}。故此处需要通过 {@code Long.valueOf(loginId.toString())} 转换。

*/ @Component public class SaTokenPermissionImpl implements StpInterface { @@ -19,13 +36,27 @@ public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) { this.userAccountRepository = userAccountRepository; } + /** + * 返回指定用户拥有的权限码列表。 + * + *

Sa-Token 每次执行 {@code @SaCheckPermission("user:xxx")} 时都会调用此方法, + * 将返回值与注解中声明的权限码对比,若不包含则抛出 {@code NotPermissionException}。

+ * + * @param loginId 登录 ID,运行时实际类型为 String(Sa-Token 内部序列化结果) + * @param loginType 登录类型,单端场景下为 "login",此处忽略 + */ @Override public List getPermissionList(Object loginId, String loginType) { + // loginId 由 Sa-Token 以 String 形式回传,需先 toString() 再解析为 Long return userAccountRepository.findById(Long.valueOf(loginId.toString())) .map(account -> List.copyOf(account.permissions())) .orElse(List.of()); } + /** + * 返回指定用户拥有的角色标识列表,供 {@code @SaCheckRole} 使用。 + * 逻辑与 {@link #getPermissionList} 完全对称,仅字段来源不同。 + */ @Override public List getRoleList(Object loginId, String loginType) { return userAccountRepository.findById(Long.valueOf(loginId.toString())) 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 799f306..6f75754 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -41,7 +41,15 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti } /** - * Sa-Token: 拦截权限不足异常 + * Sa-Token: 拦截权限不足异常。 + * + *

为什么用 {@code e.getPermission()} 而不是 {@code e.getCode()}?
+ * {@code NotPermissionException} 继承自 {@code SaTokenException},父类的 {@code getCode()} + * 返回的是异常场景码(整数,如 -1),表示"哪种类型的 Sa-Token 异常",而非权限字符串本身。 + * 权限字符串(如 {@code "user:center:read"})存储在 {@code NotPermissionException} + * 自身的 {@code permission} 字段中,须通过 {@code getPermission()} 获取。 + * 若误用 {@code getCode()},错误消息将显示为 "拒绝访问: 缺少权限 [-1]", + * 对调用方毫无诊断价值。

*/ @ExceptionHandler(NotPermissionException.class) public ResponseEntity> handleNotPermissionException(NotPermissionException e) { diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java index 3595995..561f21e 100644 --- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java +++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java @@ -4,15 +4,33 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -// 显式覆盖数据源配置,防止 SPRING_DATASOURCE_URL 环境变量(指向生产 PostgreSQL) -// 优先于 application-test.properties,导致上下文加载失败。 +/** + * Spring Boot 上下文加载冒烟测试。 + * + *

为什么需要在这里覆盖数据源属性?

+ *

原始代码使用裸 {@code @SpringBootTest},未覆盖任何属性。 + * 当测试服务器设置了 {@code SPRING_DATASOURCE_URL} 环境变量(指向生产 Neon PostgreSQL)时, + * 环境变量的优先级高于 {@code application-test.properties}, + * Spring 会尝试用 PostgreSQL 驱动连接该 URL,而 H2 驱动拒绝 {@code jdbc:postgresql://} 格式, + * 导致上下文启动失败,报错: + * {@code Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://...}。

+ * + *

{@code @SpringBootTest(properties)} 的优先级高于一切外部环境变量, + * 可确保本测试始终在 H2 内存库上运行,与生产数据库完全隔离。

+ * + *

同理,也覆盖了 JustAuth 的 redirect-uri:JustAuth 使用 Apache Commons + * {@code UrlValidator} 在 {@link me.zhyd.oauth.request.AuthGithubRequest} 初始化时 + * 校验 redirect-uri,默认拒绝 localhost,故使用格式合法的占位 URL。

+ */ @SpringBootTest(properties = { + // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2,详见类 Javadoc "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" @@ -21,7 +39,7 @@ class BackendApplicationTests { /** - * 验证 Spring Boot 测试上下文可以正常启动。 + * 验证 Spring Boot 测试上下文可以正常启动(所有 Bean 可注入、数据源可连接)。 */ @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 d142597..bcbcc3c 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -21,13 +21,47 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; +/** + * OpenAiStreamController 集成测试。 + * + *

三处改动说明

+ * + *

1. 请求体字段:message → messages

+ *

{@link OpenAiStreamRequest} DTO 已从单条字符串字段 {@code message} 重构为 + * 多轮对话列表字段 {@code messages},以对齐 Vercel AI SDK 的 payload 格式。 + * 旧版测试发送 {@code {"message":"..."}},服务端将 {@code messages} 视为 null/空, + * 触发 {@code @NotEmpty} 校验失败(400),而非进入 SSE 处理流程。

+ * + *

2. 匿名请求错误消息:"未登录..." → "未提供 Token"

+ *

与 AuthControllerIntegrationTests 同理,未携带 token 属于 Sa-Token {@code NOT_TOKEN} + * 场景,GlobalExceptionHandler 的当前输出是 "未提供 Token", + * 旧版通用消息已过时。

+ * + *

3. StreamingResponseBody 的 MockMvc 测试方式:asyncDispatch 模式

+ *

控制器返回 {@link org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody}, + * Spring 将实际写入操作分派到异步线程。MockMvc 对此类异步响应的正确测试步骤是: + *

    + *
  1. 调用 {@code mockMvc.perform(...).andExpect(request().asyncStarted()).andReturn()} + * 触发异步处理,获取 {@code MvcResult};
  2. + *
  3. 再调用 {@code mockMvc.perform(asyncDispatch(mvcResult))} 完成派发, + * 此时响应体才真正写入,可对 status / content 做断言。
  4. + *
+ * 旧版使用轮询 {@code getContentAsString()} 的方式无法获取到 {@code StreamingResponseBody} + * 写入的内容,测试超时报错 "SSE 响应内容未按预期写入"。

+ */ @Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class) class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest { + /** + * 验证已登录用户可以发起 SSE 流式请求,并收到 Vercel Stream 格式的响应。 + * + *

采用 asyncDispatch 两步模式:先启动异步,再派发获取完整响应体。

+ */ @Test void streamReturnsSseEventsForAuthenticatedUser() throws Exception { String token = loginAsAdmin(); - // StreamingResponseBody 采用异步派发:先获取 MvcResult,再通过 asyncDispatch 触发真实写入 + + // 第一步:发起请求,确认异步处理已启动(StreamingResponseBody 异步写入) MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream") .header("satoken", token) .contentType(MediaType.APPLICATION_JSON) @@ -39,13 +73,18 @@ void streamReturnsSseEventsForAuthenticatedUser() throws Exception { .andExpect(request().asyncStarted()) .andReturn(); - // relayEvents() 将 OpenAI choices[0].delta.content 转换为 Vercel Stream 格式 "0:\"hello\"\n" + // 第二步:触发异步派发,断言响应体包含 Vercel Stream 前导符 "0:" 和内容 "hello" + // relayEvents() 从 choices[0].delta.content 提取文本,转换为 0:""\n 格式 mockMvc.perform(asyncDispatch(mvcResult)) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("0:"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("hello"))); } + /** + * 未携带 token 访问流式接口,SaInterceptor 在请求到达控制器前即触发 NOT_TOKEN 异常, + * 返回 401 + "未提供 Token",不会进入异步处理流程。 + */ @Test void streamRejectsAnonymousRequest() throws Exception { mockMvc.perform(post("/openai/responses/stream") @@ -60,6 +99,10 @@ void streamRejectsAnonymousRequest() throws Exception { .andExpect(jsonPath("$.message").value("未提供 Token")); } + /** + * messages 为空数组时,@NotEmpty 校验失败,返回 400 + 字段级错误消息。 + * 旧测试发送 {"message": ""} 并期望 "message: 消息不能为空",已按新 DTO 结构更新。 + */ @Test void streamValidatesEmptyMessages() throws Exception { String token = loginAsAdmin(); @@ -81,7 +124,8 @@ void streamValidatesEmptyMessages() throws Exception { static class OpenAiTestConfiguration { /** - * 提供一个稳定的测试桩网关,避免控制器测试依赖真实 OpenAI 或 Mockito。 + * 替换真实 {@link OpenAiStreamGateway},避免集成测试依赖外部 OpenAI 服务或 Mockito。 + * {@code @Primary} 确保此 Bean 在有多个同类型 Bean 时优先被注入。 */ @Bean @Primary @@ -92,16 +136,19 @@ OpenAiStreamGateway openAiStreamGateway() { private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway { - /** - * 测试环境下跳过外部 OpenAI 配置校验。 - */ + /** 测试环境无需校验 OpenAI 配置(apiKey 等),直接放行。 */ @Override public void validateConfiguration(OpenAiStreamRequest request) { } /** - * 返回固定的 OpenAI SSE 格式事件流(与真实 API 格式保持一致)。 - * relayEvents() 提取 choices[0].delta.content 写入 Vercel Stream 格式。 + * 返回一条符合 OpenAI SSE 协议格式的固定响应,供 {@code relayEvents()} 解析。 + * + *

{@code relayEvents()} 从 {@code choices[0].delta.content} 提取文本, + * 转换为 {@code 0:"hello"\n} 写入输出流。 + * 旧版 stub 使用 {@code {"type":"...","delta":"..."}} 格式, + * 与 OpenAI 实际格式不符,{@code relayEvents()} 无法找到 {@code choices} 节点, + * 导致输出流为空,asyncDispatch 后 content 断言失败。

*/ @Override public InputStream openStream(OpenAiStreamRequest request) { diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java index 9bafcc6..2cd53c8 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -13,17 +13,39 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -// 显式指定 H2 内存库配置,优先级高于环境变量(如 SPRING_DATASOURCE_URL), -// 确保集成测试始终使用 H2 而非生产 PostgreSQL 连接。 +/** + * Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。 + * + *

为什么在 @SpringBootTest 中显式指定数据源属性?

+ *

Spring Boot 属性优先级从高到低依次为: + *

    + *
  1. {@code @SpringBootTest(properties = {...})} — 最高
  2. + *
  3. 操作系统环境变量(如 {@code SPRING_DATASOURCE_URL})
  4. + *
  5. {@code application-test.properties} 等 Profile 配置文件 — 最低
  6. + *
+ * 测试服务器上的 {@code SPRING_DATASOURCE_URL} 环境变量指向生产 Neon PostgreSQL, + * 其优先级高于 {@code application-test.properties} 中的 H2 配置,导致集成测试 + * 尝试连接 PostgreSQL,上下文启动失败,所有 Web 集成测试报错。 + * 通过在 {@code @SpringBootTest(properties)} 中覆盖数据源属性,可绕过环境变量, + * 确保测试始终使用 H2 内存库。

+ * + *

为什么还要覆盖 JustAuth redirect-uri?

+ *

JustAuth 内部使用 Apache Commons {@code UrlValidator} 校验 redirect-uri 格式。 + * 默认情况下,{@code UrlValidator} 拒绝 {@code localhost} 域名(视为非法 URL), + * 而 {@code application.properties} 中的默认值是 {@code http://localhost:3000/...}。 + * 若不覆盖,{@code OAuthController} 在构建 {@code AuthGithubRequest} 时会立即抛出 + * {@code AuthException: Illegal redirect uri},导致所有 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 使用 Apache Commons UrlValidator 校验 redirect-uri,默认拒绝 localhost。 - // 测试环境使用合法格式的占位 URL,不影响实际网络请求。 + // --- JustAuth:使用合法格式的占位 redirect-uri,规避 UrlValidator localhost 限制 --- "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" @@ -36,7 +58,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") @@ -54,23 +76,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 621903f..8053a27 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,26 @@ import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +/** + * AuthController 集成测试(账号密码登录、退出、当前用户查询)。 + * + *

历史测试失败原因(已修复)

+ *

修复前,所有测试都报 "Driver claims to not accept jdbcUrl, jdbc:postgresql://...", + * 根本原因是 {@code SPRING_DATASOURCE_URL} 环境变量覆盖了 {@code application-test.properties} + * 中的 H2 配置,已通过 {@link com.involutionhell.backend.support.AbstractWebIntegrationTest} + * 的 {@code @SpringBootTest(properties)} 解决。

+ * + *

匿名请求错误消息的变化("未登录..." → "未提供 Token")

+ *

旧版测试断言 {@code "未登录或登录状态已失效"},此消息来自早期 GlobalExceptionHandler + * 使用通用文案的版本。当前 GlobalExceptionHandler 对 {@code NotLoginException} 按场景值细分: + *

    + *
  • {@code NOT_TOKEN}(完全未携带 token)→ "未提供 Token"
  • + *
  • {@code INVALID_TOKEN}(token 格式非法)→ "Token 无效"
  • + *
  • {@code TOKEN_TIMEOUT} → "Token 已过期"
  • + *
  • + *
+ * 匿名请求属于 {@code NOT_TOKEN} 场景,故正确消息为 "未提供 Token"。

+ */ class AuthControllerIntegrationTests extends AbstractWebIntegrationTest { @Test @@ -70,6 +90,10 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception { .andExpect(jsonPath("$.data.permissions[0]").isNotEmpty()); } + /** + * 未携带任何 token 访问受保护接口,Sa-Token 抛出 NOT_TOKEN 场景的 NotLoginException, + * GlobalExceptionHandler 将其映射为 "未提供 Token"(而非旧版通用文案"未登录或登录状态已失效")。 + */ @Test void meRejectsAnonymousRequest() throws Exception { mockMvc.perform(get("/auth/me")) @@ -87,11 +111,16 @@ 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)); } + /** + * 匿名 POST /auth/logout,同样属于 NOT_TOKEN 场景,期望 "未提供 Token"。 + * 旧版测试使用通用消息,已更新为当前 GlobalExceptionHandler 的实际输出。 + */ @Test void logoutRejectsAnonymousRequest() throws Exception { mockMvc.perform(post("/auth/logout")) 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 bcde63e..39efd27 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java @@ -10,8 +10,37 @@ import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; +/** + * UserCenterController + AuthController(/auth/me)集成测试。 + * + *

URL 路径修正(旧:/api/user-center/* → 新:/users/* 和 /auth/me)

+ *

旧版测试使用 {@code /api/user-center/profile}、{@code /api/user-center/users} 等路径, + * 这些路径在重构中被移除(注释"context-path 已含 /api/v1,此处不再重复加 /api 前缀"说明 + * 服务曾有全局 context-path,后来去掉了)。当前实际映射为: + *

    + *
  • 当前用户信息 → {@code GET /auth/me}(AuthController)
  • + *
  • 用户列表 → {@code GET /users}(UserCenterController)
  • + *
  • 单个用户 → {@code GET /users/{id}}(UserCenterController)
  • + *
  • 更新权限 → {@code PUT /users/{id}/authorization}(UserCenterController)
  • + *
+ * 旧路径不存在时,Spring 抛出 {@code NoResourceFoundException},被 GlobalExceptionHandler + * 的 {@code handleUnexpected} 兜底捕获,返回 HTTP 500,导致测试全部失败。

+ * + *

权限错误消息修正(旧:"无权限访问:..." → 新:"拒绝访问: 缺少权限 [...]")

+ *

旧版断言使用的消息与 GlobalExceptionHandler 实际输出不符。 + * 现在 GlobalExceptionHandler 输出 {@code "拒绝访问: 缺少权限 [<权限码>]"}, + * 测试消息已与之对齐。

+ * + *

@SaCheckPermission 之前为何一直 403?

+ *

项目缺少 {@code StpInterface} 实现,Sa-Token 回退使用空列表, + * 导致所有权限校验恒定失败。已通过新增 {@code SaTokenPermissionImpl} 解决。

+ */ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest { + /** + * 当前用户信息接口现在位于 AuthController(/auth/me), + * 旧测试错误地访问了已不存在的 /api/user-center/profile。 + */ @Test void profileReturnsCurrentUserForAuthorizedUser() throws Exception { String token = loginAsAlice(); @@ -22,6 +51,10 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception { .andExpect(jsonPath("$.data.username").value("alice")); } + /** + * 管理员拥有 user:center:read 权限,可访问全量用户列表。 + * 旧 URL /api/user-center/users 不存在,已修正为 /users。 + */ @Test void usersListReturnsAllUsersForAdmin() throws Exception { String token = loginAsAdmin(); @@ -32,6 +65,11 @@ void usersListReturnsAllUsersForAdmin() throws Exception { .andExpect(jsonPath("$.data.length()").value(3)); } + /** + * alice 仅有 user:profile:read,缺少 user:center:read,访问 /users 时 Sa-Token + * 抛出 NotPermissionException,GlobalExceptionHandler 返回 "拒绝访问: 缺少权限 [user:center:read]"。 + * 旧测试期望的 "无权限访问: user:center:read" 是当时不同的错误消息格式,已对齐。 + */ @Test void usersListRejectsUserWithoutReadPermission() throws Exception { String token = loginAsAlice(); @@ -42,6 +80,10 @@ void usersListRejectsUserWithoutReadPermission() throws Exception { .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]")); } + /** + * 审计员拥有 user:profile:read,可查询单个用户详情。 + * 旧 URL /api/user-center/users/2 已修正为 /users/2。 + */ @Test void getUserReturnsRequestedUserForAuditor() throws Exception { String token = loginAsAuditor(); @@ -52,6 +94,7 @@ void getUserReturnsRequestedUserForAuditor() throws Exception { .andExpect(jsonPath("$.data.username").value("alice")); } + /** 匿名访问 /users/1 触发 NOT_TOKEN,返回 401。 */ @Test void getUserRejectsAnonymousRequest() throws Exception { mockMvc.perform(get("/users/1")) @@ -59,6 +102,10 @@ void getUserRejectsAnonymousRequest() throws Exception { .andExpect(jsonPath("$.success").value(false)); } + /** + * 用户不存在时,UserCenterService 抛出 IllegalArgumentException, + * GlobalExceptionHandler 将其映射为 400 BAD_REQUEST。 + */ @Test void getUserReturnsBusinessErrorWhenUserMissing() throws Exception { String token = loginAsAdmin(); @@ -69,6 +116,12 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception { .andExpect(jsonPath("$.message").value("用户不存在: 999")); } + /** + * 管理员成功更新 alice(id=2)的角色与权限后,再次查询验证持久化结果。 + * + *

{@code @DirtiesContext} 在方法执行后重置 Spring 上下文(含 H2 数据库), + * 防止本测试对 alice 的修改影响同一进程内后续测试的预期数据。

+ */ @Test @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) void updateAuthorizationAllowsAdmin() throws Exception { @@ -89,12 +142,14 @@ void updateAuthorizationAllowsAdmin() throws Exception { .andExpect(jsonPath("$.data.roles.length()").value(2)) .andExpect(jsonPath("$.data.permissions.length()").value(2)); + // 二次查询,验证数据库已持久化 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("/users/2/authorization") @@ -109,6 +164,10 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception { .andExpect(jsonPath("$.success").value(false)); } + /** + * alice 缺少 user:center:manage,更新权限时被 @SaCheckPermission 拦截,返回 403。 + * 消息格式已从旧版 "无权限访问: ..." 对齐为 GlobalExceptionHandler 当前输出格式。 + */ @Test void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception { String token = loginAsAlice(); From f3d66bfff8d83638e59aecd7370ff8f245ca105c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 00:52:25 +0000 Subject: [PATCH 3/3] refactor: rewrite comments to remove HTML tags and sound more human Removed all

,

,

,

    ,
  • ,
      tags from Javadoc blocks. Rewrote everything in plain Chinese as a developer would naturally write it. https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk --- .../common/config/SaTokenPermissionImpl.java | 35 +++-------- .../common/error/GlobalExceptionHandler.java | 11 ++-- .../backend/BackendApplicationTests.java | 27 +++----- ...penAiStreamControllerIntegrationTests.java | 63 ++++--------------- .../support/AbstractWebIntegrationTest.java | 35 +++++------ .../AuthControllerIntegrationTests.java | 31 +++------ .../UserCenterControllerIntegrationTests.java | 60 +++++------------- 7 files changed, 72 insertions(+), 190 deletions(-) diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java index 49f286b..2bdf707 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java @@ -8,24 +8,13 @@ /** * Sa-Token 权限与角色加载实现。 * - *

      为什么需要这个类?

      - *

      Sa-Token 的 {@code @SaCheckPermission} / {@code @SaCheckRole} 注解在执行权限校验时, - * 会调用 {@link StpInterface#getPermissionList} / {@link StpInterface#getRoleList} - * 来获取当前登录用户的权限集合与角色集合。

      + * 项目原来缺少 StpInterface 实现,Sa-Token 找不到实现 Bean 时会回退到默认的空列表, + * 导致所有 @SaCheckPermission / @SaCheckRole 注解永远校验失败(403), + * 不管数据库里给用户配了什么权限都没用。这是个生产 Bug,加上这个类才算把权限体系真正接通。 * - *

      在此类被添加之前,项目中缺少 {@code StpInterface} 的实现 Bean, - * Sa-Token 回退使用默认的空列表实现,导致所有 {@code @SaCheckPermission} 检查 - * 无论用户实际持有什么权限,一律抛出 {@code NotPermissionException}(HTTP 403)。 - * 这是一个生产代码 Bug:权限体系在数据库层面已设计完备,但因缺少加载桥梁而完全失效。

      - * - *

      实现逻辑

      - *

      以登录时写入 Sa-Token Session 的用户 ID 为键,从 {@code user_accounts} 表加载 - * {@code permissions} 与 {@code roles} 列(逗号分隔字符串已由 Repository 层解析为 Set)。

      - * - *

      loginId 类型说明

      - *

      Sa-Token 在内部将登录 ID 序列化为 {@code String} 存储,因此 - * {@code loginId} 参数的运行时类型是 {@code String},而非调用 {@code StpUtil.login(Long)} - * 时传入的 {@code Long}。故此处需要通过 {@code Long.valueOf(loginId.toString())} 转换。

      + * 关于 loginId 类型:Sa-Token 内部把登录 ID 序列化成 String 存储, + * 即使调用 StpUtil.login(Long) 传入的是 Long,回调这里时运行时类型也是 String, + * 所以不能直接强转,要先 toString() 再 Long.valueOf()。 */ @Component public class SaTokenPermissionImpl implements StpInterface { @@ -37,25 +26,21 @@ public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) { } /** - * 返回指定用户拥有的权限码列表。 - * - *

      Sa-Token 每次执行 {@code @SaCheckPermission("user:xxx")} 时都会调用此方法, - * 将返回值与注解中声明的权限码对比,若不包含则抛出 {@code NotPermissionException}。

      + * 返回用户拥有的权限码列表,Sa-Token 执行 @SaCheckPermission 时会调用此方法。 * - * @param loginId 登录 ID,运行时实际类型为 String(Sa-Token 内部序列化结果) + * @param loginId 登录 ID,运行时实际类型是 String,不是 Long * @param loginType 登录类型,单端场景下为 "login",此处忽略 */ @Override public List getPermissionList(Object loginId, String loginType) { - // loginId 由 Sa-Token 以 String 形式回传,需先 toString() 再解析为 Long + // Sa-Token 回传的 loginId 是 String,必须先 toString() 再转 Long return userAccountRepository.findById(Long.valueOf(loginId.toString())) .map(account -> List.copyOf(account.permissions())) .orElse(List.of()); } /** - * 返回指定用户拥有的角色标识列表,供 {@code @SaCheckRole} 使用。 - * 逻辑与 {@link #getPermissionList} 完全对称,仅字段来源不同。 + * 返回用户拥有的角色列表,供 @SaCheckRole 使用,逻辑和 getPermissionList 对称。 */ @Override public List getRoleList(Object loginId, String loginType) { 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 6f75754..0671a09 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -43,13 +43,10 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti /** * Sa-Token: 拦截权限不足异常。 * - *

      为什么用 {@code e.getPermission()} 而不是 {@code e.getCode()}?
      - * {@code NotPermissionException} 继承自 {@code SaTokenException},父类的 {@code getCode()} - * 返回的是异常场景码(整数,如 -1),表示"哪种类型的 Sa-Token 异常",而非权限字符串本身。 - * 权限字符串(如 {@code "user:center:read"})存储在 {@code NotPermissionException} - * 自身的 {@code permission} 字段中,须通过 {@code getPermission()} 获取。 - * 若误用 {@code getCode()},错误消息将显示为 "拒绝访问: 缺少权限 [-1]", - * 对调用方毫无诊断价值。

      + * 注意要用 e.getPermission() 而不是 e.getCode()。 + * getCode() 是父类 SaTokenException 的方法,返回的是整数场景码(比如 -1), + * 而权限字符串(比如 "user:center:read")在 NotPermissionException 自己的 permission 字段里, + * 要调 getPermission() 才能拿到。用错了的话错误消息会变成 "拒绝访问: 缺少权限 [-1]",没有任何意义。 */ @ExceptionHandler(NotPermissionException.class) public ResponseEntity> handleNotPermissionException(NotPermissionException e) { diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java index 561f21e..9673245 100644 --- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java +++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java @@ -7,30 +7,26 @@ /** * Spring Boot 上下文加载冒烟测试。 * - *

      为什么需要在这里覆盖数据源属性?

      - *

      原始代码使用裸 {@code @SpringBootTest},未覆盖任何属性。 - * 当测试服务器设置了 {@code SPRING_DATASOURCE_URL} 环境变量(指向生产 Neon PostgreSQL)时, - * 环境变量的优先级高于 {@code application-test.properties}, - * Spring 会尝试用 PostgreSQL 驱动连接该 URL,而 H2 驱动拒绝 {@code jdbc:postgresql://} 格式, - * 导致上下文启动失败,报错: - * {@code Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://...}。

      + * 原来用的是裸 @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://... * - *

      {@code @SpringBootTest(properties)} 的优先级高于一切外部环境变量, - * 可确保本测试始终在 H2 内存库上运行,与生产数据库完全隔离。

      + * @SpringBootTest(properties) 的优先级比环境变量还高,所以在这里覆盖就能保证始终跑 H2。 * - *

      同理,也覆盖了 JustAuth 的 redirect-uri:JustAuth 使用 Apache Commons - * {@code UrlValidator} 在 {@link me.zhyd.oauth.request.AuthGithubRequest} 初始化时 - * 校验 redirect-uri,默认拒绝 localhost,故使用格式合法的占位 URL。

      + * JustAuth 那几个属性也是同理:JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri, + * 它不接受 localhost,不覆盖的话 AuthGithubRequest 初始化直接抛异常。 */ @SpringBootTest(properties = { - // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2,详见类 Javadoc + // 覆盖 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 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" @@ -38,9 +34,6 @@ @ActiveProfiles("test") class BackendApplicationTests { - /** - * 验证 Spring Boot 测试上下文可以正常启动(所有 Bean 可注入、数据源可连接)。 - */ @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 bcbcc3c..9f0a031 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -24,39 +24,22 @@ /** * OpenAiStreamController 集成测试。 * - *

      三处改动说明

      + * 旧测试有三个问题: * - *

      1. 请求体字段:message → messages

      - *

      {@link OpenAiStreamRequest} DTO 已从单条字符串字段 {@code message} 重构为 - * 多轮对话列表字段 {@code messages},以对齐 Vercel AI SDK 的 payload 格式。 - * 旧版测试发送 {@code {"message":"..."}},服务端将 {@code messages} 视为 null/空, - * 触发 {@code @NotEmpty} 校验失败(400),而非进入 SSE 处理流程。

      + * 1. 请求体字段写错了。DTO 已从单字段 message 改为多轮对话列表 messages, + * 旧测试还发 {"message":"..."}, 服务端 messages 为空,直接 400 校验失败,根本进不了 SSE 流程。 * - *

      2. 匿名请求错误消息:"未登录..." → "未提供 Token"

      - *

      与 AuthControllerIntegrationTests 同理,未携带 token 属于 Sa-Token {@code NOT_TOKEN} - * 场景,GlobalExceptionHandler 的当前输出是 "未提供 Token", - * 旧版通用消息已过时。

      + * 2. 匿名请求的期望消息过时了。未带 token 是 Sa-Token NOT_TOKEN 场景, + * GlobalExceptionHandler 现在返回 "未提供 Token",不是旧版的通用文案。 * - *

      3. StreamingResponseBody 的 MockMvc 测试方式:asyncDispatch 模式

      - *

      控制器返回 {@link org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody}, - * Spring 将实际写入操作分派到异步线程。MockMvc 对此类异步响应的正确测试步骤是: - *

        - *
      1. 调用 {@code mockMvc.perform(...).andExpect(request().asyncStarted()).andReturn()} - * 触发异步处理,获取 {@code MvcResult};
      2. - *
      3. 再调用 {@code mockMvc.perform(asyncDispatch(mvcResult))} 完成派发, - * 此时响应体才真正写入,可对 status / content 做断言。
      4. - *
      - * 旧版使用轮询 {@code getContentAsString()} 的方式无法获取到 {@code StreamingResponseBody} - * 写入的内容,测试超时报错 "SSE 响应内容未按预期写入"。

      + * 3. StreamingResponseBody 不能用轮询 getContentAsString() 来读响应体。 + * Spring 把实际写入分派到异步线程,MockMvc 必须用两步走: + * 先 perform + asyncStarted 拿到 MvcResult,再 perform(asyncDispatch(mvcResult)) 才能读到内容。 + * 旧版轮询方式始终读到空,超时报错。 */ @Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class) class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest { - /** - * 验证已登录用户可以发起 SSE 流式请求,并收到 Vercel Stream 格式的响应。 - * - *

      采用 asyncDispatch 两步模式:先启动异步,再派发获取完整响应体。

      - */ @Test void streamReturnsSseEventsForAuthenticatedUser() throws Exception { String token = loginAsAdmin(); @@ -73,18 +56,13 @@ void streamReturnsSseEventsForAuthenticatedUser() throws Exception { .andExpect(request().asyncStarted()) .andReturn(); - // 第二步:触发异步派发,断言响应体包含 Vercel Stream 前导符 "0:" 和内容 "hello" - // relayEvents() 从 choices[0].delta.content 提取文本,转换为 0:""\n 格式 + // 第二步:触发异步派发,此时响应体才真正写入,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"))); } - /** - * 未携带 token 访问流式接口,SaInterceptor 在请求到达控制器前即触发 NOT_TOKEN 异常, - * 返回 401 + "未提供 Token",不会进入异步处理流程。 - */ @Test void streamRejectsAnonymousRequest() throws Exception { mockMvc.perform(post("/openai/responses/stream") @@ -99,10 +77,6 @@ void streamRejectsAnonymousRequest() throws Exception { .andExpect(jsonPath("$.message").value("未提供 Token")); } - /** - * messages 为空数组时,@NotEmpty 校验失败,返回 400 + 字段级错误消息。 - * 旧测试发送 {"message": ""} 并期望 "message: 消息不能为空",已按新 DTO 结构更新。 - */ @Test void streamValidatesEmptyMessages() throws Exception { String token = loginAsAdmin(); @@ -123,10 +97,7 @@ void streamValidatesEmptyMessages() throws Exception { @TestConfiguration static class OpenAiTestConfiguration { - /** - * 替换真实 {@link OpenAiStreamGateway},避免集成测试依赖外部 OpenAI 服务或 Mockito。 - * {@code @Primary} 确保此 Bean 在有多个同类型 Bean 时优先被注入。 - */ + // 用 stub 替换真实 Gateway,避免集成测试依赖外部 OpenAI 服务 @Bean @Primary OpenAiStreamGateway openAiStreamGateway() { @@ -136,20 +107,12 @@ OpenAiStreamGateway openAiStreamGateway() { private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway { - /** 测试环境无需校验 OpenAI 配置(apiKey 等),直接放行。 */ @Override public void validateConfiguration(OpenAiStreamRequest request) { } - /** - * 返回一条符合 OpenAI SSE 协议格式的固定响应,供 {@code relayEvents()} 解析。 - * - *

      {@code relayEvents()} 从 {@code choices[0].delta.content} 提取文本, - * 转换为 {@code 0:"hello"\n} 写入输出流。 - * 旧版 stub 使用 {@code {"type":"...","delta":"..."}} 格式, - * 与 OpenAI 实际格式不符,{@code relayEvents()} 无法找到 {@code choices} 节点, - * 导致输出流为空,asyncDispatch 后 content 断言失败。

      - */ + // 必须用 OpenAI 实际的 SSE 格式,relayEvents() 从 choices[0].delta.content 读文本。 + // 旧 stub 用的是自定义 {"type":"...","delta":"..."} 格式,relayEvents() 解析不到,输出为空。 @Override public InputStream openStream(OpenAiStreamRequest request) { return new ByteArrayInputStream(""" diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java index 2cd53c8..c53b57c 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -16,36 +16,29 @@ /** * Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。 * - *

      为什么在 @SpringBootTest 中显式指定数据源属性?

      - *

      Spring Boot 属性优先级从高到低依次为: - *

        - *
      1. {@code @SpringBootTest(properties = {...})} — 最高
      2. - *
      3. 操作系统环境变量(如 {@code SPRING_DATASOURCE_URL})
      4. - *
      5. {@code application-test.properties} 等 Profile 配置文件 — 最低
      6. - *
      - * 测试服务器上的 {@code SPRING_DATASOURCE_URL} 环境变量指向生产 Neon PostgreSQL, - * 其优先级高于 {@code application-test.properties} 中的 H2 配置,导致集成测试 - * 尝试连接 PostgreSQL,上下文启动失败,所有 Web 集成测试报错。 - * 通过在 {@code @SpringBootTest(properties)} 中覆盖数据源属性,可绕过环境变量, - * 确保测试始终使用 H2 内存库。

      + * 为什么要在 @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 {@code UrlValidator} 校验 redirect-uri 格式。 - * 默认情况下,{@code UrlValidator} 拒绝 {@code localhost} 域名(视为非法 URL), - * 而 {@code application.properties} 中的默认值是 {@code http://localhost:3000/...}。 - * 若不覆盖,{@code OAuthController} 在构建 {@code AuthGithubRequest} 时会立即抛出 - * {@code AuthException: Illegal redirect uri},导致所有 OAuth 相关集成测试以 500 失败。 - * 测试环境仅需要一个格式合法的占位 URL,不会发起任何真实网络请求。

      + * 为什么还要覆盖 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 环境变量,强制使用 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:使用合法格式的占位 redirect-uri,规避 UrlValidator localhost 限制 --- + // 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" 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 8053a27..600dc90 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java @@ -12,22 +12,11 @@ /** * AuthController 集成测试(账号密码登录、退出、当前用户查询)。 * - *

      历史测试失败原因(已修复)

      - *

      修复前,所有测试都报 "Driver claims to not accept jdbcUrl, jdbc:postgresql://...", - * 根本原因是 {@code SPRING_DATASOURCE_URL} 环境变量覆盖了 {@code application-test.properties} - * 中的 H2 配置,已通过 {@link com.involutionhell.backend.support.AbstractWebIntegrationTest} - * 的 {@code @SpringBootTest(properties)} 解决。

      - * - *

      匿名请求错误消息的变化("未登录..." → "未提供 Token")

      - *

      旧版测试断言 {@code "未登录或登录状态已失效"},此消息来自早期 GlobalExceptionHandler - * 使用通用文案的版本。当前 GlobalExceptionHandler 对 {@code NotLoginException} 按场景值细分: - *

        - *
      • {@code NOT_TOKEN}(完全未携带 token)→ "未提供 Token"
      • - *
      • {@code INVALID_TOKEN}(token 格式非法)→ "Token 无效"
      • - *
      • {@code TOKEN_TIMEOUT} → "Token 已过期"
      • - *
      • - *
      - * 匿名请求属于 {@code NOT_TOKEN} 场景,故正确消息为 "未提供 Token"。

      + * 旧测试断言的匿名请求错误消息是 "未登录或登录状态已失效",这是早期 GlobalExceptionHandler 的通用文案。 + * 现在 GlobalExceptionHandler 对 NotLoginException 按场景值细分: + * 完全没带 token 是 NOT_TOKEN 场景,对应 "未提供 Token"; + * token 格式非法是 INVALID_TOKEN,对应 "Token 无效";以此类推。 + * 匿名请求属于 NOT_TOKEN,所以正确消息是 "未提供 Token"。 */ class AuthControllerIntegrationTests extends AbstractWebIntegrationTest { @@ -90,10 +79,7 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception { .andExpect(jsonPath("$.data.permissions[0]").isNotEmpty()); } - /** - * 未携带任何 token 访问受保护接口,Sa-Token 抛出 NOT_TOKEN 场景的 NotLoginException, - * GlobalExceptionHandler 将其映射为 "未提供 Token"(而非旧版通用文案"未登录或登录状态已失效")。 - */ + // 未带 token 是 NOT_TOKEN 场景,GlobalExceptionHandler 返回 "未提供 Token" @Test void meRejectsAnonymousRequest() throws Exception { mockMvc.perform(get("/auth/me")) @@ -117,10 +103,7 @@ void logoutSucceedsAndMakesTokenInvalid() throws Exception { .andExpect(jsonPath("$.success").value(false)); } - /** - * 匿名 POST /auth/logout,同样属于 NOT_TOKEN 场景,期望 "未提供 Token"。 - * 旧版测试使用通用消息,已更新为当前 GlobalExceptionHandler 的实际输出。 - */ + // 同上,匿名 logout 也是 NOT_TOKEN 场景 @Test void logoutRejectsAnonymousRequest() throws Exception { mockMvc.perform(post("/auth/logout")) 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 39efd27..620aad9 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java @@ -13,34 +13,22 @@ /** * UserCenterController + AuthController(/auth/me)集成测试。 * - *

      URL 路径修正(旧:/api/user-center/* → 新:/users/* 和 /auth/me)

      - *

      旧版测试使用 {@code /api/user-center/profile}、{@code /api/user-center/users} 等路径, - * 这些路径在重构中被移除(注释"context-path 已含 /api/v1,此处不再重复加 /api 前缀"说明 - * 服务曾有全局 context-path,后来去掉了)。当前实际映射为: - *

        - *
      • 当前用户信息 → {@code GET /auth/me}(AuthController)
      • - *
      • 用户列表 → {@code GET /users}(UserCenterController)
      • - *
      • 单个用户 → {@code GET /users/{id}}(UserCenterController)
      • - *
      • 更新权限 → {@code PUT /users/{id}/authorization}(UserCenterController)
      • - *
      - * 旧路径不存在时,Spring 抛出 {@code NoResourceFoundException},被 GlobalExceptionHandler - * 的 {@code handleUnexpected} 兜底捕获,返回 HTTP 500,导致测试全部失败。

      + * 旧测试用的 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 改对就好了。 * - *

      权限错误消息修正(旧:"无权限访问:..." → 新:"拒绝访问: 缺少权限 [...]")

      - *

      旧版断言使用的消息与 GlobalExceptionHandler 实际输出不符。 - * 现在 GlobalExceptionHandler 输出 {@code "拒绝访问: 缺少权限 [<权限码>]"}, - * 测试消息已与之对齐。

      + * 权限错误消息也变了:旧测试期望 "无权限访问: user:center:read", + * 但 GlobalExceptionHandler 实际输出是 "拒绝访问: 缺少权限 [user:center:read]",对齐即可。 * - *

      @SaCheckPermission 之前为何一直 403?

      - *

      项目缺少 {@code StpInterface} 实现,Sa-Token 回退使用空列表, - * 导致所有权限校验恒定失败。已通过新增 {@code SaTokenPermissionImpl} 解决。

      + * @SaCheckPermission 之前一直 403 的原因:项目缺少 StpInterface 实现, + * Sa-Token 找不到实现 Bean 就用空列表兜底,所有权限校验必然失败。 + * 新增 SaTokenPermissionImpl 后才真正把权限数据接进来。 */ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest { - /** - * 当前用户信息接口现在位于 AuthController(/auth/me), - * 旧测试错误地访问了已不存在的 /api/user-center/profile。 - */ + // 旧测试访问的 /api/user-center/profile 已不存在,现在是 /auth/me @Test void profileReturnsCurrentUserForAuthorizedUser() throws Exception { String token = loginAsAlice(); @@ -51,10 +39,6 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception { .andExpect(jsonPath("$.data.username").value("alice")); } - /** - * 管理员拥有 user:center:read 权限,可访问全量用户列表。 - * 旧 URL /api/user-center/users 不存在,已修正为 /users。 - */ @Test void usersListReturnsAllUsersForAdmin() throws Exception { String token = loginAsAdmin(); @@ -65,11 +49,8 @@ void usersListReturnsAllUsersForAdmin() throws Exception { .andExpect(jsonPath("$.data.length()").value(3)); } - /** - * alice 仅有 user:profile:read,缺少 user:center:read,访问 /users 时 Sa-Token - * 抛出 NotPermissionException,GlobalExceptionHandler 返回 "拒绝访问: 缺少权限 [user:center:read]"。 - * 旧测试期望的 "无权限访问: user:center:read" 是当时不同的错误消息格式,已对齐。 - */ + // alice 只有 user:profile:read,没有 user:center:read,访问 /users 会被拦截 + // 旧测试期望的消息 "无权限访问: ..." 和 GlobalExceptionHandler 实际输出不一致,已修正 @Test void usersListRejectsUserWithoutReadPermission() throws Exception { String token = loginAsAlice(); @@ -80,10 +61,6 @@ void usersListRejectsUserWithoutReadPermission() throws Exception { .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]")); } - /** - * 审计员拥有 user:profile:read,可查询单个用户详情。 - * 旧 URL /api/user-center/users/2 已修正为 /users/2。 - */ @Test void getUserReturnsRequestedUserForAuditor() throws Exception { String token = loginAsAuditor(); @@ -116,12 +93,7 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception { .andExpect(jsonPath("$.message").value("用户不存在: 999")); } - /** - * 管理员成功更新 alice(id=2)的角色与权限后,再次查询验证持久化结果。 - * - *

      {@code @DirtiesContext} 在方法执行后重置 Spring 上下文(含 H2 数据库), - * 防止本测试对 alice 的修改影响同一进程内后续测试的预期数据。

      - */ + // @DirtiesContext 确保本测试对 alice 的修改不会污染其他测试的预期数据 @Test @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) void updateAuthorizationAllowsAdmin() throws Exception { @@ -164,10 +136,6 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception { .andExpect(jsonPath("$.success").value(false)); } - /** - * alice 缺少 user:center:manage,更新权限时被 @SaCheckPermission 拦截,返回 403。 - * 消息格式已从旧版 "无权限访问: ..." 对齐为 GlobalExceptionHandler 当前输出格式。 - */ @Test void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception { String token = loginAsAlice();