fix(p1r): 加固 Account 导出凭证消费边界
This commit is contained in:
parent
2ef35aedb3
commit
a37dc80609
@ -17,10 +17,13 @@ import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.muse.module.member.domain.account.AccountDownloadGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@ -72,6 +75,8 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
private AccountAuditService auditService;
|
||||
@Resource
|
||||
private AccountFileServiceFacade fileServiceFacade;
|
||||
@Value("${muse.account.download-credential-secret:}")
|
||||
private String downloadCredentialSecret;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -101,7 +106,7 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
|
||||
String taskId = newTaskId();
|
||||
String sourceSnapshot = sourceSnapshot(exportRequest);
|
||||
AccountFileServiceFacade.ExportTaskCreateResult fileResult = fileServiceFacade.createExportTask(
|
||||
AccountFileServiceFacade.ExportTaskCreateResult fileResult = createExportTaskSafely(
|
||||
AccountFileServiceFacade.ExportTaskCreateCommand.builder()
|
||||
.accountUserId(accountUserId)
|
||||
.taskId(taskId)
|
||||
@ -156,22 +161,21 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
if (credential == null) {
|
||||
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
AccountDownloadGuard.requireUsableCredential(accountUserId, credential.getAccountUserId(),
|
||||
credential.getExpiresAt(), credential.getConsumedAt(), credential.getRevokedAt(),
|
||||
Boolean.TRUE.equals(credential.getSourceBlocked()), LocalDateTime.now());
|
||||
Boolean.TRUE.equals(credential.getSourceBlocked()), now);
|
||||
AccountExportTaskDO task = exportTaskMapper.selectByTaskIdAndAccountUserId(credential.getTaskId(), accountUserId);
|
||||
requireDownloadableTask(task);
|
||||
AccountFileServiceFacade.DownloadPackageResult result =
|
||||
fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef());
|
||||
int consumedRows = downloadCredentialMapper.markConsumedIfUsable(credentialHash, accountUserId, now, now);
|
||||
if (consumedRows != 1) {
|
||||
// 凭证消费是一次性原子门禁;校验后被撤销、过期、阻断或并发消费时,必须在读文件前失败关闭。
|
||||
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
|
||||
}
|
||||
AccountFileServiceFacade.DownloadPackageResult result = downloadExportPackageSafely(accountUserId, task);
|
||||
if (result == null || !result.available() || result.bytes() == null || result.bytes().length == 0) {
|
||||
throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
LocalDateTime consumedAt = LocalDateTime.now();
|
||||
int consumedRows = downloadCredentialMapper.markConsumed(credentialHash, consumedAt);
|
||||
if (consumedRows != 1) {
|
||||
// 凭证消费是一次性门禁;并发下载在读取后抢先消费时,后续请求必须失败关闭。
|
||||
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
|
||||
}
|
||||
auditService.record(AccountAuditService.AuditCreateReq.builder()
|
||||
.operationId(OPERATION_DOWNLOAD_EXPORT)
|
||||
.actorUserId(accountUserId)
|
||||
@ -189,6 +193,25 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
return new DownloadPackage(defaultFilename(result.filename(), task), "application/octet-stream", result.bytes());
|
||||
}
|
||||
|
||||
private AccountFileServiceFacade.ExportTaskCreateResult createExportTaskSafely(
|
||||
AccountFileServiceFacade.ExportTaskCreateCommand command) {
|
||||
try {
|
||||
return fileServiceFacade.createExportTask(command);
|
||||
} catch (RuntimeException ex) {
|
||||
// FileService 还不是 Account owner;异常只能收敛为 unavailable,不能泄露为 500 或伪造文件结果。
|
||||
return AccountFileServiceFacade.ExportTaskCreateResult.unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private AccountFileServiceFacade.DownloadPackageResult downloadExportPackageSafely(Long accountUserId,
|
||||
AccountExportTaskDO task) {
|
||||
try {
|
||||
return fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef());
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private MemberUserDO requireUser(Long accountUserId) {
|
||||
MemberUserDO user = accountUserId == null ? null : memberUserMapper.selectById(accountUserId);
|
||||
if (user == null) {
|
||||
@ -240,7 +263,8 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
ExportRequest exportRequest, String sourceSnapshot,
|
||||
AccountFileServiceFacade.ExportTaskCreateResult fileResult) {
|
||||
String status = normalizeCreateStatus(fileResult);
|
||||
String fileRef = fileResult == null || !fileResult.available() ? null : fileResult.fileRef();
|
||||
String fileRef = fileResult == null || !fileResult.available() || STATUS_FAILED.equals(status)
|
||||
? null : fileResult.fileRef();
|
||||
AccountExportTaskDO task = AccountExportTaskDO.builder()
|
||||
.taskId(taskId)
|
||||
.accountUserId(accountUserId)
|
||||
@ -252,7 +276,7 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
.fileRef(fileRef)
|
||||
.sourceBlocked(false)
|
||||
.errorCode(STATUS_FAILED.equals(status) ? ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode().toString() : null)
|
||||
.errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息" : null)
|
||||
.errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息或下载凭证 secret" : null)
|
||||
.completedAt(STATUS_COMPLETED.equals(status)
|
||||
? firstNonNull(fileResult == null ? null : fileResult.completedAt(), LocalDateTime.now())
|
||||
: null)
|
||||
@ -263,8 +287,9 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
private String normalizeCreateStatus(AccountFileServiceFacade.ExportTaskCreateResult fileResult) {
|
||||
String status = fileResult == null ? null : fileResult.status();
|
||||
if (STATUS_COMPLETED.equals(status)) {
|
||||
if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef()) || fileResult.downloadExpiresAt() == null) {
|
||||
// completed 必须同时具备真实 fileRef 和 Account 可派生的下载凭证;缺任一项都按失败关闭。
|
||||
if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef())
|
||||
|| fileResult.downloadExpiresAt() == null || !hasDownloadCredentialSecret()) {
|
||||
// completed 必须同时具备真实 fileRef、过期时间和 Account HMAC secret;缺任一项都按失败关闭。
|
||||
return STATUS_FAILED;
|
||||
}
|
||||
return STATUS_COMPLETED;
|
||||
@ -311,7 +336,10 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
detail.setFailedReason(task.getErrorMessage());
|
||||
detail.setCreatedAt(task.getCreateTime());
|
||||
detail.setCompletedAt(task.getCompletedAt());
|
||||
if (STATUS_COMPLETED.equals(detail.getStatus()) && isCredentialVisible(credential)) {
|
||||
if (STATUS_COMPLETED.equals(detail.getStatus())
|
||||
&& !Boolean.TRUE.equals(task.getSourceBlocked())
|
||||
&& hasDownloadCredentialSecret()
|
||||
&& isCredentialVisible(credential)) {
|
||||
detail.setDownloadExpiresAt(credential.getExpiresAt());
|
||||
String accountCredentialId = buildAccountCredentialId(task);
|
||||
if (credential.getCredentialHash().equals(hashCredential(accountCredentialId))) {
|
||||
@ -398,17 +426,25 @@ public class AccountExportServiceImpl implements AccountExportService {
|
||||
}
|
||||
|
||||
private String buildAccountCredentialId(AccountExportTaskDO task) {
|
||||
// Account 下载凭证 ID 由 taskId/requestHash 可重算生成;数据库只保存 hash,不持久化也不回传 FileService 原始 token。
|
||||
return "acct-dl-" + sha256(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
|
||||
// Account 下载凭证 ID 使用服务端 secret 做 HMAC 派生;只读数据库不能反推可用 credentialId。
|
||||
if (!hasDownloadCredentialSecret()) {
|
||||
throw new IllegalStateException("Account 下载凭证 secret 未配置");
|
||||
}
|
||||
return "acct-dl-" + hmacSha256(downloadCredentialSecret, task.getTaskId() + ":" + task.getRequestHash())
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
private String sha256(String payload) {
|
||||
private boolean hasDownloadCredentialSecret() {
|
||||
return StringUtils.hasText(downloadCredentialSecret);
|
||||
}
|
||||
|
||||
private String hmacSha256(String secret, String payload) {
|
||||
try {
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256")
|
||||
.digest(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("JDK 缺少 HmacSHA256 摘要算法或密钥不可用", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,11 +31,18 @@ public interface AccountDownloadCredentialMapper extends BaseMapperX<AccountDown
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
default int markConsumed(String credentialHash, LocalDateTime consumedAt) {
|
||||
default int markConsumedIfUsable(String credentialHash, Long accountUserId,
|
||||
LocalDateTime now, LocalDateTime consumedAt) {
|
||||
return update(null, new LambdaUpdateWrapper<AccountDownloadCredentialDO>()
|
||||
.set(AccountDownloadCredentialDO::getConsumedAt, consumedAt)
|
||||
.eq(AccountDownloadCredentialDO::getCredentialHash, credentialHash)
|
||||
.isNull(AccountDownloadCredentialDO::getConsumedAt));
|
||||
.eq(AccountDownloadCredentialDO::getAccountUserId, accountUserId)
|
||||
.gt(AccountDownloadCredentialDO::getExpiresAt, now)
|
||||
.isNull(AccountDownloadCredentialDO::getRevokedAt)
|
||||
.isNull(AccountDownloadCredentialDO::getConsumedAt)
|
||||
.and(wrapper -> wrapper.isNull(AccountDownloadCredentialDO::getSourceBlocked)
|
||||
.or()
|
||||
.eq(AccountDownloadCredentialDO::getSourceBlocked, false)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -14,10 +14,15 @@ import cn.iocoder.muse.module.member.dal.mysql.account.AccountDownloadCredential
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountExportTaskMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDate;
|
||||
@ -37,6 +42,8 @@ import static org.mockito.Mockito.*;
|
||||
*/
|
||||
class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
private static final String TEST_CREDENTIAL_SECRET = "unit-test-download-secret";
|
||||
|
||||
@InjectMocks
|
||||
private AccountExportServiceImpl exportService;
|
||||
|
||||
@ -55,6 +62,11 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private AccountFileServiceFacade fileServiceFacade;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", TEST_CREDENTIAL_SECRET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_createQueuedProfileExport_whenFileServiceUnavailable() {
|
||||
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
|
||||
@ -213,6 +225,56 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedInternally_whenCredentialSecretMissing() {
|
||||
ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", "");
|
||||
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-export");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(fileServiceFacade.createExportTask(any()))
|
||||
.thenReturn(AccountFileServiceFacade.ExportTaskCreateResult.available("completed",
|
||||
"plain-credential-token", "file-ref-1", LocalDateTime.of(2026, 5, 28, 11, 0),
|
||||
LocalDateTime.of(2026, 5, 28, 10, 0)));
|
||||
when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> {
|
||||
AccountExportTaskDO task = invocation.getArgument(0);
|
||||
task.setId(701L);
|
||||
task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0));
|
||||
return 1;
|
||||
});
|
||||
|
||||
AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO);
|
||||
|
||||
assertEquals("queued", result.getStatus());
|
||||
verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) ->
|
||||
"failed".equals(task.getStatus())
|
||||
&& task.getFileRef() == null
|
||||
&& task.getErrorMessage().contains("secret")));
|
||||
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_createQueuedTask_whenCreateFacadeThrowsRuntimeException() {
|
||||
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-export");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(fileServiceFacade.createExportTask(any())).thenThrow(new RuntimeException("file service down"));
|
||||
when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> {
|
||||
AccountExportTaskDO task = invocation.getArgument(0);
|
||||
task.setId(701L);
|
||||
task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0));
|
||||
return 1;
|
||||
});
|
||||
|
||||
AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO);
|
||||
|
||||
assertEquals("queued", result.getStatus());
|
||||
verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) ->
|
||||
"queued".equals(task.getStatus()) && task.getFileRef() == null));
|
||||
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_getExportTaskForOwnerOnly_withoutReturningHashedCredential() {
|
||||
AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", false);
|
||||
@ -227,6 +289,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals("task-701", detail.getTaskId());
|
||||
assertEquals("completed", detail.getStatus());
|
||||
assertEquals(accountCredentialId, detail.getDownloadCredentialId());
|
||||
assertNotEquals("acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32),
|
||||
detail.getDownloadCredentialId());
|
||||
assertFalse(detail.getDownloadCredentialId().contains("hash"));
|
||||
assertNotNull(detail.getDownloadExpiresAt());
|
||||
verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L);
|
||||
@ -249,6 +313,22 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
assertNull(detail.getDownloadExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_hideDownloadCredentialInDetail_whenTaskSourceBlocked() {
|
||||
AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", true);
|
||||
String accountCredentialId = accountCredentialId(task);
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
|
||||
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L)).thenReturn(task);
|
||||
when(downloadCredentialMapper.selectLatestByTaskIdAndAccountUserId("task-701", 1001L))
|
||||
.thenReturn(credential(accountCredentialId, 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
|
||||
|
||||
AccountExportTaskDetailRespVO detail = exportService.appGetExportTask(1001L, "task-701");
|
||||
|
||||
assertEquals("completed", detail.getStatus());
|
||||
assertNull(detail.getDownloadCredentialId());
|
||||
assertNull(detail.getDownloadExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_downloadPackage_whenCredentialHashMatchesAndTaskCompleted() {
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusHours(1);
|
||||
@ -259,7 +339,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
|
||||
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip",
|
||||
"application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8)));
|
||||
when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(1);
|
||||
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-download");
|
||||
|
||||
AccountExportService.DownloadPackage result = exportService.appDownloadExport(1001L, "cred-1");
|
||||
@ -267,7 +348,12 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals("account-export.zip", result.filename());
|
||||
assertArrayEquals("zip-bytes".getBytes(StandardCharsets.UTF_8), result.bytes());
|
||||
verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1"));
|
||||
verify(downloadCredentialMapper).markConsumed(eq(hash("cred-1")), any(LocalDateTime.class));
|
||||
InOrder inOrder = inOrder(downloadCredentialMapper, exportTaskMapper, fileServiceFacade);
|
||||
inOrder.verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1"));
|
||||
inOrder.verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L);
|
||||
inOrder.verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class));
|
||||
inOrder.verify(fileServiceFacade).downloadExportPackage(1001L, "task-701", "file-ref-1");
|
||||
verify(auditService).record(argThat(req ->
|
||||
"appDownloadExport".equals(req.getOperationId())
|
||||
&& "succeeded".equals(req.getStatus())
|
||||
@ -280,15 +366,14 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
|
||||
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L))
|
||||
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
|
||||
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
|
||||
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip",
|
||||
"application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8)));
|
||||
when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(0);
|
||||
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(0);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> exportService.appDownloadExport(1001L, "cred-1"));
|
||||
|
||||
assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode());
|
||||
verifyNoInteractions(fileServiceFacade);
|
||||
verifyNoInteractions(auditService);
|
||||
}
|
||||
|
||||
@ -336,12 +421,33 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
|
||||
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
|
||||
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.unavailable());
|
||||
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> exportService.appDownloadExport(1001L, "cred-1"));
|
||||
|
||||
assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode());
|
||||
verify(downloadCredentialMapper, never()).markConsumed(anyString(), any());
|
||||
verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectDownloadAsFileServiceUnavailable_whenDownloadFacadeThrowsRuntimeException() {
|
||||
when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1")))
|
||||
.thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
|
||||
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L))
|
||||
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
|
||||
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
|
||||
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
|
||||
.thenThrow(new RuntimeException("file service down"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> exportService.appDownloadExport(1001L, "cred-1"));
|
||||
|
||||
assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode());
|
||||
verifyNoInteractions(auditService);
|
||||
}
|
||||
|
||||
private AccountExportTaskCreateReqVO exportReq(String exportType, String commandId) {
|
||||
@ -400,6 +506,16 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
private String accountCredentialId(AccountExportTaskDO task) {
|
||||
return "acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
|
||||
return "acct-dl-" + hmac(TEST_CREDENTIAL_SECRET, task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
|
||||
}
|
||||
|
||||
private String hmac(String secret, String payload) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user