feat: complete phase 02 RBAC and data scope recovery

This commit is contained in:
2026-09-10 10:58:43 +08:00
parent b46b48eb33
commit 5b51d5b3f1
115 changed files with 3608 additions and 1135 deletions
@@ -0,0 +1,64 @@
package com.danyang.match.platform.auth;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.mapper.PermissionMapper;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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 org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OperatorAuthServiceTest {
@Mock OperatorMapper operatorMapper;
@Mock PermissionMapper permissionMapper;
@Mock JwtTokenService jwtTokenService;
@Mock StringRedisTemplate redisTemplate;
@Mock ValueOperations<String, String> valueOperations;
@Mock AuditLogMapper auditLogMapper;
@Mock PasswordEncoder passwordEncoder;
private OperatorAuthService service;
@BeforeEach
void setUp() {
service = new OperatorAuthService(operatorMapper, permissionMapper, jwtTokenService,
redisTemplate, auditLogMapper, passwordEncoder);
}
@Test
void existingOperatorWithWrongPasswordCannotReceiveToken() {
Operator operator = new Operator();
operator.setId("operator-1");
operator.setUsername("admin");
operator.setPasswordHash("bcrypt-hash-from-database");
operator.setStatus("active");
when(operatorMapper.findByUsername("admin")).thenReturn(Optional.of(operator));
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(passwordEncoder.matches("not-the-password", operator.getPasswordHash())).thenReturn(false);
when(valueOperations.increment("auth:operator_login_attempts:operator-1")).thenReturn(1L);
assertThrows(UnauthorizedException.class,
() -> service.login("admin", "not-the-password"));
verify(passwordEncoder).matches("not-the-password", "bcrypt-hash-from-database");
verify(jwtTokenService, never()).generateOperatorToken(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap());
}
}
@@ -24,7 +24,7 @@ class AuthControllerTest {
@Test
void testWechatLoginWithInvalidCode() throws Exception {
mockMvc.perform(post("/api/v1/auth/wechat/login")
mockMvc.perform(post("/api/v1/auth/wechat/login").contextPath("/api/v1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\": \"\"}"))
.andExpect(status().isBadRequest())
@@ -33,7 +33,7 @@ class AuthControllerTest {
@Test
void testWechatLoginMissingCode() throws Exception {
mockMvc.perform(post("/api/v1/auth/wechat/login")
mockMvc.perform(post("/api/v1/auth/wechat/login").contextPath("/api/v1")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
@@ -42,8 +42,13 @@ class AuthControllerTest {
@Test
void testHealthLiveNotAffectedByAuth() throws Exception {
mockMvc.perform(get("/api/v1/health/live"))
mockMvc.perform(get("/api/v1/health/live").contextPath("/api/v1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
void testProtectedEndpointRejectsAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/users/me").contextPath("/api/v1"))
.andExpect(status().isForbidden());
}
}
@@ -0,0 +1,33 @@
package com.danyang.match.platform.content;
import com.danyang.match.platform.content.controller.ContentController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ContentControllerPermissionContractTest {
@Test
void everyContentMutationRequiresItsV11Permission() {
Map<String, String> expected = Map.of(
"createCategory", "hasAuthority('content.create')",
"updateCategory", "hasAuthority('content.edit')",
"createArticle", "hasAuthority('content.create')",
"updateArticle", "hasAuthority('content.edit')",
"publishArticle", "hasAuthority('content.publish')",
"offlineArticle", "hasAuthority('content.offline')");
expected.forEach((methodName, expression) -> {
var method = java.util.Arrays.stream(ContentController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst()
.orElseThrow();
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
assertNotNull(annotation, methodName + " 必须由服务端权限保护");
assertEquals(expression, annotation.value(), methodName + " 权限码不符合 V015");
});
}
}
@@ -0,0 +1,93 @@
package com.danyang.match.platform.counseling;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CounselingDataScopeTest {
@Mock CounselingServiceMapper counselingMapper;
@Mock CounselingFollowupMapper followupMapper;
@Mock OperatorMapper operatorMapper;
@Mock NotificationService notificationService;
@Mock UserMapper userMapper;
@Mock RiskReferralService riskReferralService;
@Mock AuditLogMapper auditLogMapper;
private CounselingServiceApp service;
@BeforeEach
void setUp() {
service = new CounselingServiceApp(counselingMapper, followupMapper, operatorMapper,
notificationService, userMapper, riskReferralService, new DataScopeGuard(auditLogMapper));
}
@Test
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
CounselingService counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
"service-1", new OperatorPrincipal("counselor-b", java.util.Set.of("counseling.service.process")), Instant.parse("2026-09-10T01:00:00Z"),
Instant.parse("2026-09-10T02:00:00Z")));
verify(counselingMapper, never()).updateById(any());
verify(auditLogMapper).insert(argThat(audit ->
"DATA_SCOPE_DENIED".equals(audit.getAction())
&& "counselor-b".equals(audit.getOperatorId())));
}
@Test
void assignedCounselorCanScheduleOwnService() {
CounselingService counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
Instant.parse("2026-09-10T01:00:00Z"), Instant.parse("2026-09-10T02:00:00Z"));
assertEquals("scheduled", result.getStatus());
verify(counselingMapper).updateById(counseling);
}
@Test
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
CounselingService counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
var result = service.getCounselorAssignedServices("counselor-a");
assertEquals(1, result.size());
assertTrue(result.getFirst().getFollowups() == null || result.getFirst().getFollowups().isEmpty());
verifyNoInteractions(followupMapper);
}
private CounselingService assignedService(String id, String operatorId) {
CounselingService service = new CounselingService();
service.setId(id);
service.setUserId("user-1");
service.setAssignedOperatorId(operatorId);
service.setStatus("assigned");
service.setCreatedAt(Instant.now());
return service;
}
}
@@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* P25-T02: Migration 最终验证
* 验证所有 V001-V014 迁移文件存在、命名规范、语法正确
* 验证所有 V001-V029 迁移文件存在、命名规范、语法正确
*/
class MigrationValidationTest {
@@ -22,8 +22,8 @@ class MigrationValidationTest {
@Test
void testAllMigrationsExist() {
// 预期 V001-V014
for (int i = 1; i <= 14; i++) {
// 预期 V001-V029
for (int i = 1; i <= 29; i++) {
String expected = String.format("V%03d__", i);
assertTrue(filesStartWith(expected),
"迁移文件缺失: " + expected + "*.sql");
@@ -47,10 +47,10 @@ class MigrationValidationTest {
"命名不规范: " + name);
// 版本号递增
int version = Integer.parseInt(name.substring(1, 4));
assertTrue(version >= 1 && version <= 14,
"版本号应在1-14之间: " + name);
assertTrue(version >= 1 && version <= 29,
"版本号应在1-29之间: " + name);
}
assertEquals(14, count, "应有14个迁移文件,实际" + count + "个");
assertEquals(29, count, "应有29个迁移文件,实际" + count + "个");
}
}
@@ -1,5 +1,8 @@
package com.danyang.match.platform.discover;
import com.danyang.match.platform.discover.service.DiscoverService;
import org.springframework.test.util.ReflectionTestUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@@ -16,6 +19,8 @@ class DiscoverServiceTest {
@Test
void testAgeNull() {
assertEquals(0, java.time.Period.between(null, java.time.LocalDate.now()).getYears());
DiscoverService service = new DiscoverService(null);
Integer age = ReflectionTestUtils.invokeMethod(service, "calculateAge", (java.time.LocalDate) null);
assertEquals(0, age);
}
}
@@ -148,7 +148,7 @@ class CoreInvariantsTest {
// 风险等级
assertNotNull(Constants.RISK_LEVEL_HIGH);
assertNotNull(Constants.RISK_LEVEL_WARNING);
assertNotNull(Constants.RISK_LEVEL_LOW);
assertNotNull(Constants.RISK_LEVEL_NORMAL);
// 本地资格类型
assertNotNull(Constants.LOCAL_ELIGIBILITY_TYPE_HUKOU);
assertNotNull(Constants.LOCAL_ELIGIBILITY_TYPE_WORK);
@@ -42,6 +42,7 @@ class P0FixVerificationTest {
@Autowired private RelationshipService relationshipService;
@Autowired private RelationshipStatusService statusService;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private com.danyang.match.platform.account.service.AccountLifecycleService accountLifecycleService;
private String userA, userB;
@@ -204,15 +205,9 @@ class P0FixVerificationTest {
@DisplayName("P0-3: 无进行中业务时允许注销")
void testWithdrawAllowedWhenNoActiveServices() {
// 用户A无任何活动报名,应允许注销
assertEquals(true,
((java.util.Map<?, ?>) com.danyang.match.platform.account.service.AccountLifecycleService.class
.getDeclaredFields()[0]).getType().equals(java.lang.Class.class));
// 简化验证:直接检查数据库
var result = new com.danyang.match.platform.account.service.AccountLifecycleService(
userMapper, null, null, null, null, null, null, null, null) {
// 这个测试主要是验证查询逻辑不再全表扫描
};
assertTrue(true); // 通过代码审查已验证查询条件修复
var result = accountLifecycleService.checkCanWithdraw(userA);
assertEquals(Boolean.TRUE, result.get("canWithdraw"));
assertTrue(((java.util.Map<?, ?>) result.get("blockers")).isEmpty());
}
@Test
@@ -1,9 +1,9 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
import com.danyang.match.platform.match.entity.UserPreference;
import com.danyang.match.platform.match.mapper.UserPreferenceMapper;
import com.danyang.match.platform.match.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -1,7 +1,6 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
import com.danyang.match.platform.match.entity.ScoredCandidate;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.fasterxml.jackson.databind.JsonNode;
@@ -39,8 +38,6 @@ class RecommendationEngineBidirectionalRequiredTest {
@BeforeEach
void setUp() {
config.setMinDailyLimit(1);
config.setMaxDailyLimit(20);
config.setDefaultScoreThreshold(60);
config.setDefaultCooldownDays(180);
}
@@ -55,8 +52,8 @@ class RecommendationEngineBidirectionalRequiredTest {
myPref.minAge = 25;
myPref.maxAge = 30;
UserProfile candidate = buildProfile(1995, 170, "321181", "never_married"); // 30岁(2025-1995=30)→ 刚好边界
UserProfile myProfile = buildProfile(1990, 175, "321181", "never_married"); // 35岁
UserProfile candidate = buildProfile(LocalDate.now().getYear() - 30, 170, "321181", "never_married"); // 30岁,刚好边界
UserProfile myProfile = buildProfile(LocalDate.now().getYear() - 35, 175, "321181", "never_married"); // 35岁
// 候选B的年龄偏好也是Required:B要求A在28-40之间 → A(35)符合
TestPreference targetPref = new TestPreference();
@@ -71,7 +68,7 @@ class RecommendationEngineBidirectionalRequiredTest {
);
// 现在B年龄=31,超出A要求的[25,30]
UserProfile tooOld = buildProfile(1994, 170, "321181", "never_married"); // 31岁
UserProfile tooOld = buildProfile(LocalDate.now().getYear() - 31, 170, "321181", "never_married"); // 31岁
assertFalse(
evaluateRequired(myPref, tooOld, targetPref, myProfile),
"B年龄31超出A要求的[25,30],应被过滤"
@@ -11,7 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class RecommendationEngineServiceTest {
class RecommendationEngineServiceMockTest {
@InjectMocks
private RecommendationEngineService engineService;
@@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.*;
class MessageRiskServiceTest {
private final MessageRiskService riskService = new MessageRiskService();
private final MessageRiskService riskService = new MessageRiskService(null);
@Test
void testNormalContent() {
@@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.*;
class PhotoServiceTest {
private final PhotoService photoService = new PhotoService();
private final PhotoService photoService = new PhotoService(null, null);
@Test
void testValidateMagicNumberJpeg() {
@@ -0,0 +1,38 @@
package com.danyang.match.platform.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.security.AuthorizationAuditListener;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.event.AuthorizationDeniedEvent;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AuthorizationAuditListenerTest {
@Test
void deniedOperatorAuthorizationIsWrittenToAuditLog() {
AuditLogMapper mapper = mock(AuditLogMapper.class);
AuthorizationAuditListener listener = new AuthorizationAuditListener(mapper);
OperatorPrincipal principal = new OperatorPrincipal("operator-1", Set.of("content.view"));
var authentication = UsernamePasswordAuthenticationToken.authenticated(principal, null, java.util.List.of());
var event = new AuthorizationDeniedEvent<>(() -> authentication,
"ContentController.publishArticle", new AuthorizationDecision(false));
listener.onAuthorizationDenied(event);
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(mapper).insert(captor.capture());
assertEquals("operator-1", captor.getValue().getOperatorId());
assertEquals("AUTHORIZATION_DENIED", captor.getValue().getAction());
assertEquals("authorization", captor.getValue().getResourceType());
assertNotNull(captor.getValue().getCreatedAt());
}
}
@@ -0,0 +1,80 @@
package com.danyang.match.platform.security;
import com.danyang.match.platform.account.controller.AccountOperatorController;
import com.danyang.match.platform.activity.controller.*;
import com.danyang.match.platform.analytics.AnalyticsController;
import com.danyang.match.platform.appeal.controller.AppealOperatorController;
import com.danyang.match.platform.audit.controller.AuditLogController;
import com.danyang.match.platform.content.controller.ContentController;
import com.danyang.match.platform.counseling.controller.CounselingController;
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
import com.danyang.match.platform.operator.controller.OperatorController;
import com.danyang.match.platform.organization.controller.OrganizationController;
import com.danyang.match.platform.referral.controller.ReferralController;
import com.danyang.match.platform.review.controller.ReviewController;
import com.danyang.match.platform.risk.controller.ReportOperatorController;
import com.danyang.match.platform.risk.controller.RiskController;
import com.danyang.match.platform.service_appointment.controller.ServiceAppointmentController;
import com.danyang.match.platform.service_program.controller.ServiceProgramController;
import com.danyang.match.platform.shared.controller.FeatureFlagController;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.controller.UserController;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class OperatorEndpointPermissionCoverageTest {
private static final List<Class<?>> CONTROLLERS = List.of(
AccountOperatorController.class, ActivityController.class,
ActivityInterestController.class, ActivityOperatorController.class,
CheckinController.class, AnalyticsController.class,
AppealOperatorController.class, AuditLogController.class,
ContentController.class, CounselingController.class,
MatchmakerOperatorController.class, OperatorController.class,
OrganizationController.class, ReviewController.class,
ReferralController.class,
ReportOperatorController.class, RiskController.class,
ServiceAppointmentController.class, ServiceProgramController.class,
FeatureFlagController.class, UserController.class);
@Test
void everyOperatorEndpointHasExplicitPermissionContract() {
List<String> missing = new ArrayList<>();
for (Class<?> controller : CONTROLLERS) {
boolean operatorController = paths(controller).stream().anyMatch(this::isOperatorPath);
for (Method method : controller.getDeclaredMethods()) {
boolean operatorMethod = paths(method).stream().anyMatch(this::isOperatorPath);
boolean operatorPrincipal = Arrays.stream(method.getParameterTypes())
.anyMatch(OperatorPrincipal.class::equals);
if (!operatorController && !operatorMethod && !operatorPrincipal) {
continue;
}
boolean secured = AnnotatedElementUtils.hasAnnotation(controller, PreAuthorize.class)
|| AnnotatedElementUtils.hasAnnotation(method, PreAuthorize.class);
if (!secured) {
missing.add(controller.getSimpleName() + "." + method.getName());
}
}
}
assertEquals(List.of(), missing, "以下运营端接口缺少显式 Permission;不能依赖 /operator/** 粗过滤器");
}
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
? mapping.path() : mapping.value());
}
private boolean isOperatorPath(String path) {
return path.equals("/operator") || path.startsWith("/operator/");
}
}
@@ -0,0 +1,41 @@
package com.danyang.match.platform.security;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PrincipalInjectionContractTest {
@Test
void controllersMustUseSecurityPrincipalsInsteadOfParsingAuthorizationHeaders() throws IOException {
Path controllers = Path.of("backend/src/main/java/com/danyang/match/platform");
List<String> violations = new ArrayList<>();
try (var files = Files.walk(controllers)) {
files.filter(path -> path.getFileName().toString().endsWith("Controller.java"))
.forEach(path -> inspect(path, violations));
}
assertEquals(List.of(), violations,
"控制器必须使用 UserPrincipal/OperatorPrincipal,不能自行读取或解析 Authorization/JWT");
}
private void inspect(Path path, List<String> violations) {
try {
String source = Files.readString(path);
if (source.contains("JwtTokenService")
|| source.contains("RequestHeader(\"Authorization\")")
|| source.contains("RequestHeader(value = \"Authorization\"")) {
violations.add(path.toString());
}
} catch (IOException error) {
throw new IllegalStateException("无法读取控制器源码: " + path, error);
}
}
}
@@ -11,6 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.util.HtmlUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -90,7 +91,7 @@ class SecurityTests {
// 验证所有 Mapper 使用 @Param 注解的参数化查询
String[] dangerousPatterns = {"DROP TABLE", "'; --", "1=1 OR 1=1"};
for (String pattern : dangerousPatterns) {
assertFalse(pattern.matches(".*[;']", "SQL注入模式不应出现在正常输入中"));
assertFalse(pattern.matches(".*[;']"), "SQL注入模式不应出现在正常输入中");
}
}
@@ -108,7 +109,9 @@ class SecurityTests {
// 用户输入的 description 字段在展示时应被转义
// 前端框架(Vue/小程序)默认转义,后端存储原始值
String xssPayload = "<script>alert('xss')</script>";
assertFalse(xssPayload.contains("<script>"), "XSS payload should be detected");
String escaped = HtmlUtils.htmlEscape(xssPayload);
assertFalse(escaped.contains("<script>"), "转义结果不应包含可执行 script 标签");
assertTrue(escaped.contains("&lt;script&gt;"), "script 标签必须被 HTML 转义");
}
@Test
@@ -0,0 +1,109 @@
package com.danyang.match.platform.security;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.security.AuthorizationAuditListener;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorizationEventPublisher;
import org.springframework.security.authorization.SpringAuthorizationEventPublisher;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
class V11MethodSecurityMatrixTest {
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
@Test
void v11RolesEnforcePositiveAndNegativePermissionsAndAuditEveryDenial() {
try (var context = new AnnotationConfigApplicationContext(TestSecurityConfig.class)) {
SecuredOperations secured = context.getBean(SecuredOperations.class);
AuditLogMapper auditLogMapper = context.getBean(AuditLogMapper.class);
authenticate("content-editor", Set.of("content.edit"));
assertDoesNotThrow(secured::editContent);
assertThrows(AccessDeniedException.class, secured::viewUserData);
authenticate("counselor", Set.of("counseling.service.view", "counseling.service.process"));
assertDoesNotThrow(secured::processAssignedCounseling);
assertThrows(AccessDeniedException.class, secured::assignCounseling);
authenticate("station-operator", Set.of("service_appointment.confirm", "activity.checkin"));
assertDoesNotThrow(secured::confirmStationAppointment);
assertThrows(AccessDeniedException.class, secured::viewUserData);
// Organization/Sponsor relationship is deliberately absent from authorities.
authenticate("sponsor-organization-operator", Set.of());
assertThrows(AccessDeniedException.class, secured::viewUserData);
verify(auditLogMapper, times(4)).insert(org.mockito.ArgumentMatchers.any());
}
}
private void authenticate(String operatorId, Set<String> permissions) {
OperatorPrincipal principal = new OperatorPrincipal(operatorId, permissions);
var authorities = permissions.stream().map(SimpleGrantedAuthority::new).toList();
var authentication = UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@Configuration
@EnableMethodSecurity
static class TestSecurityConfig {
@Bean
AuditLogMapper auditLogMapper() {
return mock(AuditLogMapper.class);
}
@Bean
AuthorizationAuditListener authorizationAuditListener(AuditLogMapper mapper) {
return new AuthorizationAuditListener(mapper);
}
@Bean
AuthorizationEventPublisher authorizationEventPublisher(ApplicationEventPublisher publisher) {
return new SpringAuthorizationEventPublisher(publisher);
}
@Bean
SecuredOperations securedOperations() {
return new SecuredOperations();
}
}
static class SecuredOperations {
@PreAuthorize("hasAuthority('content.edit')")
void editContent() {}
@PreAuthorize("hasAuthority('user.view')")
void viewUserData() {}
@PreAuthorize("hasAuthority('counseling.service.process')")
void processAssignedCounseling() {}
@PreAuthorize("hasAuthority('counseling.service.assign')")
void assignCounseling() {}
@PreAuthorize("hasAuthority('service_appointment.confirm')")
void confirmStationAppointment() {}
}
}
@@ -0,0 +1,66 @@
package com.danyang.match.platform.security;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class V11PermissionMatrixContractTest {
private static final String STATION = "00000000-0000-0000-0000-000000000010";
private static final String COUNSELOR = "00000000-0000-0000-0000-000000000011";
private static final String COUNSELING_MANAGER = "00000000-0000-0000-0000-000000000012";
private static final String CONTENT_EDITOR = "00000000-0000-0000-0000-000000000013";
@Test
void v11RolesMatchCustomerPermissionBaseline() throws IOException {
String migrations = Files.readString(Path.of("backend/src/main/resources/db/migration/V015__add_v11_roles_and_permissions.sql"))
+ Files.readString(Path.of("backend/src/main/resources/db/migration/V029__complete_service_station_permissions.sql"));
assertEquals(Set.of(
"service_location.view", "service_appointment.view",
"service_appointment.confirm", "service_appointment.complete",
"activity.checkin", "service.assisted_registration"), permissionsFor(migrations, STATION));
assertEquals(Set.of(
"counseling.service.view", "counseling.service.process",
"counseling.note.create"), permissionsFor(migrations, COUNSELOR));
assertEquals(Set.of(
"counseling.service.view", "counseling.service.assign",
"counseling.service.process", "counseling.note.view"),
permissionsFor(migrations, COUNSELING_MANAGER));
assertEquals(Set.of(
"content.view", "content.create", "content.edit",
"content.publish", "content.offline"), permissionsFor(migrations, CONTENT_EDITOR));
for (String role : Set.of(STATION, COUNSELOR, COUNSELING_MANAGER, CONTENT_EDITOR)) {
Set<String> permissions = permissionsFor(migrations, role);
assertFalse(permissions.contains("user.view"));
assertFalse(permissions.contains("identity.sensitive.view"));
assertFalse(permissions.contains("message.content.view"));
}
}
private Set<String> permissionsFor(String sql, String roleId) {
Pattern grant = Pattern.compile("SELECT\\s+'" + Pattern.quote(roleId)
+ "',\\s*id\\s+FROM\\s+permissions\\s+WHERE\\s+code\\s+IN\\s*\\(([^)]*)\\)",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
Matcher grants = grant.matcher(sql);
Set<String> permissions = new HashSet<>();
Pattern code = Pattern.compile("'([^']+)'");
while (grants.find()) {
Matcher codes = code.matcher(grants.group(1));
while (codes.find()) {
permissions.add(codes.group(1));
}
}
return permissions;
}
}
@@ -0,0 +1,85 @@
package com.danyang.match.platform.service_appointment;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
import com.danyang.match.platform.service_appointment.service.ServiceAppointmentService;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ServiceAppointmentDataScopeTest {
@Mock ServiceAppointmentMapper appointmentMapper;
@Mock ServiceLocationMapper locationMapper;
@Mock NotificationService notificationService;
@Mock RiskReferralService riskReferralService;
@Mock AuditLogMapper auditLogMapper;
private ServiceAppointmentService service;
@BeforeEach
void setUp() {
service = new ServiceAppointmentService(appointmentMapper, locationMapper,
notificationService, riskReferralService, new DataScopeGuard(auditLogMapper));
}
@Test
void userCannotReadAnotherUsersAppointmentById() {
ServiceAppointment appointment = appointment("appointment-1", "user-a", "operator-a");
when(appointmentMapper.selectById("appointment-1")).thenReturn(appointment);
assertNull(service.getAppointmentDetail("appointment-1", "user-b"));
}
@Test
void stationOperatorListUsesServerSideAccessibleScope() {
ServiceAppointment appointment = appointment("appointment-1", "user-a", "operator-a");
when(appointmentMapper.findAccessibleByOperator("operator-a")).thenReturn(List.of(appointment));
var result = service.getOperatorAppointments("operator-a");
assertEquals(1, result.size());
verify(appointmentMapper).findAccessibleByOperator("operator-a");
verify(appointmentMapper, never()).findByUserId("operator-a");
}
@Test
void stationOperatorCannotCompleteAppointmentOutsideStationOrTaskScope() {
ServiceAppointment appointment = appointment("appointment-1", "user-a", "operator-a");
appointment.setStatus("confirmed");
when(appointmentMapper.selectById("appointment-1")).thenReturn(appointment);
when(appointmentMapper.canAccessByOperator("appointment-1", "operator-b")).thenReturn(false);
assertThrows(AccessDeniedException.class,
() -> service.completeAppointment("appointment-1", "operator-b"));
verify(appointmentMapper, never()).updateById(any());
verify(auditLogMapper).insert(argThat(audit ->
"DATA_SCOPE_DENIED".equals(audit.getAction())
&& "operator-b".equals(audit.getOperatorId())));
}
private ServiceAppointment appointment(String id, String userId, String operatorId) {
ServiceAppointment appointment = new ServiceAppointment();
appointment.setId(id);
appointment.setUserId(userId);
appointment.setAssignedOperatorId(operatorId);
appointment.setServiceLocationId("station-1");
appointment.setStatus("submitted");
appointment.setCreatedAt(Instant.now());
return appointment;
}
}
@@ -1,13 +1,19 @@
package com.danyang.match.platform.shared.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@SpringBootTest
@AutoConfigureMockMvc
@@ -16,9 +22,21 @@ class HealthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private StringRedisTemplate redisTemplate;
@MockBean
private ValueOperations<String, String> valueOperations;
@BeforeEach
void setUpRedisHealthStub() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("_health_check_")).thenReturn("ok");
}
@Test
void testHealthLive() throws Exception {
mockMvc.perform(get("/api/v1/health/live"))
mockMvc.perform(get("/api/v1/health/live").contextPath("/api/v1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("UP"));
@@ -26,7 +44,7 @@ class HealthControllerTest {
@Test
void testHealthReady() throws Exception {
mockMvc.perform(get("/api/v1/health/ready"))
mockMvc.perform(get("/api/v1/health/ready").contextPath("/api/v1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("READY"));
@@ -34,7 +52,7 @@ class HealthControllerTest {
@Test
void testVersion() throws Exception {
mockMvc.perform(get("/api/v1/version"))
mockMvc.perform(get("/api/v1/version").contextPath("/api/v1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@@ -0,0 +1,101 @@
package com.danyang.match.platform.shared.filter;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.service.PermissionService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import io.jsonwebtoken.Claims;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class JwtAuthFilterTest {
@Mock private JwtTokenService jwtTokenService;
@Mock private PermissionService permissionService;
@Mock private Claims claims;
@InjectMocks private JwtAuthFilter filter;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void operatorTokenCreatesAuthenticatedOperatorPrincipalWithPermissionAuthorities() throws Exception {
String token = "operator-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", "content.publish"));
MockHttpServletRequest request = authenticatedRequest(token);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertTrue(authentication.isAuthenticated());
assertInstanceOf(OperatorPrincipal.class, authentication.getPrincipal());
assertEquals("operator-1", ((OperatorPrincipal) authentication.getPrincipal()).id());
assertTrue(authentication.getAuthorities().stream()
.anyMatch(authority -> "content.publish".equals(authority.getAuthority())));
assertTrue(authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_OPERATOR".equals(authority.getAuthority())));
}
@Test
void userTokenCreatesUserPrincipalWithoutLoadingOperatorPermissions() throws Exception {
String token = "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");
MockHttpServletRequest request = authenticatedRequest(token);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
assertEquals("user-1", ((UserPrincipal) authentication.getPrincipal()).id());
assertFalse(authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_OPERATOR".equals(authority.getAuthority())));
verifyNoInteractions(permissionService);
}
@Test
void invalidTokenReturns401AndDoesNotAuthenticate() throws Exception {
String token = "invalid-token";
when(jwtTokenService.validateToken(token)).thenReturn(false);
MockHttpServletRequest request = authenticatedRequest(token);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertEquals(401, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
private MockHttpServletRequest authenticatedRequest(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + token);
return request;
}
}
@@ -22,3 +22,11 @@ features:
matchmaker-enabled: true
chat-image-enabled: false
appeal-enabled: false
object-storage:
endpoint: http://localhost:9000
access-key: unit-test-access-key
secret-key: unit-test-secret-key
bucket-public: unit-test-public
bucket-private: unit-test-private
region: us-east-1