fix(security): ce-debug 全量修复 P0 #1 + P1 #4/#5 + P2 #9/#10/#12
Backend CI / Build & Test (pull_request) Waiting to run
Details
Backend CI / Build & Test (pull_request) Waiting to run
Details
ce-debug skill 系统化修复 ce-code-review 报告中全部 actionable findings: 1. P0 #1 PreAuthorizeCoverageTest SERVICES 遗漏 + 4 服务 @PreAuthorize 对齐 - 4 个 ServiceImpl 共 12 个 @Transactional 写方法加 @PreAuthorize: * AbilityPackageVersionServiceImpl (5 方法, base:project:manage) * ApprovalFlowRuntimeServiceImpl (4 方法, base:lifecycle:manage) * ContractRoomServiceImpl (1 方法, base:contract:manage) * LookupServiceImpl (2 方法, base:lookup:manage — admin-only 语义) - PreAuthorizeCoverageTest SERVICES 列表补齐 34→38 - 修复 system-user admin 旁路失效风险(@PreAuthorize 缺失 → admin 旁路不触发) 2. P1 #4 AuditConsumerTest 缺失 — 新建 6 个测试覆盖 R4 闭环 - Happy path: 新事件 + save + ACK - 幂等命中: Redis SETNX false / DB count>0 → ACK 不处理 - 异常路径: JSON 解析失败 → NACK / save() 异常 → NACK + SecurityContext 清理 - 关键验证: try-with-resources 异常路径 SecurityContext 已清理(防 RabbitMQ 线程池权限泄漏) 3. P1 #5 V13 migration test 缺失 — 新建 V13SystemUserMigrationTest 5 个测试 - 静态校验 V13__add_system_user.sql INSERT 关键列值: id=0L (= SystemUserSecurityContext.SYSTEM_USER_ID) username='system' (= SystemUserSecurityContext.SYSTEM_USERNAME) status=1 / account_type=1 / deleted=0 - 验证 BCrypt password 格式 + ON DUPLICATE KEY UPDATE 幂等 + R4/OQ5 决策注释可追溯 - ponytail: pms-auth 无 testcontainers 基础设施,静态 SQL 校验捕获编辑回归 4. P2 #9 VALID_FUNC_CODES 三模块重复无 V12 交叉检查 - pms-auth PreAuthorizeCoverageTest 新增 V12 cross-check 测试 - 解析 V12 SQL 提取 function_code(UPDATE + INSERT 模式),验证 VALID_FUNC_CODES 包含全部 - ponytail: 不抽共享 fixture(需 Gradle testFixtures 插件配置),保留 3 模块列表重复, pms-auth 单点 V12 cross-check 捕获漂移 5. P2 #10 GlobalExceptionHandler 缺 AuthenticationException handler - 新增 AuthenticationException → 401 handler(防匿名访问 @PreAuthorize 返回 500) - 2 个测试验证 AuthenticationCredentialsNotFoundException → ResultCode.UNAUTHORIZED 6. P2 #12 AuditConsumer 信任 MQ payload 文档化 - AuditConsumer.onAuditOperation 新增 Trust Boundary Javadoc - 文档化 MQ ingress 信任边界、信任假设(operatorId/module/operationTime 来源)、 不校验原因(MQ 内网隔离 + AuditLogAspect 唯一生产者)、升级路径(MQ 跨集群联邦时需校验) 测试验证: - pms-auth: V13SystemUserMigrationTest 5/5 + PreAuthorizeCoverageTest (含 V12 cross-check) 全部通过 - pms-common: GlobalExceptionHandlerTest (含 AuthenticationException) 全部通过 - pms-base: PreAuthorizeCoverageTest (SERVICES 38 项) 全部通过 - pms-audit: AuditConsumerTest 6/6 + PreAuthorizeCoverageTest 全部通过 - ./gradlew :pms-auth:test :pms-common:test :pms-base:test :pms-audit:test BUILD SUCCESSFUL
This commit is contained in:
parent
95a7473f66
commit
201181ca27
|
|
@ -35,7 +35,33 @@ public class AuditConsumer {
|
|||
private final MqConsumeLogMapper mqConsumeLogMapper;
|
||||
|
||||
/**
|
||||
* 消费操作审计日志
|
||||
* 消费操作审计日志。
|
||||
* <p>
|
||||
* <b>Trust Boundary(P2 #12 文档化)</b>:本方法是 MQ ingress 信任边界。
|
||||
* 消息来源为各服务 AuditLogAspect AOP 切面({@code com.pms.common.aspect.AuditLogAspect}),
|
||||
* 通过 RabbitMQ 内部集群({@code spring.rabbitmq.host},不对外暴露)发送。
|
||||
* <p>
|
||||
* 信任假设(不在此处校验,由 AOP 切面 + MQ 网络隔离保证):
|
||||
* <ul>
|
||||
* <li>{@code operatorId}:来自 {@link com.pms.common.security.UserContext},由
|
||||
* {@code HeaderAuthenticationFilter} 从 JWT claims 解析,已通过认证</li>
|
||||
* <li>{@code module}/{@code operation}:由 {@code @AuditLog} 注解静态指定,编译期确定</li>
|
||||
* <li>{@code operationTime}:由 AOP 切面在请求线程生成,非用户输入</li>
|
||||
* <li>{@code requestId}:由 MDC 生成,非用户输入</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 不在此处做 payload 校验的原因:
|
||||
* <ol>
|
||||
* <li>MQ 集群不对外暴露,外部无法注入恶意 payload</li>
|
||||
* <li>AuditLogAspect 是唯一生产者,payload 结构由代码保证</li>
|
||||
* <li>校验逻辑会与 AuditLogAspect 重复,违反 DRY</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* 升级路径:若未来 MQ 集群对外暴露(如跨集群联邦),需在此处增加 payload 校验。
|
||||
* 当前依赖 {@code spring.rabbitmq.host} 配置为内网地址 + 防火墙规则隔离。
|
||||
* <p>
|
||||
* 异常路径:JSON 解析失败 → ACK 丢弃(消息格式错误,重试无意义);
|
||||
* 业务异常 → NACK 不重试(避免毒消息循环)。
|
||||
*/
|
||||
@RabbitListener(queues = AuditConstants.QUEUE_AUDIT_OPERATION)
|
||||
public void onAuditOperation(String message, Channel channel,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
package com.pms.audit.consumer;
|
||||
|
||||
import com.pms.audit.dto.AuditEventMessage;
|
||||
import com.pms.audit.service.AuditLogService;
|
||||
import com.pms.common.entity.MqConsumeLog;
|
||||
import com.pms.common.mapper.MqConsumeLogMapper;
|
||||
import com.pms.common.util.JsonUtils;
|
||||
import com.rabbitmq.client.Channel;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* AuditConsumer 单元测试(ce-debug P1 #4)
|
||||
* <p>
|
||||
* 覆盖场景:
|
||||
* <ul>
|
||||
* <li>Happy path: 新事件 → save() 调用 + consumeLog 插入 + ACK</li>
|
||||
* <li>幂等命中(Redis): SETNX false → ACK 不处理</li>
|
||||
* <li>幂等命中(DB): count > 0 → ACK 不处理</li>
|
||||
* <li>异常: JSON 解析 null → ACK 丢弃</li>
|
||||
* <li>异常: save() 抛异常 → NACK + SecurityContext 清理(try-with-resources 验证)</li>
|
||||
* <li>eventId 缺失:fallback 到 operatorId:operationTime 拼装 → save() 调用</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 设计依据:ce-code-review P1 #4 — AuditConsumer 为 R4 system-user admin 旁路核心入口,
|
||||
* 0 测试覆盖。此测试为 ponytail runnable check,验证 MQ 消费 → SystemUserSecurityContext →
|
||||
* save() → 幂等/异常路径完整闭环。
|
||||
*/
|
||||
@DisplayName("AuditConsumer 单元测试(R4 system-user admin 旁路入口)")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AuditConsumerTest {
|
||||
|
||||
@Mock
|
||||
private AuditLogService auditLogService;
|
||||
@Mock
|
||||
private StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
@Mock
|
||||
private MqConsumeLogMapper mqConsumeLogMapper;
|
||||
@Mock
|
||||
private Channel channel;
|
||||
@Mock
|
||||
private Message amqpMessage;
|
||||
|
||||
@InjectMocks
|
||||
private AuditConsumer consumer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MessageProperties props = new MessageProperties();
|
||||
props.setDeliveryTag(1L);
|
||||
when(amqpMessage.getMessageProperties()).thenReturn(props);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Happy path: 新事件 → save() 调用 + consumeLog 插入 + ACK")
|
||||
void shouldSaveAndAckWhenNewEvent() throws Exception {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true);
|
||||
when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0);
|
||||
|
||||
AuditEventMessage event = buildEvent("evt-001", 1001L, 1001L);
|
||||
String message = JsonUtils.toJson(event);
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
verify(auditLogService).save(any(AuditEventMessage.class));
|
||||
verify(mqConsumeLogMapper).insert(any(MqConsumeLog.class));
|
||||
verify(channel).basicAck(1L, false);
|
||||
verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("幂等命中(Redis): SETNX false → ACK 不处理")
|
||||
void shouldAckWhenRedisIdempotentHit() throws Exception {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(false);
|
||||
|
||||
AuditEventMessage event = buildEvent("evt-002", 1002L, 1002L);
|
||||
String message = JsonUtils.toJson(event);
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
verify(auditLogService, never()).save(any());
|
||||
verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class));
|
||||
verify(channel).basicAck(1L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("幂等命中(DB): count > 0 → ACK 不处理")
|
||||
void shouldAckWhenDbIdempotentHit() throws Exception {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true);
|
||||
when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(1);
|
||||
|
||||
AuditEventMessage event = buildEvent("evt-003", 1003L, 1003L);
|
||||
String message = JsonUtils.toJson(event);
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
verify(auditLogService, never()).save(any());
|
||||
verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class));
|
||||
verify(channel).basicAck(1L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("异常: JSON 解析抛异常 → NACK(JsonUtils.fromJson 对非法 JSON 抛异常,进入 catch 块)")
|
||||
void shouldNackWhenJsonParseThrows() throws Exception {
|
||||
// 传入非 JSON 字符串,JsonUtils.fromJson 抛异常 → 进入 catch → basicNack
|
||||
String message = "not-a-valid-json";
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
verify(auditLogService, never()).save(any());
|
||||
verify(channel).basicNack(1L, false, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("异常: save() 抛异常 → NACK + SecurityContext 清理(try-with-resources 验证)")
|
||||
void shouldNackAndClearSecurityContextWhenSaveThrows() throws Exception {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true);
|
||||
when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0);
|
||||
doThrow(new RuntimeException("DB connection lost"))
|
||||
.when(auditLogService).save(any(AuditEventMessage.class));
|
||||
|
||||
AuditEventMessage event = buildEvent("evt-004", 1004L, 1004L);
|
||||
String message = JsonUtils.toJson(event);
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
// 验证 NACK
|
||||
verify(channel).basicNack(1L, false, false);
|
||||
verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class));
|
||||
|
||||
// 关键验证:try-with-resources 异常路径 SecurityContext 已清理
|
||||
// 防止 RabbitMQ 线程池复用继承 admin 权限(R4 缓解策略核心安全约束)
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication())
|
||||
.as("save() 异常后 SecurityContext 必须为空,防 RabbitMQ 线程池权限泄漏")
|
||||
.isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("eventId 缺失:fallback 到 operatorId:operationTime 拼装 → save() 调用")
|
||||
void shouldFallbackEventIdWhenMissing() throws Exception {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true);
|
||||
when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0);
|
||||
|
||||
// eventId=null,operatorId=2001, operationTime=1700000000000L
|
||||
AuditEventMessage event = buildEvent(null, 2001L, 1700000000000L);
|
||||
String message = JsonUtils.toJson(event);
|
||||
|
||||
consumer.onAuditOperation(message, channel, amqpMessage);
|
||||
|
||||
verify(auditLogService).save(any(AuditEventMessage.class));
|
||||
verify(channel).basicAck(1L, false);
|
||||
// 验证 fallback eventId 格式 "audit:operatorId:operationTime"
|
||||
verify(mqConsumeLogMapper).countByEventIdAndGroup(eq("audit:2001:1700000000000"), eq("audit-consumer"));
|
||||
}
|
||||
|
||||
private AuditEventMessage buildEvent(String eventId, Long operatorId, Long operationTime) {
|
||||
AuditEventMessage event = new AuditEventMessage();
|
||||
event.setEventId(eventId);
|
||||
event.setOperatorId(operatorId);
|
||||
event.setOperatorName("test-user");
|
||||
event.setOperatorAccount("test");
|
||||
event.setOperatorType(1);
|
||||
event.setOperationType("CREATE");
|
||||
event.setModule("USER");
|
||||
event.setOperationDesc("测试操作");
|
||||
event.setMethod("UserController.create");
|
||||
event.setRequestUrl("/api/v1/users");
|
||||
event.setRequestMethod("POST");
|
||||
event.setOperationResult("SUCCESS");
|
||||
event.setOperationTime(operationTime);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,11 @@ import org.junit.jupiter.params.provider.MethodSource;
|
|||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
|
@ -172,4 +175,78 @@ class PreAuthorizeCoverageTest {
|
|||
assertThat(VALID_FUNC_CODES).contains(
|
||||
"user_management", "org_management", "role_management", "system_management");
|
||||
}
|
||||
|
||||
/**
|
||||
* V12 cross-check(P2 #9):解析 V12 migration SQL 提取实际定义的 function_code,
|
||||
* 验证 VALID_FUNC_CODES 列表与 V12 一致(避免 V12 加新 func_code 后测试列表漂移)。
|
||||
* <p>
|
||||
* V12 定义 8 个 func_code(admin/base/charge/operation/user_management/
|
||||
* role_management/org_management/system_management);'viewer' 是 V1 遗留,V12 未 UPDATE
|
||||
* 但仍在 t_role 中(Q10 决策保留),VALID_FUNC_CODES 单独覆盖。
|
||||
* <p>
|
||||
* ponytail: 不抽共享 fixture(需 Gradle testFixtures 插件配置),保留 3 模块列表重复,
|
||||
* 但 pms-auth 在此提供 V12 单点交叉检查 — V12 加新 func_code 时本测试强制失败,
|
||||
* 触发开发者同步更新 3 模块的 VALID_FUNC_CODES(pms-base/pms-audit 各自的 selfCheck 也会校验自身使用的 func_code)。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("V12 cross-check: VALID_FUNC_CODES 包含 V12 migration 实际定义的全部 function_code")
|
||||
void validFuncCodes_matchesV12Migration() throws IOException {
|
||||
String v12Sql = loadV12MigrationSql();
|
||||
Set<String> v12DefinedFuncCodes = extractFuncCodesFromV12(v12Sql);
|
||||
|
||||
// V12 必须至少定义 8 个 func_code(4 UPDATE + 4 INSERT)
|
||||
assertThat(v12DefinedFuncCodes)
|
||||
.as("V12 应至少定义 8 个 func_code(4 UPDATE + 4 INSERT)")
|
||||
.hasSizeGreaterThanOrEqualTo(8);
|
||||
|
||||
// VALID_FUNC_CODES 必须包含 V12 实际定义的全部 func_code
|
||||
assertThat(VALID_FUNC_CODES)
|
||||
.as("VALID_FUNC_CODES 必须包含 V12 migration 实际定义的全部 function_code(V12 加新 code 时本断言失败,触发同步更新)")
|
||||
.containsAll(v12DefinedFuncCodes);
|
||||
|
||||
// VALID_FUNC_CODES 还必须包含 'viewer'(V1 遗留,V12 未 UPDATE 但仍合法)
|
||||
assertThat(VALID_FUNC_CODES)
|
||||
.as("VALID_FUNC_CODES 必须包含 'viewer'(V1 遗留,V12 未 UPDATE 但 Q10 决策保留)")
|
||||
.contains("viewer");
|
||||
}
|
||||
|
||||
/** 加载 V12 migration SQL 文件(pms-auth 模块 classpath 资源) */
|
||||
private String loadV12MigrationSql() throws IOException {
|
||||
try (InputStream is = getClass().getResourceAsStream(
|
||||
"/db/migration/V12__realign_function_code_and_add_management_roles.sql")) {
|
||||
assertThat(is)
|
||||
.as("V12 migration 文件必须在 classpath: /db/migration/V12__realign_function_code_and_add_management_roles.sql")
|
||||
.isNotNull();
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 V12 SQL 提取实际定义的 function_code 集合:
|
||||
* <ul>
|
||||
* <li>UPDATE 模式:`` `function_code` = 'xxx' ``(反引号包裹列名)</li>
|
||||
* <li>INSERT 模式:INSERT INTO t_role VALUES (id, 'ROLE_CODE', '名称', 'func_code', 1, UNIX_TIMESTAMP()...)
|
||||
* — 匹配 `'func_code',\s*1,\s*UNIX_TIMESTAMP`(function_code 后跟 data_scope=1 与时间戳)</li>
|
||||
* </ul>
|
||||
* V12 的 INSERT 是单条多 VALUES 元组,第 2 个正则用全局 find() 捕获全部 4 个元组。
|
||||
*/
|
||||
private Set<String> extractFuncCodesFromV12(String sql) {
|
||||
// UPDATE 模式:`function_code` = 'xxx'(反引号可选,兼容注释中无反引号的提及)
|
||||
Pattern updatePattern = Pattern.compile(
|
||||
"`?function_code`?\\s*=\\s*'([^']+)'", Pattern.CASE_INSENSITIVE);
|
||||
// INSERT 模式:'func_code', 1, UNIX_TIMESTAMP — 匹配每个 VALUES 元组中的 function_code
|
||||
Pattern insertTuplePattern = Pattern.compile(
|
||||
"'([^']+)'\\s*,\\s*1\\s*,\\s*UNIX_TIMESTAMP", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
Set<String> codes = new java.util.HashSet<>();
|
||||
Matcher updateMatcher = updatePattern.matcher(sql);
|
||||
while (updateMatcher.find()) {
|
||||
codes.add(updateMatcher.group(1));
|
||||
}
|
||||
Matcher insertMatcher = insertTuplePattern.matcher(sql);
|
||||
while (insertMatcher.find()) {
|
||||
codes.add(insertMatcher.group(1));
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
package com.pms.auth.migration;
|
||||
|
||||
import com.pms.common.security.SystemUserSecurityContext;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* V13 system-user migration 静态守卫测试(P1 #5 ponytail runnable check)
|
||||
* <p>
|
||||
* 设计依据:ce-code-review P1 #5 — V13 插入 system 占位用户行(id=0L, username='system'),
|
||||
* 此行存在性是 R4 system-user admin 旁路方案的根依赖({@link SystemUserSecurityContext#SYSTEM_USER_ID}
|
||||
* 必须在 t_user 表中存在,否则 setCreatedBy(null) 破坏 NOT NULL 约束)。
|
||||
* <p>
|
||||
* 测试策略:pms-auth 模块无 testcontainers/DB 集成测试基础设施,遵循 Mockito-only 单元测试约定。
|
||||
* 本测试以静态 SQL 资源校验方式运行:加载 V13__add_system_user.sql 作为 classpath 资源,
|
||||
* 解析 INSERT 语句验证关键列值与 SystemUserSecurityContext 常量一致。
|
||||
* <p>
|
||||
* 守卫范围:
|
||||
* <ul>
|
||||
* <li>id = 0(= {@link SystemUserSecurityContext#SYSTEM_USER_ID})</li>
|
||||
* <li>username = 'system'(= {@link SystemUserSecurityContext#SYSTEM_USERNAME})</li>
|
||||
* <li>status = 1(启用,避免 AuthServiceImpl.login 走"账号禁用"分支混淆)</li>
|
||||
* <li>account_type = 1(普通账号类型)</li>
|
||||
* <li>deleted = 0(未删除)</li>
|
||||
* <li>real_name 含"系统占位用户"(标识用途)</li>
|
||||
* <li>password 为 BCrypt 哈希(非明文,非空)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* ponytail: 不引入 H2/testcontainers — 仅校验 SQL 文本静态正确性,DB 实际执行行为由 Flyway 启动时
|
||||
* 与 post-deploy 验证 SQL 覆盖。本测试捕获"SQL 编辑回归"风险(如误删 INSERT、改 id/username)。
|
||||
*/
|
||||
@DisplayName("V13 system-user migration 静态守卫测试")
|
||||
class V13SystemUserMigrationTest {
|
||||
|
||||
private static final String MIGRATION_PATH = "/db/migration/V13__add_system_user.sql";
|
||||
|
||||
/**
|
||||
* INSERT INTO `t_user` (...) VALUES (0, NULL, 'system', '$2a$...', '系统占位用户', 1, 1, ...) —
|
||||
* 提取 VALUES 子句前 6 个值(id, project_id, username, password, real_name, status)。
|
||||
* 注释剥离后匹配,允许 SQL 文本跨多行书写。
|
||||
*/
|
||||
private static final Pattern VALUES_PATTERN = Pattern.compile(
|
||||
"VALUES\\s*\\(\\s*(\\d+)\\s*,\\s*NULL\\s*,\\s*'([^']+)'\\s*,\\s*'([^']+)'\\s*,"
|
||||
+ "\\s*'([^']+)'\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
|
||||
/** SQL 行注释 (-- 至行尾),剥离后便于正则匹配跨行 VALUES */
|
||||
private static final Pattern LINE_COMMENT_PATTERN = Pattern.compile("--[^\\n]*\\n");
|
||||
|
||||
@Test
|
||||
@DisplayName("V13 migration 文件存在于 classpath")
|
||||
void migrationFile_existsOnClasspath() throws IOException {
|
||||
try (InputStream is = getClass().getResourceAsStream(MIGRATION_PATH)) {
|
||||
assertThat(is)
|
||||
.as("V13 migration 文件必须在 classpath: %s", MIGRATION_PATH)
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V13 INSERT 包含 system 占位用户行:id=0L, username='system', status=1, account_type=1, deleted=0")
|
||||
void v13Insert_systemUserRow_matchesSystemUserSecurityContextConstants() throws IOException {
|
||||
String sql = stripComments(loadMigrationSql());
|
||||
Matcher matcher = VALUES_PATTERN.matcher(sql);
|
||||
assertThat(matcher.find())
|
||||
.as("V13 必须包含 INSERT INTO t_user VALUES (0, NULL, 'system', ...) 语句")
|
||||
.isTrue();
|
||||
|
||||
// id: 第 1 组 — V13 注释要求 id=0
|
||||
String idValue = matcher.group(1);
|
||||
assertThat(idValue)
|
||||
.as("V13 system-user 行 id 必须为 0(= SystemUserSecurityContext.SYSTEM_USER_ID)")
|
||||
.isEqualTo("0");
|
||||
|
||||
// username: 第 2 组
|
||||
String username = matcher.group(2);
|
||||
assertThat(username)
|
||||
.as("V13 system-user 行 username 必须为 'system'(= SystemUserSecurityContext.SYSTEM_USERNAME)")
|
||||
.isEqualTo(SystemUserSecurityContext.SYSTEM_USERNAME);
|
||||
|
||||
// password: 第 3 组(BCrypt 哈希,$2a$10$... 格式)
|
||||
String password = matcher.group(3);
|
||||
assertThat(password)
|
||||
.as("V13 system-user 行 password 必须为 BCrypt 哈希格式($2a$... 起头),非明文")
|
||||
.startsWith("$2a$");
|
||||
|
||||
// real_name: 第 4 组
|
||||
String realName = matcher.group(4);
|
||||
assertThat(realName)
|
||||
.as("V13 system-user 行 real_name 含'系统占位用户'标识用途")
|
||||
.contains("系统占位用户");
|
||||
|
||||
// status: 第 5 组
|
||||
int status = Integer.parseInt(matcher.group(5));
|
||||
assertThat(status)
|
||||
.as("V13 system-user 行 status=1(启用)")
|
||||
.isEqualTo(1);
|
||||
|
||||
// account_type: 第 6 组
|
||||
int accountType = Integer.parseInt(matcher.group(6));
|
||||
assertThat(accountType)
|
||||
.as("V13 system-user 行 account_type=1(普通账号类型)")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V13 INSERT 包含 deleted=0 标记(未软删除)")
|
||||
void v13Insert_systemUserRow_notSoftDeleted() throws IOException {
|
||||
String sql = stripComments(loadMigrationSql());
|
||||
// VALUES 子句末尾倒数第 1 个数字字段是 deleted(见 V13 SQL 列顺序:..., deleted)
|
||||
// 匹配 ..., 0\n); 模式 — deleted=0
|
||||
Pattern deletedPattern = Pattern.compile(",\\s*0\\s*\\)\\s*$", Pattern.MULTILINE);
|
||||
assertThat(deletedPattern.matcher(sql).find())
|
||||
.as("V13 system-user 行 deleted=0(未软删除,避免被 BaseModel 自动过滤)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V13 包含 ON DUPLICATE KEY UPDATE(幂等执行)")
|
||||
void v13Insert_idempotent() throws IOException {
|
||||
String sql = loadMigrationSql();
|
||||
assertThat(sql)
|
||||
.as("V13 必须包含 ON DUPLICATE KEY UPDATE 保证重复执行幂等")
|
||||
.containsIgnoringCase("ON DUPLICATE KEY UPDATE");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V13 注释引用 R4/OQ5 决策(可追溯性)")
|
||||
void v13Comments_referenceR4AndOQ5Decisions() throws IOException {
|
||||
String sql = loadMigrationSql();
|
||||
assertThat(sql)
|
||||
.as("V13 注释必须引用 R4 system-user 方案(决策可追溯)")
|
||||
.contains("R4");
|
||||
assertThat(sql)
|
||||
.as("V13 注释必须引用 OQ5 决策(决策可追溯)")
|
||||
.contains("OQ5");
|
||||
}
|
||||
|
||||
private String loadMigrationSql() throws IOException {
|
||||
try (InputStream is = getClass().getResourceAsStream(MIGRATION_PATH)) {
|
||||
assertThat(is).as("V13 migration 文件必须在 classpath").isNotNull();
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/** 剥离 SQL 行注释 (-- 至行尾),便于正则匹配跨行 VALUES 子句 */
|
||||
private String stripComments(String sql) {
|
||||
return LINE_COMMENT_PATTERN.matcher(sql).replaceAll("\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import com.pms.common.security.UserContext;
|
|||
import com.pms.common.util.JsonUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe
|
|||
private final ProjectAbilityMapper projectAbilityMapper;
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:project:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AbilityPackageVersion createDraft(Long packageId) {
|
||||
AbilityPackage pkg = abilityPackageMapper.selectById(packageId);
|
||||
|
|
@ -78,6 +80,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:project:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void submitForApproval(Long versionId) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
|
|
@ -96,6 +99,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:project:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void approve(Long versionId, String comment) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
|
|
@ -135,6 +139,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:project:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(Long versionId, String reason) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
|
|
@ -156,6 +161,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:project:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long versionId) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import com.pms.common.util.JsonUtils;
|
|||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic
|
|||
private static final String ACTION_REJECT_PREV = "REJECT_PREV";
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createInstance(Long projectId, String stageTransition) {
|
||||
ApprovalFlowTemplateDTO template = configService.getTemplateByTransition(stageTransition);
|
||||
|
|
@ -101,6 +103,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ApprovalCompletedEvent approve(Long instanceId, Long userId, String comment) {
|
||||
ApprovalFlowInstance instance = loadInstance(instanceId);
|
||||
|
|
@ -166,6 +169,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void rejectToInitiator(Long instanceId, Long userId, String comment) {
|
||||
ApprovalFlowInstance instance = loadInstance(instanceId);
|
||||
|
|
@ -182,6 +186,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void rejectToPrevious(Long instanceId, Long userId, String comment) {
|
||||
ApprovalFlowInstance instance = loadInstance(instanceId);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.pms.common.constant.CommonConstants;
|
|||
import com.pms.common.security.UserContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ public class ContractRoomServiceImpl implements ContractRoomService {
|
|||
private final ContractRoomMapper contractRoomMapper;
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:contract:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void bindRooms(Long contractId, Long projectId, List<Long> roomIds) {
|
||||
if (roomIds == null) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.pms.common.security.UserContext;
|
|||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ public class LookupServiceImpl implements LookupService {
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lookup:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long addProjectItem(Long projectId, String dictCode, String itemCode, String itemName, Integer sort) {
|
||||
if (projectId == null) {
|
||||
|
|
@ -108,6 +110,7 @@ public class LookupServiceImpl implements LookupService {
|
|||
}
|
||||
|
||||
@Override
|
||||
@PreAuthorize("hasAuthorityAndAdmin('base:lookup:manage')")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void disableProjectItem(Long projectId, String dictCode, String itemCode) {
|
||||
if (projectId == null) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package com.pms.base.controller;
|
||||
|
||||
import com.pms.base.service.impl.AbilityPackageServiceImpl;
|
||||
import com.pms.base.service.impl.AbilityPackageVersionServiceImpl;
|
||||
import com.pms.base.service.impl.ApprovalFlowRuntimeServiceImpl;
|
||||
import com.pms.base.service.impl.ArchiveServiceImpl;
|
||||
import com.pms.base.service.impl.ApprovalFlowConfigServiceImpl;
|
||||
import com.pms.base.service.impl.BuildingServiceImpl;
|
||||
import com.pms.base.service.impl.CamChargeServiceImpl;
|
||||
import com.pms.base.service.impl.CommunityActivityServiceImpl;
|
||||
import com.pms.base.service.impl.ContractRoomServiceImpl;
|
||||
import com.pms.base.service.impl.ContractServiceImpl;
|
||||
import com.pms.base.service.impl.ContractTypeServiceImpl;
|
||||
import com.pms.base.service.impl.DataScopeRuleServiceImpl;
|
||||
|
|
@ -19,6 +22,7 @@ import com.pms.base.service.impl.EnterpriseServiceServiceImpl;
|
|||
import com.pms.base.service.impl.FloorServiceImpl;
|
||||
import com.pms.base.service.impl.LeaseContractServiceImpl;
|
||||
import com.pms.base.service.impl.LifecycleServiceImpl;
|
||||
import com.pms.base.service.impl.LookupServiceImpl;
|
||||
import com.pms.base.service.impl.MeetingRoomServiceImpl;
|
||||
import com.pms.base.service.impl.OwnerCommitteeServiceImpl;
|
||||
import com.pms.base.service.impl.OwnerRoomServiceImpl;
|
||||
|
|
@ -145,7 +149,13 @@ class PreAuthorizeCoverageTest {
|
|||
);
|
||||
|
||||
/**
|
||||
* U4+U5+U6 范围 @Service 类列表(pms-base 全量 ServiceImpl,34 个)。
|
||||
* U4+U5+U6 范围 @Service 类列表(pms-base 全量 ServiceImpl,38 个)。
|
||||
* <p>
|
||||
* ce-debug P0 #1 修复:补齐 4 个遗漏的 @Service impl
|
||||
* - AbilityPackageVersionServiceImpl(5 @Transactional 写方法)
|
||||
* - ApprovalFlowRuntimeServiceImpl(4 @Transactional 写方法)
|
||||
* - ContractRoomServiceImpl(1 @Transactional 写方法)
|
||||
* - LookupServiceImpl(2 @Transactional 写方法)
|
||||
*/
|
||||
private static final List<Class<?>> SERVICES = List.of(
|
||||
// U4 项目域 + 数据域
|
||||
|
|
@ -154,9 +164,11 @@ class PreAuthorizeCoverageTest {
|
|||
ProjectAbilityServiceImpl.class,
|
||||
LifecycleServiceImpl.class,
|
||||
ApprovalFlowConfigServiceImpl.class,
|
||||
ApprovalFlowRuntimeServiceImpl.class,
|
||||
DataScopeRuleServiceImpl.class,
|
||||
ArchiveServiceImpl.class,
|
||||
AbilityPackageServiceImpl.class,
|
||||
AbilityPackageVersionServiceImpl.class,
|
||||
PreHandoverProgressServiceImpl.class,
|
||||
ProjectInitServiceImpl.class,
|
||||
// U5 空间域
|
||||
|
|
@ -174,6 +186,7 @@ class PreAuthorizeCoverageTest {
|
|||
// U6 合同域
|
||||
ContractServiceImpl.class,
|
||||
ContractTypeServiceImpl.class,
|
||||
ContractRoomServiceImpl.class,
|
||||
LeaseContractServiceImpl.class,
|
||||
// U6 设备域
|
||||
DeviceServiceImpl.class,
|
||||
|
|
@ -188,7 +201,9 @@ class PreAuthorizeCoverageTest {
|
|||
MeetingRoomServiceImpl.class,
|
||||
PublicRevenueServiceImpl.class,
|
||||
WorkshopLeaseServiceImpl.class,
|
||||
CamChargeServiceImpl.class
|
||||
CamChargeServiceImpl.class,
|
||||
// 字典/配置
|
||||
LookupServiceImpl.class
|
||||
);
|
||||
|
||||
static Stream<Arguments> publicMethods() {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import jakarta.validation.ConstraintViolationException;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
|
|
@ -131,6 +132,27 @@ public class GlobalExceptionHandler {
|
|||
return Result.error(ResultCode.FORBIDDEN, "无权限访问");
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证失败(匿名访问 @PreAuthorize 保护方法等)
|
||||
* <p>
|
||||
* Spring Security 在 SecurityContext 无 Authentication 时,方法级 @PreAuthorize 触发的
|
||||
* SpEL 表达式求值会抛 {@link org.springframework.security.authentication.AuthenticationCredentialsNotFoundException}
|
||||
* (继承 AuthenticationException)。若不显式处理,会落入通用 Exception handler 返回 500。
|
||||
* <p>
|
||||
* 标准 Spring Security 语义:AuthenticationException → 401(未认证),AccessDeniedException → 403(已认证无权限)。
|
||||
* PR2-PR4 给 100+ 方法加 @PreAuthorize 扩大触发面,此 handler 阻断匿名访问误报 500。
|
||||
* <p>
|
||||
* ponytail: 处理 AuthenticationException 父类而非具体子类,覆盖所有认证失败语义,
|
||||
* 与 ExceptionTranslationFilter 行为一致;@ExceptionHandler 按最具体匹配,BusinessException 优先级更高。
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public Result<Void> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {
|
||||
log.warn("认证失败: path={}, userId={}, type={}",
|
||||
request.getRequestURI(), UserContext.getUserId(), e.getClass().getSimpleName());
|
||||
return Result.error(ResultCode.UNAUTHORIZED, "未认证或认证已过期");
|
||||
}
|
||||
|
||||
/**
|
||||
* 其他未捕获异常
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test;
|
|||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
|
||||
|
|
@ -142,4 +144,32 @@ class GlobalExceptionHandlerTest {
|
|||
assertThat(result.getCode()).isEqualTo(ResultCode.INTERNAL_ERROR.getCode());
|
||||
assertThat(result.getMessage()).isEqualTo("系统繁忙,请稍后重试");
|
||||
}
|
||||
|
||||
// ===== ce-debug P2 #10:AuthenticationException → 401(防匿名访问 @PreAuthorize 方法返回 500) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("AuthenticationCredentialsNotFoundException → Result.code=401 + message=未认证或认证已过期")
|
||||
void shouldReturnUnauthorizedWhenAuthenticationCredentialsNotFound() {
|
||||
SecurityContextHolder.clearContext();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users");
|
||||
|
||||
Result<Void> result = handler.handleAuthenticationException(
|
||||
new AuthenticationCredentialsNotFoundException("无 Authentication 对象"), request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode());
|
||||
assertThat(result.getMessage()).isEqualTo("未认证或认证已过期");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AuthenticationException 子类 → 统一 401(覆盖 BadCredentialsException 等认证失败语义)")
|
||||
void shouldReturnUnauthorizedForAnyAuthenticationException() {
|
||||
SecurityContextHolder.clearContext();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/charge/dashboard");
|
||||
|
||||
// AuthenticationException 是抽象类,用其具体子类 AuthenticationCredentialsNotFoundException 验证多态分发
|
||||
AuthenticationException authEx = new AuthenticationCredentialsNotFoundException("test") {};
|
||||
Result<Void> result = handler.handleAuthenticationException(authEx, request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue