338 lines
16 KiB
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;
|
|
}
|
|
}
|
|
}
|