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;
}
}