Files
FT546-WCS/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java

338 lines
16 KiB
Java

package com.ruoyi.shadow;
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
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.context.annotation.Primary;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
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 static final String ARTIFACT_SHA256 =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static final String CONFIGURATION_SHA256 =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
private final WebApplicationContextRunner contextRunner =
new WebApplicationContextRunner()
.withInitializer(
new ConfigDataApplicationContextInitializer())
.withUserConfiguration(Ft546ShadowApplication.class,
TestShadowDatabaseGateConfiguration.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("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED"));
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
assertNotNull(context.getBean(Ft546ShadowCycle.class));
assertNotNull(context.getBean(Ft546ShadowScheduler.class));
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
assertNotNull(context.getBean(
JdbcShadowTrafficStateRepository.class));
assertNotNull(context.getBean(ShadowRunEvidence.class));
assertNotNull(context.getBean(ShadowEvidenceRepository.class));
assertNotNull(context.getBean(ShadowEvidenceStorageHealth.class));
assertNotNull(context.getBean(ShadowCycleEvidenceCollector.class));
Set<String> cycleDependencies = Stream.of(context.getBeanFactory()
.getDependenciesForBean("ft546ShadowCycle"))
.collect(Collectors.toSet());
assertTrue(cycleDependencies.contains("shadowEvidenceRepository"));
assertTrue(cycleDependencies.contains(
"shadowEvidenceStorageHealth"));
assertTrue(cycleDependencies.contains(
"shadowCycleEvidenceCollector"));
assertTrue(cycleDependencies.contains("shadowStartupReport"));
Set<String> trafficDependencies = Stream.of(
context.getBeanFactory().getDependenciesForBean(
"shadowTrafficStateRepository"))
.collect(Collectors.toSet());
Set<String> evidenceDependencies = Stream.of(
context.getBeanFactory().getDependenciesForBean(
"shadowEvidenceRepository"))
.collect(Collectors.toSet());
assertTrue(trafficDependencies.contains("shadowEvidenceTx"));
assertTrue(evidenceDependencies.contains("shadowEvidenceTx"));
assertEquals(context.getBean(
JdbcShadowTrafficStateRepository.class),
context.getBean(com.ruoyi.wcs.port.out
.TrafficStateRepository.class));
assertNotNull(context.getBean(ShadowStatusController.class));
assertNotNull(context.getBean(com.ruoyi.shadow.web.ShadowInputController.class));
Map<String, String> persisted = context.getBean(
RecordingShadowDatabaseGate.class).getPersistedChecks();
assertNotNull(persisted);
assertEquals(context.getBean(ShadowStartupReport.class)
.getStartupChecks(), persisted);
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("$.runtimeIdentity.buildId").value("ft546-shadow-20260910-source-recovery-v1"))
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
.value("PASS"))
.andExpect(jsonPath(
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
.value("PASS"));
mvc.perform(get("/shadow/inputs/00000000-0000-0000-0000-000000000001")
.with(request -> { request.setRemoteAddr("10.6.22.6"); return request; }))
.andExpect(status().isForbidden());
mvc.perform(get("/shadow/inputs/not-a-uuid"))
.andExpect(status().isBadRequest());
});
}
@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.username=source",
"shadow.evidence.username=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.source.username=source",
"shadow.evidence.username=evidence"),
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.source.username="),
"SHADOW_SOURCE_USERNAME_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.username="),
"SHADOW_EVIDENCE_USERNAME_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.output-directory="),
"SHADOW_EVIDENCE_OUTPUT_DIRECTORY_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.minimum-free-bytes=-1"),
"SHADOW_EVIDENCE_MINIMUM_FREE_BYTES_INVALID");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.artifact-sha256=invalid"),
"SHADOW_ARTIFACT_SHA256_INVALID");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.configuration-sha256="),
"SHADOW_CONFIGURATION_SHA256_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.source-server="),
"SHADOW_SOURCE_SERVER_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.source-database="),
"SHADOW_SOURCE_DATABASE_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"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",
"shadow.source.username=source",
"shadow.evidence.username=evidence",
"shadow.evidence.output-directory=.",
"shadow.evidence.minimum-free-bytes=0",
"shadow.evidence.run.artifact-sha256=" + ARTIFACT_SHA256,
"shadow.evidence.run.configuration-sha256="
+ CONFIGURATION_SHA256,
"shadow.evidence.run.source-server=ft546-test-server",
"shadow.evidence.run.source-database=ft546-test-source");
}
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();
}
}
@Configuration(proxyBeanMethods = false)
static class TestShadowDatabaseGateConfiguration {
@Bean
@Primary
RecordingShadowDatabaseGate testShadowDatabaseGate() {
return new RecordingShadowDatabaseGate();
}
}
static final class RecordingShadowDatabaseGate
implements ShadowDatabaseGate {
private Map<String, String> persistedChecks;
@Override
public Map<String, String> verify(
com.ruoyi.shadow.config.ShadowProperties properties) {
Map<String, String> checks =
new LinkedHashMap<String, String>();
checks.put("SOURCE_PERMISSIONS_VERIFIED", "PASS");
checks.put("EVIDENCE_DATABASE_VERIFIED", "PASS");
return checks;
}
public void persistChecks(Map<String, String> checks) {
persistedChecks = new LinkedHashMap<String, String>(checks);
}
Map<String, String> getPersistedChecks() {
return persistedChecks;
}
}
}