You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
200 lines
8.2 KiB
200 lines
8.2 KiB
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,任务书 §十五 与 §十九 生命周期组)。
|
|
*
|
|
* <p>覆盖:登出只撤销当前设备、多设备隔离、重复撤销幂等、
|
|
* 注销完成撤销全部会话、其他用户不受影响。</p>
|
|
*/
|
|
@SpringBootTest
|
|
@ActiveProfiles("integration-test")
|
|
class AuthSessionRevocationIntegrationTest {
|
|
|
|
private static final AtomicLong PHONE_SEQ = new AtomicLong(9800);
|
|
|
|
@MockBean
|
|
private RedisTemplate<String, Object> 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<String, Object> 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());
|
|
}
|
|
}
|
|
|