轻松聘AI开发
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.
 
 
 

230 lines
11 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.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 / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配
* <strong>恰好一个</strong> {@link SmsCodeSender},且只有显式 true 才启用 Mock。
*
* <p>全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、
* 不测试字符串解析辅助函数、不使用 Mockito 验证)。</p>
*
* <p>{@link SenderConsumerProbe} 是<strong>消费者</strong>探针:它以构造参数方式依赖
* {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景;
* 它<strong>不生产</strong> any SmsCodeSender,因此不会掩盖生产 Bean 缺失。</p>
*/
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<ILoggingEvent> 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);
});
}
}