feat: complete phase 02 RBAC and data scope recovery
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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);
|
||||
|
||||
+4
-9
@@ -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
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+3
-6
@@ -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],应被过滤"
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+80
@@ -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/");
|
||||
}
|
||||
}
|
||||
+41
@@ -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("<script>"), "script 标签必须被 HTML 转义");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+109
@@ -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() {}
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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));
|
||||
}
|
||||
|
||||
+101
@@ -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
|
||||
Reference in New Issue
Block a user