feat(ft546): add isolated shadow process

This commit is contained in:
zgw
2026-08-12 14:21:32 +08:00
parent 778444e5ea
commit 364668c939
14 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
package com.ruoyi.project.shadowtest;
import org.springframework.beans.factory.FactoryBean;
public final class ForbiddenFactoryBean implements FactoryBean<Object> {
@Override
public Object getObject() {
throw new AssertionError("forbidden factory must never create a bean");
}
@Override
public Class<?> getObjectType() {
return null;
}
@Override
public boolean isSingleton() {
return true;
}
}

View File

@@ -0,0 +1,200 @@
package com.ruoyi.shadow;
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class Ft546ShadowApplicationTest {
private final WebApplicationContextRunner contextRunner =
new WebApplicationContextRunner()
.withInitializer(
new ConfigDataApplicationContextInitializer())
.withUserConfiguration(Ft546ShadowApplication.class);
@Test
void explicitReadOnlyShadowConfigurationStartsTheNarrowContext() {
configured(contextRunner).run(context -> {
assertFalse(context.getStartupFailure() != null);
assertEquals("127.0.0.1",
context.getEnvironment().getProperty("server.address"));
assertEquals("18091",
context.getEnvironment().getProperty("server.port"));
assertEquals(ShadowStartupReport.Status.STARTING,
context.getBean(ShadowStartupReport.class).getStatus());
assertNotNull(context.getBean(ShadowStartupReport.class)
.getCheckedAt());
assertNull(context.getBean(ShadowStartupReport.class)
.getLastCompletedCycle());
assertNull(context.getBean(ShadowStartupReport.class)
.getSourceLagMillis());
assertEquals(0L, context.getBean(ShadowStartupReport.class)
.getEvidenceBacklog());
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SHADOW_ENABLED"));
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SIDE_EFFECT_BEANS_ABSENT"));
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED"));
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ShadowStatusController.class));
assertTrue(context.getBeansOfType(
DeviceCommandGateway.class).isEmpty());
assertTrue(context.getBeansOfType(
WmsNotificationGateway.class).isEmpty());
});
}
@Test
void statusEndpointReturnsTheReadOnlyStartupState() {
configured(contextRunner).run(context -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
mvc.perform(get("/shadow/status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("STARTING"))
.andExpect(jsonPath("$.checkedAt").exists())
.andExpect(jsonPath("$.evidenceBacklog").value(0))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
.value("PASS"))
.andExpect(jsonPath(
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
.value("PASS"));
});
}
@Test
void startupFailsClosedUnlessEnabledProfileAndBothJdbcUrlsAreExplicit() {
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_MUST_BE_EXPLICITLY_ENABLED");
assertStartupFailure(contextRunner.withPropertyValues(
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_PROFILE_MUST_BE_ACTIVE");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_SOURCE_JDBC_URL_REQUIRED");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source"),
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
"server.address=0.0.0.0"),
"SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
}
@Test
void startupRejectsAnyRealCommandGatewayBean() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenGatewayConfiguration.class),
"DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW");
}
@Test
void startupRejectsAnyRealWmsGatewayBean() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenWmsGatewayConfiguration.class),
"WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW");
}
@Test
void startupRejectsAForbiddenFactoryBeanWithUnknownProductType() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenFactoryBeanConfiguration.class),
"FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:"
+ ForbiddenFactoryBean.class.getName());
}
private static WebApplicationContextRunner configured(
WebApplicationContextRunner runner) {
return runner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence");
}
private static void assertStartupFailure(
WebApplicationContextRunner runner, String reasonCode) {
runner.run(context -> {
Throwable failure = context.getStartupFailure();
assertNotNull(failure);
assertTrue(messages(failure).contains(reasonCode),
messages(failure));
});
}
private static String messages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Throwable current = failure;
while (current != null) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenGatewayConfiguration {
@Bean
DeviceCommandGateway forbiddenDeviceCommandGateway() {
return envelope -> {
throw new AssertionError("must never send in shadow test");
};
}
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenWmsGatewayConfiguration {
@Bean
WmsNotificationGateway forbiddenWmsNotificationGateway() {
return notification -> {
throw new AssertionError("must never notify in shadow test");
};
}
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenFactoryBeanConfiguration {
@Bean
ForbiddenFactoryBean forbiddenFactoryBean() {
return new ForbiddenFactoryBean();
}
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.shadow;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;
import org.springframework.util.ClassUtils;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.junit.jupiter.api.Assertions.assertFalse;
class ShadowDependencyBoundaryTest {
@Test
void shadowCodeCannotDependOnAdminPlcOrLegacySchedulerPackages() {
JavaClasses shadowClasses = new ClassFileImporter()
.importUrl(Ft546ShadowApplication.class.getProtectionDomain()
.getCodeSource().getLocation());
ArchRule rule = noClasses()
.that().resideInAPackage("com.ruoyi.shadow..")
.should().dependOnClassesThat().resideInAnyPackage(
"com.ruoyi.project..",
"com.ruoyi.schedule..",
"com.ruoyi.api..",
"HslCommunication..",
"org.apache.plc4x..");
rule.check(shadowClasses);
}
@Test
void productionSideEffectLibrariesAreAbsentFromTheShadowClasspath() {
ClassLoader loader = getClass().getClassLoader();
assertFalse(ClassUtils.isPresent(
"com.ruoyi.schedule.ScheduleThread", loader));
assertFalse(ClassUtils.isPresent(
"HslCommunication.Profinet.Siemens.SiemensS7Net", loader));
assertFalse(ClassUtils.isPresent(
"org.apache.plc4x.java.api.PlcDriverManager", loader));
assertFalse(ClassUtils.isPresent(
"org.springframework.data.redis.connection.RedisConnectionFactory",
loader));
assertFalse(ClassUtils.isPresent(
"org.springframework.security.web.SecurityFilterChain", loader));
assertFalse(ClassUtils.isPresent(
"org.mybatis.spring.SqlSessionFactoryBean", loader));
assertFalse(ClassUtils.isPresent(
"cn.hutool.extra.spring.SpringUtil", loader));
}
}