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 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 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)导致频繁登出"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceConfirmUploadTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceConfirmUploadTest.java new file mode 100644 index 0000000..3080ad2 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceConfirmUploadTest.java @@ -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)。 + *

+ */ +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 回归守卫: + * + * + *

使用 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 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 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 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 result = provider.verify("张三", VALID_ID); + assertFalse((Boolean) result.get("verified"), "第三方非 2xx 响应 = 未通过(fail-closed)"); + } +} diff --git a/ui-tests/profileLabels.test.mjs b/ui-tests/profileLabels.test.mjs new file mode 100644 index 0000000..996946f --- /dev/null +++ b/ui-tests/profileLabels.test.mjs @@ -0,0 +1,28 @@ +// 纯函数回归测试:枚举码 → 中文映射(修复「枚举码直渲未映射中文」) +// 运行:node ui-tests/profileLabels.test.mjs +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { serviceTypeLabel, labelOf } = require('../miniapp/constants/profileLabels.js') + +let passed = 0 +function check(name, actual, expected) { + assert.equal(actual, expected, `${name}: 期望 "${expected}",实际 "${actual}"`) + passed++ +} + +// ===== 红娘服务类型映射(本次修复点:matchmaker-workbench 卡片曾直渲枚举码)===== +check('recommend_partner', serviceTypeLabel('recommend_partner'), '推荐对象') +check('review_preference', serviceTypeLabel('review_preference'), '择偶指导') +check('contact_intention', serviceTypeLabel('contact_intention'), '牵线意向') +check('未知码回退原值', serviceTypeLabel('mystery_code'), 'mystery_code') +check('空串返回空', serviceTypeLabel(''), '') +check('null 返回空', serviceTypeLabel(null), '') + +// ===== 通用 labelOf(既有字典,回归不退化)===== +check('labelOf education', labelOf('educationCode', 'bachelor'), '本科') +check('labelOf marital', labelOf('maritalStatus', 'never_married'), '未婚') +check('labelOf 未知码回退', labelOf('maritalStatus', 'xxx'), 'xxx') + +console.log(`✅ profileLabels 回归测试全部通过(${passed} 项)`)