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 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 trafficDependencies = Stream.of( context.getBeanFactory().getDependenciesForBean( "shadowTrafficStateRepository")) .collect(Collectors.toSet()); Set 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 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 persistedChecks; @Override public Map verify( com.ruoyi.shadow.config.ShadowProperties properties) { Map checks = new LinkedHashMap(); checks.put("SOURCE_PERMISSIONS_VERIFIED", "PASS"); checks.put("EVIDENCE_DATABASE_VERIFIED", "PASS"); return checks; } public void persistChecks(Map checks) { persistedChecks = new LinkedHashMap(checks); } Map getPersistedChecks() { return persistedChecks; } } }