package com.mydreamjob.auth; import com.mydreamjob.deletion.AccountDeletion; import com.mydreamjob.deletion.AccountDeletionCompletionWorker; import com.mydreamjob.deletion.AccountDeletionRepository; import com.mydreamjob.deletion.DeletionStatus; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.context.ActiveProfiles; import java.time.Instant; import java.time.LocalDateTime; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * S02 会话撤销语义(设计 D11 / D12,任务书 §十五 与 §十九 生命周期组)。 * *

覆盖:登出只撤销当前设备、多设备隔离、重复撤销幂等、 * 注销完成撤销全部会话、其他用户不受影响。

*/ @SpringBootTest @ActiveProfiles("integration-test") class AuthSessionRevocationIntegrationTest { private static final AtomicLong PHONE_SEQ = new AtomicLong(9800); @MockBean private RedisTemplate redisTemplate; @Autowired private UserRepository userRepository; @Autowired private AuthSessionService authSessionService; @Autowired private AccountDeletionRepository accountDeletionRepository; @Autowired private AccountDeletionCompletionWorker accountDeletionCompletionWorker; private Long userId; private Long otherUserId; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); userId = createUser("S02 撤销"); otherUserId = createUser("S02 他人"); } @AfterEach void tearDown() { accountDeletionRepository.findByUserId(userId).ifPresent(accountDeletionRepository::delete); accountDeletionRepository.findByUserId(otherUserId).ifPresent(accountDeletionRepository::delete); userRepository.deleteById(otherUserId); userRepository.deleteById(userId); } private Long createUser(String nickname) { return userRepository.save(User.builder() .phone("15" + String.format("%09d", PHONE_SEQ.incrementAndGet())) .nickname(nickname) .role(User.Role.STUDENT) .roles(Set.of(User.Role.STUDENT)) .active(true) .verified(true) .build()).getId(); } private AuthSessionService.SessionTokens sessionFor(Long uid) { return authSessionService.createSession(userRepository.findById(uid).orElseThrow(), Instant.now(), null); } // ── 单设备登出 ────────────────────────────────────────────── @Test void logoutRevokesOnlyCurrentSession() { AuthSessionService.SessionTokens deviceA = sessionFor(userId); AuthSessionService.SessionTokens deviceB = sessionFor(userId); assertThat(deviceA.sessionId()).isNotEqualTo(deviceB.sessionId()); authSessionService.logout(userId, deviceA.sessionId(), Instant.now(), null); assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())) .as("其他设备会话不得被误伤").isTrue(); } @Test void repeatedLogoutIsIdempotent() { AuthSessionService.SessionTokens device = sessionFor(userId); authSessionService.logout(userId, device.sessionId(), Instant.now(), null); // 第二次登出不得抛异常 authSessionService.logout(userId, device.sessionId(), Instant.now(), null); assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); } @Test void logoutDoesNotAffectOtherUsers() { AuthSessionService.SessionTokens mine = sessionFor(userId); AuthSessionService.SessionTokens theirs = sessionFor(otherUserId); authSessionService.logout(userId, mine.sessionId(), Instant.now(), null); assertThat(authSessionService.isSessionActive(otherUserId, theirs.sessionId(), Instant.now())) .as("其他用户会话不受影响").isTrue(); } // ── 注销完成撤销全部会话 ──────────────────────────────────── @Test void accountDeletionCompletionRevokesAllSessions() { AuthSessionService.SessionTokens deviceA = sessionFor(userId); AuthSessionService.SessionTokens deviceB = sessionFor(userId); AuthSessionService.SessionTokens other = sessionFor(otherUserId); requestDeletionPastCooling(userId); assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())).isFalse(); assertThat(authSessionService.isSessionActive(otherUserId, other.sessionId(), Instant.now())) .as("其他用户会话不受影响").isTrue(); } @Test void pendingDeletionDoesNotForceLogout() { AuthSessionService.SessionTokens device = sessionFor(userId); // 尚在冷静期(未到期)的 PENDING 不应撤销会话 accountDeletionRepository.save(AccountDeletion.builder() .userId(userId).status(DeletionStatus.PENDING) .requestedAt(LocalDateTime.now()) .effectiveAt(LocalDateTime.now().plusDays(30)) .build()); assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isFalse(); assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) .as("注销申请 PENDING 时不强制退出").isTrue(); } @Test void cancelledDeletionDoesNotRestoreRevokedSessions() { AuthSessionService.SessionTokens device = sessionFor(userId); requestDeletionPastCooling(userId); assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); // 回退注销状态(等价于取消)不得让已撤销的会话复活 AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElseThrow(); rec.setStatus(DeletionStatus.NONE); accountDeletionRepository.saveAndFlush(rec); assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) .as("取消注销不恢复旧 session").isFalse(); } @Test void revokeAllUserSessionsReportsAffectedRows() { sessionFor(userId); sessionFor(userId); sessionFor(userId); int affected = authSessionService.revokeAllUserSessions( userId, AuthSessionService.REASON_USER_DISABLED, Instant.now()); assertThat(affected).as("应撤销该用户全部活动会话").isGreaterThanOrEqualTo(3); // 再次吊销:已无活动会话,受影响行数为 0(幂等) assertThat(authSessionService.revokeAllUserSessions( userId, AuthSessionService.REASON_USER_DISABLED, Instant.now())).isZero(); } private void requestDeletionPastCooling(Long uid) { accountDeletionRepository.save(AccountDeletion.builder() .userId(uid).status(DeletionStatus.PENDING) .requestedAt(LocalDateTime.now().minusDays(31)) .effectiveAt(LocalDateTime.now().minusDays(1)) .build()); } }