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 com.mydreamjob.config.AdminBootstrapProperties; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; /** * S03-A 管理员初始化的规则分支(设计 §3.2)。 * *

真实 {@link AdminBootstrapRunner} + Mock 仓储,逐条覆盖任务书 §四 要求的场景。 * 日志脱敏用真实 Logback 捕获验证。

*/ @ExtendWith(MockitoExtension.class) class AdminBootstrapRunnerTest { private static final String PHONE = "13800000009"; private static final String OTHER_PHONE = "13800000008"; @Mock private UserRepository userRepository; /** S03 整改后创建走独立事务组件(JdbcTemplate),单测以 mock 替代。 */ @Mock private AdminBootstrapCreator creator; private AdminBootstrapProperties properties; private AdminBootstrapRunner runner; private ListAppender appender; private Logger appLogger; @BeforeEach void setUp() { properties = new AdminBootstrapProperties(); runner = new AdminBootstrapRunner(properties, userRepository, creator); 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(); } } private String capturedLog() { return appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.joining("\n")); } private void enableWith(String phone) { properties.setEnabled("true"); properties.setPhone(phone); } // ── 关闭 / 未启用 ────────────────────────────────────────── @Test void disabledDoesNothing() { properties.setEnabled("false"); properties.setPhone(PHONE); runner.run(null); verifyNoInteractions(userRepository); verifyNoInteractions(creator); } @Test void nonLiteralTrueValueDoesNotEnable() { // 只有字面量 "true" 才算启用;大小写变体一律不生效。 for (String value : new String[]{"TRUE", "True", "yes", "1", "on", " true "}) { properties.setEnabled(value); properties.setPhone(PHONE); runner.run(null); } verifyNoInteractions(userRepository); verifyNoInteractions(creator); } // ── 手机号校验 ───────────────────────────────────────────── @Test void missingPhoneFailsAtStartup() { properties.setEnabled("true"); properties.setPhone(""); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); verifyNoInteractions(userRepository); verifyNoInteractions(creator); } @Test void invalidPhoneFailsAtStartup() { for (String bad : new String[]{"123", "1380000000", "138000000012", "1380000000a", " 1380000000a "}) { properties.setEnabled("true"); properties.setPhone(bad); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); } verifyNoInteractions(userRepository); verifyNoInteractions(creator); } // ── 已有活动 ADMIN ───────────────────────────────────────── @Test void existingActiveAdminIsSkippedWithoutSideEffects() { enableWith(PHONE); when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(1L); runner.run(null); verify(creator, never()).createAdmin(any(), any()); assertTrue(capturedLog().contains("admin bootstrap skipped"), "应输出固定跳过文本"); } // ── 手机号已被占用 ───────────────────────────────────────── @Test void phoneBelongingToStudentFails() { enableWith(PHONE); when(creator.findByPhone(PHONE)).thenReturn(Optional.of( new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage()); verify(creator, never()).createAdmin(any(), any()); } @Test void phoneBelongingToCompanyFails() { enableWith(PHONE); when(creator.findByPhone(PHONE)).thenReturn(Optional.of( new AdminBootstrapCreator.BootstrapUser(9L, "COMPANY", true, true, true, List.of("COMPANY")))); assertThrows(IllegalStateException.class, () -> runner.run(null)); verify(creator, never()).createAdmin(any(), any()); } @Test void phoneBelongingToInactiveUserFailsAndIsNotRevived() { enableWith(PHONE); when(creator.findByPhone(PHONE)).thenReturn(Optional.of( new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", false, true, true, List.of("ADMIN")))); assertThrows(IllegalStateException.class, () -> runner.run(null)); verify(creator, never()).createAdmin(any(), any()); } @Test void activeAdminMissingAdminRoleCannotBypassStrictIdempotencyCheck() { assertInvalidExistingAdminRejected( new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", true, true, true, List.of())); } @Test void activeAdminWithAdditionalRoleCannotBypassStrictIdempotencyCheck() { assertInvalidExistingAdminRejected( new AdminBootstrapCreator.BootstrapUser( 9L, "ADMIN", true, true, true, List.of("ADMIN", "STUDENT"))); } @Test void activeAdminWithPasswordCannotBypassStrictIdempotencyCheck() { assertInvalidExistingAdminRejected( new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", true, true, false, List.of("ADMIN"))); } // ── 正常创建 ─────────────────────────────────────────────── @Test void createsActiveVerifiedAdminWithConsistentRole() { enableWith(PHONE); when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); runner.run(null); // 创建由独立事务组件完成(JdbcTemplate),单测只验证编排参数; // 「users.role / user_roles 一致、active、verified、password_hash 为空」由 // AdminBootstrapIntegrationTest 与 AdminBootstrapConcurrencyLoggingTest 在真实库上断言。 verify(creator).createAdmin(eq(PHONE), any()); verify(userRepository, never()).save(any()); } @Test void customNicknameIsApplied() { enableWith(PHONE); properties.setNickname("运维管理员"); when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); runner.run(null); verify(creator).createAdmin(PHONE, "运维管理员"); } // ── 日志脱敏 ─────────────────────────────────────────────── @Test void logNeverContainsPhone() { enableWith(PHONE); when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); when(creator.findByPhone(OTHER_PHONE)).thenReturn(Optional.empty()); when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); properties.setPhone(OTHER_PHONE); runner.run(null); String log = capturedLog(); assertThatNotContains(log, OTHER_PHONE); assertThatNotContains(log, PHONE); } @Test void failureMessagesNeverContainPhone() { enableWith(PHONE); when(creator.findByPhone(PHONE)).thenReturn(Optional.of( new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); assertThatNotContains(ex.getMessage(), PHONE); } private static void assertThatNotContains(String haystack, String needle) { assertFalse(haystack != null && haystack.contains(needle), "输出不得包含手机号"); } private void assertInvalidExistingAdminRejected(AdminBootstrapCreator.BootstrapUser existing) { enableWith(PHONE); when(creator.findByPhone(PHONE)).thenReturn(Optional.of(existing)); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage()); verify(creator, never()).createAdmin(any(), any()); } }