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.auth.sms.MockSmsCodeSender; import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.auth.sms.SmsSenderConfiguration; import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * S01 定向整改 F3:{@code app.sms.mock.enabled} 在 * true / false / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配 * 恰好一个 {@link SmsCodeSender},且只有显式 true 才启用 Mock。 * *

全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、 * 不测试字符串解析辅助函数、不使用 Mockito 验证)。

* *

{@link SenderConsumerProbe} 是消费者探针:它以构造参数方式依赖 * {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景; * 它不生产 any SmsCodeSender,因此不会掩盖生产 Bean 缺失。

*/ class SmsSenderConfigurationTest { /** 仅消费、不生产 SmsCodeSender 的探针。 */ static class SenderConsumerProbe { final SmsCodeSender sender; SenderConsumerProbe(SmsCodeSender sender) { this.sender = sender; } } private final ApplicationContextRunner runner = new ApplicationContextRunner() .withUserConfiguration(SmsSenderConfiguration.class, SenderConsumerProbe.class); private static final String ACC_PHONE = "19900009999"; private static final String ACC_CODE = "739251"; // ── 7 类取值行为 ──────────────────────────────────────────── @Test void trueUsesMockSender() { runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); }); } @Test void falseUsesUnavailableSender() { runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); }); } @Test void missingPropertyUsesUnavailableSender() { runner.run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); }); } @Test void blankPropertyUsesUnavailableSender() { // F3 核心回归:空值曾导致 0 个 Bean 与模糊 Spring 装配启动失败 runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { assertThat(context) .as("空值必须仍能启动且装配一个可用实现") .hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); }); } @Test void whitespacePropertyUsesUnavailableSender() { runner.withPropertyValues("app.sms.mock.enabled= ").run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); }); } @Test void invalidPropertyNeverEnablesMockSender() { for (String invalid : new String[]{"yes", "1", "on", "enabled", "TRUE1", "mock", "null"}) { runner.withPropertyValues("app.sms.mock.enabled=" + invalid).run(context -> { assertThat(context).as("非法值 %s 不应导致启动失败", invalid).hasNotFailed(); assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)) .as("非法值 %s 绝不允许启用 Mock", invalid) .isInstanceOf(UnavailableSmsCodeSender.class); assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); }); } } @Test void uppercaseTrueBehaviorIsExplicit() { // 明确语义:大小写不敏感 + 忽略两侧空白 → TRUE / True / " true " 视为启用 Mock for (String accepted : new String[]{"TRUE", "True", " true ", "tRuE"}) { runner.withPropertyValues("app.sms.mock.enabled=" + accepted).run(context -> { assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)) .as("取值 [%s] 应视为显式启用 Mock", accepted) .isInstanceOf(MockSmsCodeSender.class); }); } } @Test void everySupportedConfigurationCreatesExactlyOneSenderBean() { String[] values = {"true", "false", "", " ", "TRUE", "yes", "1", "on", "null"}; for (String value : values) { runner.withPropertyValues("app.sms.mock.enabled=" + value).run(context -> { assertThat(context).as("取值 [%s]", value).hasNotFailed(); assertThat(context.getBeanNamesForType(SmsCodeSender.class)) .as("取值 [%s] 必须恰好一个 SmsCodeSender Bean", value) .hasSize(1); }); } // 缺失属性 runner.run(context -> { assertThat(context).hasNotFailed(); assertThat(context.getBeanNamesForType(SmsCodeSender.class)) .as("缺失属性时必须恰好一个 SmsCodeSender Bean") .hasSize(1); }); } // ── 空值场景的完整证据链(消费者注入 + 安全失败 + 无敏感日志)──── @Test void blankPropertyStillAllowsConstructorInjectionIntoConsumer() { runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { assertThat(context).hasNotFailed(); // 复现原始缺陷场景:构造参数注入必须成功(曾因 0 个 Bean 失败) SenderConsumerProbe probe = context.getBean(SenderConsumerProbe.class); assertThat(probe.sender).isNotNull().isInstanceOf(UnavailableSmsCodeSender.class); // 同一实例,证明容器内只有一个 Bean assertThat(probe.sender).isSameAs(context.getBean(SmsCodeSender.class)); }); } @Test void blankPropertySenderFailsSafelyWithoutLeakingOrLogging() { ListAppender appender = new ListAppender<>(); appender.start(); Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob.auth.sms"); logger.addAppender(appender); logger.setLevel(Level.TRACE); try { runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { SmsCodeSender sender = context.getBean(SmsCodeSender.class); assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) .isInstanceOf(BusinessException.class) .satisfies(e -> { BusinessException be = (BusinessException) e; assertThat(be.getErrorCodeInt()) .isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); assertThat(be.getMessage()) .as("固定安全文本契约") .isEqualTo("sms sender unavailable") .doesNotContain(ACC_PHONE) .doesNotContain(ACC_CODE) .doesNotContainIgnoringCase("mock") .doesNotContain("app.sms"); }); String logs = appender.list.stream() .map(ILoggingEvent::getFormattedMessage) .reduce("", (a, b) -> a + '\n' + b); assertThat(logs) .doesNotContain(ACC_CODE) .doesNotContain(ACC_PHONE) .doesNotContain("app.sms.mock.enabled"); }); } finally { logger.detachAppender(appender); appender.stop(); } } @Test void unavailableSenderDoesNotReturnFalseSuccess() { runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { SmsCodeSender sender = context.getBean(SmsCodeSender.class); assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) .isInstanceOf(BusinessException.class) .satisfies(e -> { BusinessException be = (BusinessException) e; assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); assertThat(be.getMessage()) .doesNotContain(ACC_PHONE) .doesNotContain(ACC_CODE) .doesNotContainIgnoringCase("mock"); }); }); } @Test void mockSenderLoadsOnlyWhenExplicitlyEnabled() { runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { assertThat(context).hasSingleBean(SmsCodeSender.class); assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); }); } }