Author SHA1 Message Date
WorkBuddy 47cccc9ca5 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)
2026-09-30 14:05:48 +08:00
WorkBuddy 5c97c57c76 Merge branch 'main' of http://120.27.207.134:13000/admin/danyang-match
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
# Conflicts:
#	README.md
2026-09-30 13:44:13 +08:00
admin 8335ef780c Initial commit 2026-09-30 12:03:52 +08:00
6 changed files with 406 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
@@ -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)");
}
}
+28
View File
@@ -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} 项)`)