Phase 2/3 完成:修复 Shiro javax/jakarta 兼容性,实现 US1 认证模块
修复: - TokenFilter 改继承 OncePerRequestFilter(jakarta.servlet), 移除 PathMatchingFilter(javax.servlet)依赖,解决 Lombok 级联失败 - ShiroConfig 用 FilterRegistrationBean 替代 ShiroFilterFactoryBean, 避免 javax/jakarta Filter 类型不兼容;securityManager 调用 SecurityUtils.setSecurityManager() 确保 @RequiresRoles AOP 可用 - LabelBackendApplication 排除 ShiroWeb 自动配置(WebAutoConfiguration、 WebFilterConfiguration、WebMvcAutoConfiguration) - SysUserMapper @InterceptorIgnore 修正为 mybatis-plus 包路径 新增(Phase 2 尾声): - SysCompany / SysCompanyMapper - SysUser / SysUserMapper - ShiroFilterIntegrationTest(无 Token→401、过期→401、角色不足→403、满足→200) 新增(Phase 3 / US1): - LoginRequest / LoginResponse / UserInfoResponse DTO - AuthService(login + logout + me;BCrypt 校验;Redis Hash 存 Token) - AuthController(POST /api/auth/login、POST /logout、GET /me) - AuthIntegrationTest(正确密码→token、错误密码→401、退出后→401)
This commit is contained in:
160
src/test/java/com/label/integration/AuthIntegrationTest.java
Normal file
160
src/test/java/com/label/integration/AuthIntegrationTest.java
Normal file
@@ -0,0 +1,160 @@
|
||||
package com.label.integration;
|
||||
|
||||
import com.label.AbstractIntegrationTest;
|
||||
import com.label.common.result.Result;
|
||||
import com.label.module.user.dto.LoginRequest;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 认证流程集成测试(US1)。
|
||||
*
|
||||
* 测试场景:
|
||||
* 1. 正确密码登录 → 返回 token
|
||||
* 2. 错误密码登录 → 401
|
||||
* 3. 不存在的公司代码 → 401
|
||||
* 4. 有效 Token 访问 /api/auth/me → 200,返回用户信息
|
||||
* 5. 主动退出后,原 Token 访问 /api/auth/me → 401
|
||||
*
|
||||
* 测试数据来自 init.sql 种子(DEMO 公司 / admin / admin123)
|
||||
*/
|
||||
public class AuthIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
// ------------------------------------------------------------------ 登录测试 --
|
||||
|
||||
@Test
|
||||
@DisplayName("正确密码登录 → 返回 token")
|
||||
void login_withCorrectCredentials_returnsToken() {
|
||||
ResponseEntity<Map> response = doLogin("DEMO", "admin", "admin123");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
Map<?, ?> body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.get("code")).isEqualTo("SUCCESS");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) body.get("data");
|
||||
assertThat(data.get("token")).isNotNull().isInstanceOf(String.class);
|
||||
assertThat((String) data.get("token")).isNotBlank();
|
||||
assertThat(data.get("role")).isEqualTo("ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("错误密码登录 → 401 Unauthorized")
|
||||
void login_withWrongPassword_returns401() {
|
||||
ResponseEntity<Map> response = doLogin("DEMO", "admin", "wrong_password");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不存在的公司代码 → 401 Unauthorized")
|
||||
void login_withUnknownCompany_returns401() {
|
||||
ResponseEntity<Map> response = doLogin("NONEXIST", "admin", "admin123");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ /me 测试 --
|
||||
|
||||
@Test
|
||||
@DisplayName("有效 Token 访问 /api/auth/me → 200,返回用户信息")
|
||||
void me_withValidToken_returns200WithUserInfo() {
|
||||
String token = loginAndGetToken("DEMO", "admin", "admin123");
|
||||
assertThat(token).isNotBlank();
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.exchange(
|
||||
baseUrl("/api/auth/me"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest(token),
|
||||
Map.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
||||
assertThat(data.get("username")).isEqualTo("admin");
|
||||
assertThat(data.get("role")).isEqualTo("ADMIN");
|
||||
assertThat(data.get("companyId")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无 Token 访问 /api/auth/me → 401")
|
||||
void me_withNoToken_returns401() {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(
|
||||
baseUrl("/api/auth/me"), String.class);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 退出测试 --
|
||||
|
||||
@Test
|
||||
@DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401")
|
||||
void logout_thenMe_returns401() {
|
||||
String token = loginAndGetToken("DEMO", "admin", "admin123");
|
||||
assertThat(token).isNotBlank();
|
||||
|
||||
// 确认登录有效
|
||||
ResponseEntity<Map> meResponse = restTemplate.exchange(
|
||||
baseUrl("/api/auth/me"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest(token),
|
||||
Map.class);
|
||||
assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// 退出
|
||||
ResponseEntity<Map> logoutResponse = restTemplate.exchange(
|
||||
baseUrl("/api/auth/logout"),
|
||||
HttpMethod.POST,
|
||||
bearerRequest(token),
|
||||
Map.class);
|
||||
assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// 退出后再访问 /me → 401
|
||||
ResponseEntity<Map> meAfterLogout = restTemplate.exchange(
|
||||
baseUrl("/api/auth/me"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest(token),
|
||||
Map.class);
|
||||
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 工具方法 --
|
||||
|
||||
/** 发起登录请求,返回原始 ResponseEntity */
|
||||
private ResponseEntity<Map> doLogin(String companyCode, String username, String password) {
|
||||
LoginRequest req = new LoginRequest();
|
||||
req.setCompanyCode(companyCode);
|
||||
req.setUsername(username);
|
||||
req.setPassword(password);
|
||||
return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class);
|
||||
}
|
||||
|
||||
/** 登录并提取 token 字符串;失败时返回 null */
|
||||
private String loginAndGetToken(String companyCode, String username, String password) {
|
||||
ResponseEntity<Map> response = doLogin(companyCode, username, password);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
||||
return (String) data.get("token");
|
||||
}
|
||||
|
||||
/** 构造带 Bearer Token 的请求实体(无 body) */
|
||||
private HttpEntity<Void> bearerRequest(String token) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
return new HttpEntity<>(headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.label.integration;
|
||||
|
||||
import com.label.AbstractIntegrationTest;
|
||||
import com.label.common.redis.RedisKeyManager;
|
||||
import com.label.common.redis.RedisService;
|
||||
import com.label.common.result.Result;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Shiro 过滤器集成测试:
|
||||
* - 无 Token → 401 Unauthorized
|
||||
* - Token 不存在(已过期或伪造)→ 401 Unauthorized
|
||||
* - 有效 Token 但角色不足(ANNOTATOR 访问 REVIEWER 端点)→ 403 Forbidden
|
||||
* - 有效 Token 且角色满足(REVIEWER 访问 REVIEWER 端点)→ 200 OK
|
||||
*/
|
||||
@Import(ShiroFilterIntegrationTest.TestConfig.class)
|
||||
public class ShiroFilterIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
/** 仅供测试的临时 Token,测试结束后清理 */
|
||||
private static final String REVIEWER_TOKEN = "test-reviewer-token-uuid-fixed";
|
||||
private static final String ANNOTATOR_TOKEN = "test-annotator-token-uuid-fixed";
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
// ------------------------------------------------------------------ 测试 Controller --
|
||||
|
||||
/**
|
||||
* 测试专用配置:注册仅在测试环境存在的端点
|
||||
*/
|
||||
@TestConfiguration
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
public ReviewerOnlyController reviewerOnlyController() {
|
||||
return new ReviewerOnlyController();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要 REVIEWER 角色的测试端点。
|
||||
* 调用 subject.checkRole() —— 角色不足时抛出 AuthorizationException → 403。
|
||||
*/
|
||||
@RestController
|
||||
static class ReviewerOnlyController {
|
||||
@GetMapping("/api/test/reviewer-only")
|
||||
public Result<String> reviewerOnly() {
|
||||
// 验证当前 Subject 是否持有 REVIEWER 角色
|
||||
SecurityUtils.getSubject().checkRole("REVIEWER");
|
||||
return Result.success("ok");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 测试前后置 --
|
||||
|
||||
@BeforeEach
|
||||
void setupTokens() {
|
||||
// REVIEWER Token:companyId=1, userId=2
|
||||
Map<String, String> reviewerData = new HashMap<>();
|
||||
reviewerData.put("userId", "2");
|
||||
reviewerData.put("role", "REVIEWER");
|
||||
reviewerData.put("companyId", "1");
|
||||
reviewerData.put("username", "reviewer01");
|
||||
redisService.hSetAll(RedisKeyManager.tokenKey(REVIEWER_TOKEN), reviewerData, 3600L);
|
||||
|
||||
// ANNOTATOR Token:companyId=1, userId=3
|
||||
Map<String, String> annotatorData = new HashMap<>();
|
||||
annotatorData.put("userId", "3");
|
||||
annotatorData.put("role", "ANNOTATOR");
|
||||
annotatorData.put("companyId", "1");
|
||||
annotatorData.put("username", "annotator01");
|
||||
redisService.hSetAll(RedisKeyManager.tokenKey(ANNOTATOR_TOKEN), annotatorData, 3600L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanupTokens() {
|
||||
redisService.delete(RedisKeyManager.tokenKey(REVIEWER_TOKEN));
|
||||
redisService.delete(RedisKeyManager.tokenKey(ANNOTATOR_TOKEN));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 测试用例 --
|
||||
|
||||
@Test
|
||||
@DisplayName("无 Authorization 头 → 401 Unauthorized")
|
||||
void noToken_returns401() {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(
|
||||
baseUrl("/api/test/reviewer-only"), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Token 不存在于 Redis → 401 Unauthorized")
|
||||
void expiredToken_returns401() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
baseUrl("/api/test/reviewer-only"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest("non-existent-token-xyz"),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效 Token 但角色不足(ANNOTATOR 访问 REVIEWER 端点)→ 403 Forbidden")
|
||||
void annotatorToken_onReviewerEndpoint_returns403() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
baseUrl("/api/test/reviewer-only"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest(ANNOTATOR_TOKEN),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效 Token 且角色满足(REVIEWER 访问 REVIEWER 端点)→ 200 OK")
|
||||
void reviewerToken_onReviewerEndpoint_returns200() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
baseUrl("/api/test/reviewer-only"),
|
||||
HttpMethod.GET,
|
||||
bearerRequest(REVIEWER_TOKEN),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 工具方法 --
|
||||
|
||||
/** 构造带 Bearer Token 的请求实体 */
|
||||
private HttpEntity<Void> bearerRequest(String token) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
return new HttpEntity<>(headers);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user