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.
299 lines
13 KiB
299 lines
13 KiB
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 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。
|
|
*
|
|
* <p>并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码
|
|
* 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获
|
|
* root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc}
|
|
* 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。</p>
|
|
*/
|
|
@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<String, Object> redisTemplate;
|
|
|
|
@Autowired
|
|
private AdminBootstrapRunner runner;
|
|
|
|
@Autowired
|
|
private JdbcTemplate jdbcTemplate;
|
|
|
|
private final List<ListAppender<ILoggingEvent>> appenders = new ArrayList<>();
|
|
private final List<Logger> attachedLoggers = new ArrayList<>();
|
|
private final List<Long> 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<ILoggingEvent> 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 全文。
|
|
*
|
|
* <p>排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录
|
|
* 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。
|
|
* 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。</p>
|
|
*/
|
|
private String capturedText() {
|
|
StringBuilder sb = new StringBuilder();
|
|
for (ListAppender<ILoggingEvent> 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<String> runConcurrently() throws Exception {
|
|
ExecutorService pool = Executors.newFixedThreadPool(2);
|
|
CountDownLatch start = new CountDownLatch(1);
|
|
CountDownLatch ready = new CountDownLatch(2);
|
|
List<Future<String>> 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<String> results = new ArrayList<>();
|
|
for (Future<String> 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<String> 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<java.util.Map<String, Object>> 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;
|
|
}
|
|
}
|
|
|