diff --git a/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java b/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java new file mode 100644 index 0000000..8b4c4b7 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/auth/service/AuthServiceRefreshTtlTest.java @@ -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 小时)。 + * + *
此前 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 使用 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
+ *
+ */
+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));
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java
index 725281c..7376421 100644
--- a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java
@@ -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;
+ }
}
\ No newline at end of file
diff --git a/backend/src/test/java/com/danyang/match/platform/verification/provider/RealIdentityVerificationProviderTest.java b/backend/src/test/java/com/danyang/match/platform/verification/provider/RealIdentityVerificationProviderTest.java
new file mode 100644
index 0000000..593c37b
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/verification/provider/RealIdentityVerificationProviderTest.java
@@ -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 回归守卫:
+ *
+ *
+ *
+ *