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.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.StackTraceElementProxy; 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; import static org.assertj.core.api.Assertions.assertThat; /** * S03 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。 * *

并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码 * 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获 * root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc} * 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。

*/ @SpringBootTest(properties = { "app.bootstrap.admin.enabled=true", "app.bootstrap.admin.phone=13800000666", "app.bootstrap.admin.nickname=并发引导管理员", }) @ActiveProfiles("integration-test") class AdminBootstrapConcurrencyLoggingTest { private static final String BOOTSTRAP_PHONE = "13800000666"; @MockBean private RedisTemplate redisTemplate; @Autowired private AdminBootstrapRunner runner; @Autowired private JdbcTemplate jdbcTemplate; private final List> appenders = new ArrayList<>(); private final List attachedLoggers = new ArrayList<>(); private final List deactivatedAdmins = new ArrayList<>(); private static final String[] CAPTURED_LOGGERS = { org.slf4j.Logger.ROOT_LOGGER_NAME, "com.mydreamjob", "org.hibernate", "org.springframework.jdbc", }; @BeforeEach void setUp() { jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", BOOTSTRAP_PHONE); jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); // 保证「无活动 ADMIN」前置条件与执行顺序无关 deactivatedAdmins.clear(); for (Long id : jdbcTemplate.queryForList( "SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) { jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id); deactivatedAdmins.add(id); } for (String name : CAPTURED_LOGGERS) { Logger logger = (Logger) LoggerFactory.getLogger(name); ListAppender appender = new ListAppender<>(); appender.start(); logger.addAppender(appender); // 刻意**不**下调级别:按应用自身配置(root=WARN、com.mydreamjob=INFO)捕获, // 扫描结果才等于「生产到底写出了什么」。 // 框架在 TRACE 下的绑定参数日志不属于生产可见输出,不应计入本断言。 appenders.add(appender); attachedLoggers.add(logger); } } @AfterEach void tearDown() { for (int i = 0; i < attachedLoggers.size(); i++) { attachedLoggers.get(i).detachAppender(appenders.get(i)); appenders.get(i).stop(); } attachedLoggers.clear(); appenders.clear(); jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", BOOTSTRAP_PHONE); jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); for (Long id : deactivatedAdmins) { jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id); } deactivatedAdmins.clear(); } // ── 日志采集(含 throwableProxy 全文)──────────────────────── /** 把 throwableProxy 的类名、message、堆栈与 cause 链完整渲染为文本。 */ private static String renderThrowable(IThrowableProxy proxy) { if (proxy == null) { return ""; } StringBuilder sb = new StringBuilder(); sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); StackTraceElementProxy[] stack = proxy.getStackTraceElementProxyArray(); if (stack != null) { for (StackTraceElementProxy s : stack) { sb.append(" at ").append(s.getStackTraceElement()).append('\n'); } } if (proxy.getCause() != null) { sb.append("Caused by: ").append(renderThrowable(proxy.getCause())); } return sb.toString(); } /** * 所有被捕获 logger 的完整文本:formattedMessage + 参数 + throwable 全文。 * *

排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录 * 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。 * 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。

*/ private String capturedText() { StringBuilder sb = new StringBuilder(); for (ListAppender appender : appenders) { for (ILoggingEvent e : appender.list) { if (e.getLoggerName() != null && e.getLoggerName().startsWith("org.springframework.test")) { continue; } sb.append(e.getFormattedMessage()).append('\n'); Object[] args = e.getArgumentArray(); if (args != null) { for (Object a : args) { sb.append(String.valueOf(a)).append('\n'); } } sb.append(renderThrowable(e.getThrowableProxy())); } } return sb.toString(); } /** 扫描器有效性探针:注入一个标记,必须能被同一个扫描函数命中。 */ @Test void scannerDetectsInjectedProbe() { Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); String probe = "S03_SCANNER_PROBE_" + BOOTSTRAP_PHONE; logger.error("probe {}", probe); assertThat(capturedText()).as("扫描器必须能命中注入的探针,证明不是空转") .contains(probe); } // ── 并发冲突不得泄漏手机号 ────────────────────────────────── private List runConcurrently() throws Exception { ExecutorService pool = Executors.newFixedThreadPool(2); CountDownLatch start = new CountDownLatch(1); CountDownLatch ready = new CountDownLatch(2); List> futures = new ArrayList<>(); for (int i = 0; i < 2; i++) { futures.add(pool.submit(() -> { ready.countDown(); start.await(20, TimeUnit.SECONDS); try { runner.run(null); return "SUCCESS"; } catch (Throwable t) { return t.getClass().getName(); } })); } assertThat(ready.await(20, TimeUnit.SECONDS)).isTrue(); start.countDown(); List results = new ArrayList<>(); for (Future f : futures) { results.add(f.get(60, TimeUnit.SECONDS)); } pool.shutdownNow(); return results; } @Test void adminBootstrapConcurrentConflictNeverLogsConfiguredPhone() throws Exception { runConcurrently(); String log = capturedText(); assertThat(log).as("完整管理员手机号不得出现在任何 logger(含 throwableProxy)") .doesNotContain(BOOTSTRAP_PHONE); assertThat(log).as("不得出现 SQL 绑定参数回显").doesNotContain("binding parameter"); assertThat(log).as("不得出现 User.toString()").doesNotContain("User(id="); assertThat(log).as("不得出现 password/token/secret 值") .doesNotContainIgnoringCase("password=") .doesNotContainIgnoringCase("secret="); } @Test void concurrentBootstrapNeverLogsPhoneAcrossAllLoggers() throws Exception { List results = runConcurrently(); String log = capturedText(); assertThat(log).doesNotContain(BOOTSTRAP_PHONE); // 冲突方不得把原始 Hibernate/JDBC 异常带出来 assertThat(results).as("并发结果不得包含原始约束异常类型") .noneMatch(r -> r.contains("DataIntegrityViolationException") || r.contains("ConstraintViolationException") || r.contains("SQLException")); } @Test void concurrentBootstrapProducesOneValidAdmin() throws Exception { runConcurrently(); assertThat(countByPhone()).as("并发后同手机号用户恰好一行").isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE)) .as("user_roles 中 ADMIN 恰好一行").isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " + "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", Integer.class, BOOTSTRAP_PHONE)) .as("不得出现 STUDENT/COMPANY 附加角色").isZero(); List> row = jdbcTemplate.queryForList( "SELECT is_active, is_verified, password_hash FROM users WHERE phone = ?", BOOTSTRAP_PHONE); assertThat(row).hasSize(1); assertThat(((Number) row.get(0).get("is_active")).intValue()).isEqualTo(1); assertThat(((Number) row.get(0).get("is_verified")).intValue()).isEqualTo(1); assertThat(row.get(0).get("password_hash")).as("不得设置默认密码").isNull(); } @Test void repeatedBootstrapRemainsIdempotent() { runner.run(null); runner.run(null); runner.run(null); assertThat(countByPhone()).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM users WHERE role='ADMIN' AND is_active=1", Integer.class)) .isEqualTo(1); } @Test void existingStudentWithSamePhoneStillFailsWithoutPrivilegeEscalation() { jdbcTemplate.update( "INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " + "VALUES (?, '普通学生', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); try { assertThat(org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> runner.run(null)).getMessage()) .as("冲突文案必须固定且不含手机号") .doesNotContain(BOOTSTRAP_PHONE); } finally { jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); } assertThat(capturedText()).as("冲突路径也不得把手机号写进日志") .doesNotContain(BOOTSTRAP_PHONE); } @Test void conflictExceptionHasNoSensitiveCause() { jdbcTemplate.update( "INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " + "VALUES (?, '占位用户', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); try { RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> runner.run(null)); assertThat(ex.getMessage()).doesNotContain(BOOTSTRAP_PHONE); assertThat(ex.getCause()).as("不得携带原始数据库异常作为 cause").isNull(); assertThat(ex.getClass().getName()).doesNotContain("DataIntegrityViolation"); } finally { jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); } } private int countByPhone() { Integer n = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE); return n == null ? 0 : n; } }