From 291f5fc24f0cdbabc2d5bf1c3e3e9fb9d512f85d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:15:13 +0800 Subject: [PATCH] test(auth): cover merge cancellation races Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../AccountMergeFlowIntegrationTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java index 5278e320..c9235d9d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.auth.local.LocalCredential; import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.merge.AccountMergeRequest; import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; @@ -22,6 +23,9 @@ import java.util.List; import java.util.Set; import java.util.UUID; import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -64,6 +68,7 @@ class AccountMergeFlowIntegrationTest { @Autowired private UserAccountRepository userAccountRepository; @Autowired private LocalCredentialRepository localCredentialRepository; @Autowired private AccountMergeRequestRepository mergeRequestRepository; + @Autowired private AccountMergeService mergeService; @Autowired private JdbcTemplate jdbcTemplate; @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -149,6 +154,93 @@ class AccountMergeFlowIntegrationTest { .isEqualTo(AccountMergeRequest.STATUS_PENDING); } + @Test + void cancelAndConfirmCannotBothWin() throws Exception { + String suffix = UUID.randomUUID().toString(); + String primaryId = "merge-primary-" + suffix; + String secondaryId = "merge-secondary-" + suffix; + String secondaryUsername = "merge-" + suffix; + userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null)); + userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null)); + localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash")); + AccountMergeRequest request = new AccountMergeRequest( + primaryId, secondaryId, null, Instant.now().plusSeconds(1800)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + long requestId = mergeRequestRepository.save(request).getId(); + + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future confirm = executor.submit(() -> { + start.await(); + try { + mergeService.confirm(primaryId, requestId); + return true; + } catch (com.iflytek.skillhub.auth.exception.AuthFlowException expected) { + return false; + } + }); + Future cancel = executor.submit(() -> { + start.await(); + try { + mergeService.cancel(secondaryId, requestId); + return true; + } catch (com.iflytek.skillhub.auth.exception.AuthFlowException expected) { + return false; + } + }); + start.countDown(); + assertThat(confirm.get()).isNotEqualTo(cancel.get()); + } + + String finalStatus = mergeRequestRepository.findById(requestId).orElseThrow().getStatus(); + String credentialOwner = localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername) + .orElseThrow().getUserId(); + if (AccountMergeRequest.STATUS_COMPLETED.equals(finalStatus)) { + assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus()) + .isEqualTo(UserStatus.MERGED); + assertThat(credentialOwner).isEqualTo(primaryId); + } else { + assertThat(finalStatus).isEqualTo(AccountMergeRequest.STATUS_CANCELLED); + assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus()) + .isEqualTo(UserStatus.ACTIVE); + assertThat(credentialOwner).isEqualTo(secondaryId); + } + } + + @Test + void secondaryCancellationAfterApprovalBlocksConfirmation() throws Exception { + String suffix = UUID.randomUUID().toString(); + String primaryId = "merge-primary-" + suffix; + String secondaryId = "merge-secondary-" + suffix; + String secondaryUsername = "merge-" + suffix; + userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null)); + userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null)); + localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash")); + AccountMergeRequest request = mergeRequestRepository.save(new AccountMergeRequest( + primaryId, secondaryId, null, Instant.now().plusSeconds(1800))); + String body = objectMapper.writeValueAsString(java.util.Map.of("mergeRequestId", request.getId())); + + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth(secondaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/account/merge/cancel") + .with(authentication(auth(secondaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth(primaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + + assertThat(mergeRequestRepository.findById(request.getId()).orElseThrow().getStatus()) + .isEqualTo(AccountMergeRequest.STATUS_CANCELLED); + assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus()) + .isEqualTo(UserStatus.ACTIVE); + assertThat(localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername).orElseThrow().getUserId()) + .isEqualTo(secondaryId); + } + private static UsernamePasswordAuthenticationToken auth(String userId) { PlatformPrincipal principal = new PlatformPrincipal(userId, userId, null, "", "local", Set.of()); return new UsernamePasswordAuthenticationToken(principal, null, List.of());