package com.mydreamjob.auth; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; 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 日志与审计脱敏(设计第十二节,任务书 §十六)。 * *

日志断言捕获**真实 Logback 输出**(不是 Mockito verify logger); * 审计断言读取真实 audit_logs 行并校验 details 白名单。

*/ @SpringBootTest @ActiveProfiles("integration-test") class AuthTokenRedactionTest { private static final AtomicLong PHONE_SEQ = new AtomicLong(9700); @MockBean private RedisTemplate redisTemplate; @Autowired private UserRepository userRepository; @Autowired private AuthSessionService authSessionService; @Autowired private AuthTokenHasher authTokenHasher; @Autowired private JdbcTemplate jdbcTemplate; private Long userId; private ListAppender appender; private Logger appLogger; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); userId = userRepository.save(User.builder() .phone("13" + String.format("%09d", PHONE_SEQ.incrementAndGet())) .nickname("S02 脱敏") .role(User.Role.STUDENT) .roles(Set.of(User.Role.STUDENT)) .active(true) .verified(true) .build()).getId(); appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); appender = new ListAppender<>(); appender.start(); appLogger.addAppender(appender); appLogger.setLevel(Level.TRACE); } @AfterEach void tearDown() { if (appLogger != null && appender != null) { appLogger.detachAppender(appender); appender.stop(); } jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId); if (userId != null) { userRepository.deleteById(userId); } } private String capturedLog() { return appender.list.stream() .map(ILoggingEvent::getFormattedMessage) .collect(Collectors.joining("\n")); } @Test void loginRefreshAndLogoutNeverLogTokensOrHashes() { AuthSessionService.SessionTokens first = authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); AuthSessionService.SessionTokens rotated = authSessionService.refresh(first.refreshToken(), Instant.now(), null); authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); // 重放路径同样不得泄漏 try { authSessionService.refresh(first.refreshToken(), Instant.now(), null); } catch (InvalidTokenException expected) { // 预期 } String log = capturedLog(); for (String secret : List.of(first.accessToken(), first.refreshToken(), rotated.accessToken(), rotated.refreshToken())) { assertThat(log).as("日志不得包含 Token 原文").doesNotContain(secret); } String sessionHash = authTokenHasher.hash("sid:" + first.sessionId()); assertThat(log).as("日志不得包含会话摘要").doesNotContain(sessionHash); assertThat(log).doesNotContain("Bearer "); assertThat(log).doesNotContain("Authorization"); } @Test void auditDetailsUseWhitelistOnly() { AuthSessionService.SessionTokens first = authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); AuthSessionService.SessionTokens rotated = authSessionService.refresh(first.refreshToken(), Instant.now(), null); authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); try { authSessionService.refresh(first.refreshToken(), Instant.now(), null); } catch (InvalidTokenException expected) { // 预期 } List> rows = jdbcTemplate.queryForList( "SELECT action, details FROM audit_logs WHERE user_id = ? AND action LIKE 'AUTH_%'", userId); assertThat(rows).as("应写入会话相关审计").isNotEmpty(); for (Map row : rows) { String action = (String) row.get("action"); assertThat(action).isIn( "AUTH_SESSION_CREATED", "AUTH_TOKEN_REFRESHED", "AUTH_SESSION_REVOKED", "AUTH_REFRESH_REUSE_DETECTED"); String details = String.valueOf(row.get("details")); assertThat(details).as("审计 details 不得包含 Token/摘要/手机号") .doesNotContain("accessToken") .doesNotContain("refreshToken") .doesNotContain("Bearer") .doesNotContain("tokenHash") .doesNotContain(first.refreshToken()) .doesNotContain(first.accessToken()); // 只允许 sessionRef / reason 两个键 assertThat(details).contains("\"sessionRef\""); } } @Test void auditNeverContainsRawTokens() { AuthSessionService.SessionTokens tokens = authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); List allDetails = jdbcTemplate.queryForList( "SELECT details FROM audit_logs WHERE user_id = ?", String.class, userId); for (String details : allDetails) { assertThat(details).doesNotContain(tokens.accessToken()); assertThat(details).doesNotContain(tokens.refreshToken()); // 数据库里的摘要也不得出现在审计中 assertThat(details).doesNotContain(authTokenHasher.hash(tokens.refreshToken())); } } }