test: 补 v1.1.0 修复的回归测试
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s

- RealIdentityVerificationProvider: 伪造证件校验位拦截、配置缺失 fail-fast、第三方 fail-closed
- AuthService: 钉死刷新 Token Redis TTL 用 getRefreshExpirationMs(14 天)而非 access*2(4 小时)
- PhotoService: 直传落库 + 归属/存在性护栏(修复「传了白传」)
- JwtAuthFilter: 管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 + 头优先于 Cookie
- miniapp profileLabels: 枚举码→中文映射不退化(serviceTypeLabel / labelOf)
This commit is contained in:
WorkBuddy
2026-09-30 14:05:48 +08:00
parent 5c97c57c76
commit 47cccc9ca5
5 changed files with 382 additions and 0 deletions
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:刷新 Token 在 Redis 中登记的 TTL 必须与 refresh JWT 自身有效期一致
* ({@code JwtTokenService#getRefreshExpirationMs},默认 14 天),而不是
* {@code getExpirationMs() * 2}(4 小时)。
*
* <p>此前 AuthService 用 {@code getExpirationMs() * 2} 写入,导致 4h 后 Redis 记录消失、
* 刷新即失效,用户被迫频繁重新登录。本测试钉死两处写入点(登录 / 刷新)。
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceRefreshTtlTest {
@Mock private UserMapper userMapper;
@Mock private WechatLoginProvider wechatLoginProvider;
@Mock private JwtTokenService jwtTokenService;
@Mock private StringRedisTemplate redisTemplate;
@Mock private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
@Mock private com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper localEligibilityMapper;
@Mock private com.danyang.match.platform.capability.service.CapabilityService capabilityService;
@Mock private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Mock private com.danyang.match.platform.review.mapper.ReviewTaskMapper reviewTaskMapper;
@Mock private ValueOperations<String, String> valueOps;
@InjectMocks private AuthService authService;
private static final long ACCESS_MS = 7_200_000L; // 2h
private static final long REFRESH_MS = 1_209_600_000L; // 14d
/** 捕获 refresh token 在 Redis 中的登记 TTL。 */
private Duration captureRefreshTtl() {
ArgumentCaptor<Duration> cap = ArgumentCaptor.forClass(Duration.class);
verify(valueOps).set(eq("auth:refresh_token:u1"), anyString(), cap.capture());
return cap.getValue();
}
@Test
void wechatLoginStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(wechatLoginProvider.getCodeSession("code")).thenReturn(Map.of("openid", "o1"));
User user = new User();
user.setId("u1");
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
user.setPhone(null);
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(any())).thenReturn(1);
authService.wechatLogin("code");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "登录:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
@Test
void refreshTokenStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(jwtTokenService.validateRefreshToken("rt")).thenReturn(true);
when(jwtTokenService.getUserIdFromRefreshToken("rt")).thenReturn("u1");
when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "刷新:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.photo;
import com.amazonaws.services.s3.AmazonS3;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:照片直传落库(此前「传了白传」——upload-token 之后没有任何端点写 user_photos)。
* <ul>
* <li>非法的 fileKey(不归属本人)必须被归属护栏拒绝;</li>
* <li>对象尚未上传完成(存储不存在)必须被存在性护栏拒绝;</li>
* <li>合法且已上传的直传必须真正落库(写入 user_photos 记录)。</li>
* </ul>
*/
class PhotoServiceConfirmUploadTest {
private final UserPhotoMapper photoMapper = mock(UserPhotoMapper.class);
private final AmazonS3 amazonS3 = mock(AmazonS3.class);
private final FeatureFlagConfig featureFlags = mock(FeatureFlagConfig.class);
private final ProfilePolicy profilePolicy = mock(ProfilePolicy.class);
private final UserMapper userMapper = mock(UserMapper.class);
private final AmazonS3 amazonS3Signer = mock(AmazonS3.class);
private final PhotoService photoService =
new PhotoService(photoMapper, amazonS3, featureFlags, profilePolicy, userMapper, amazonS3Signer);
@Test
void illegalFileKeyRejected() {
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "evil/key.jpg", "avatar"));
assertEquals("非法的文件标识", ex.getMessage());
}
@Test
void missingObjectRejected() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/x.jpg"))).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "photos/u1/x.jpg", "lifestyle"));
assertEquals("文件尚未上传完成", ex.getMessage());
}
@Test
void validUploadPersistsPhoto() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/p.jpg"))).thenReturn(true);
when(photoMapper.countActiveProfilePhotos("u1")).thenReturn(0L);
when(profilePolicy.getMaxPhotoCount()).thenReturn(6);
when(photoMapper.insert(any())).thenReturn(1);
UserPhoto photo = photoService.confirmDirectUpload("u1", "photos/u1/p.jpg", "lifestyle");
assertNotNull(photo, "直传必须真实落库,不得白传");
assertEquals("u1", photo.getUserId());
assertEquals("photos/u1/p.jpg", photo.getFileKey());
assertEquals("lifestyle", photo.getPhotoType());
assertEquals(Constants.REVIEW_STATUS_PENDING, photo.getReviewStatus());
verify(photoMapper).insert(any(UserPhoto.class));
}
}
@@ -16,6 +16,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.http.Cookie;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@@ -98,4 +99,78 @@ class JwtAuthFilterTest {
request.addHeader("Authorization", "Bearer " + token);
return request;
}
// ===== 本轮修复回归:管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 =====
@Test
void cookieFallbackAuthenticatesOperatorWhenNoHeader() throws Exception {
String token = "cookie-op-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("operator_access");
when(permissionService.getPermissions("operator-1")).thenReturn(List.of("content.edit"));
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(OperatorPrincipal.class, authentication.getPrincipal());
assertEquals("operator-1", ((OperatorPrincipal) authentication.getPrincipal()).id());
}
@Test
void cookieFallbackAuthenticatesUserWhenNoHeader() throws Exception {
String token = "cookie-user-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("user-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
}
@Test
void cookieWithInvalidTokenReturns401() throws Exception {
String token = "bad-cookie-token";
when(jwtTokenService.validateToken(token)).thenReturn(false);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(cookieRequest(token), response, new MockFilterChain());
assertEquals(401, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
void headerTakesPrecedenceOverCookie() throws Exception {
// Authorization 头里是有效 user token;Cookie 里是无效 token,应被忽略(Cookie 仅作兜底)
String headerToken = "header-token";
when(jwtTokenService.validateToken(headerToken)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
when(jwtTokenService.validateToken("bad-cookie")).thenReturn(false);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + headerToken);
request.setCookies(new Cookie("dy_op_at", "bad-cookie"));
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
assertEquals("user-1", ((UserPrincipal) authentication.getPrincipal()).id());
}
private MockHttpServletRequest cookieRequest(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.setCookies(new Cookie("dy_op_at", token));
return request;
}
}
@@ -0,0 +1,110 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RealIdentityVerificationProvider 回归守卫:
* <ul>
* <li>伪造身份证号(校验位不符)必须在发起任何外部调用前被本地强校验拦截;</li>
* <li>配置缺失(apiUrl / apiKey 为空)必须 fail-fast,绝不静默放行任意证件;</li>
* <li>第三方核验 fail-closed:响应未明确「通过」一律视为未通过。</li>
* </ul>
*
* <p>使用 JDK 内置 {@link HttpServer} 模拟第三方核验端点,无需任何额外依赖。
*/
class RealIdentityVerificationProviderTest {
// 按 GB 11643-1999 手工计算得到的合法校验位身份证(前 17 位 + 校验位 '8')
private static final String VALID_ID = "110101199003073458";
// 校验位应为 '8',此处写成 'X' → 非法
private static final String INVALID_ID = "11010119900307345X";
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
private RealIdentityVerificationProvider newProvider(String apiUrl, String apiKey) throws Exception {
RealIdentityVerificationProvider p = new RealIdentityVerificationProvider();
setField(p, "apiUrl", apiUrl);
setField(p, "apiKey", apiKey);
return p;
}
private void setField(Object target, String name, String value) throws Exception {
Field f = RealIdentityVerificationProvider.class.getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
/** 启动本地模拟端点,返回其 URL。 */
private String startServer(int statusCode, String body) throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/verify", exchange -> {
byte[] resp = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, resp.length);
exchange.getResponseBody().write(resp);
exchange.close();
});
server.start();
return "http://localhost:" + server.getAddress().getPort() + "/verify";
}
@Test
void forgedIdRejectedBeforeAnyNetworkCall() throws Exception {
// apiUrl / apiKey 仅作占位(非空,避免误触 fail-fast 分支);
// 校验位不符应直接判未通过,且不应发起任何外部请求
RealIdentityVerificationProvider provider = newProvider("http://localhost:1/verify", "k");
Map<String, Object> result = provider.verify("张三", INVALID_ID);
assertFalse((Boolean) result.get("verified"), "校验位不符的伪造证件号必须被本地强校验判定为未通过");
}
@Test
void missingConfigFailsFast() throws Exception {
RealIdentityVerificationProvider provider = newProvider("", "");
BusinessException ex = assertThrows(BusinessException.class,
() -> provider.verify("张三", VALID_ID));
assertEquals(ApiResponseCode.SERVER_ERROR, ex.getCode(),
"缺第三方配置时必须 fail-fast 而非放行任意证件");
}
@Test
void validIdWithThirdPartySuccessVerified() throws Exception {
String url = startServer(200, "{\"success\":true}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertTrue((Boolean) result.get("verified"), "校验位合法 + 第三方明确通过 = 通过");
}
@Test
void validIdWithThirdPartyFailureNotVerified() throws Exception {
String url = startServer(200, "{\"success\":false}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方未明确「通过」 = 未通过(fail-closed)");
}
@Test
void validIdWithThirdPartyErrorNotVerified() throws Exception {
String url = startServer(500, "{\"message\":\"internal\"}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方非 2xx 响应 = 未通过(fail-closed)");
}
}