fix(ft546): isolate shadow from legacy and PLC threads

This commit is contained in:
zgw
2026-08-12 12:36:40 +08:00
parent 48b0b154b6
commit d30fa0bd1c
8 changed files with 1355 additions and 24 deletions

View File

@@ -0,0 +1,279 @@
package com.ruoyi.project.ft546.runtime;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.schedule.ScheduleThread;
import com.ruoyi.schedule.ThreadOperationController;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class Ft546ShadowIsolationTest {
private static final String SHADOW_REJECTION =
"SHADOW mode forbids legacy and PLC thread operations";
private static final AtomicBoolean PROBE_CONSTRUCTED =
new AtomicBoolean();
@Test
void shadowInitializationDoesNotConstructPlcOrLegacyThreads() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
runtime.init();
assertTrue(runtime.plcSrgvReadCustomThreads.isEmpty());
assertNull(runtime.plcSsjReadCustomThread);
assertNull(runtime.plcTsjReadCustomThread);
assertNull(runtime.plcCdzReadCustomThread);
assertNull(runtime.monitoringCustomThread);
assertNull(runtime.ssjThreadCustomThread);
assertNull(runtime.sRgvThreadCustomThread);
assertNull(runtime.tsjThreadCustomThread);
assertNull(runtime.cdzThreadCustomThread);
}
@Test
void everyManualThreadOperationFailsClosedInShadow() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
assertRejected(runtime::startAllThread);
assertRejected(runtime::startAllThread2);
assertRejected(runtime::stopAllThread);
assertRejected(runtime::stopAllThread2);
}
@Test
void shadowStatusExplainsThatNoThreadCapabilityIsAllowed() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
Map<?, ?> status = runtime.getScheduleStatusDetails();
assertEquals("SHADOW", status.get("runtimeMode"));
assertEquals(Boolean.FALSE, status.get("plcFactReadersAllowed"));
assertEquals(Boolean.FALSE, status.get("legacyBusinessThreadsAllowed"));
assertEquals(Boolean.FALSE, status.get("automaticFactReaderStartAllowed"));
assertEquals(Boolean.FALSE, status.get("isRun"));
assertEquals(Boolean.FALSE, status.get("isRun2"));
}
@Test
void runtimeModePredicatesAreExclusive() {
assertTrue(Ft546WcsProperties.RuntimeMode.LEGACY.isLegacy());
assertFalse(Ft546WcsProperties.RuntimeMode.LEGACY.isShadow());
assertFalse(Ft546WcsProperties.RuntimeMode.LEGACY.isStandard());
assertTrue(Ft546WcsProperties.RuntimeMode.SHADOW.isShadow());
assertTrue(Ft546WcsProperties.RuntimeMode.STANDARD.isStandard());
}
@Test
void adminEnvironmentProcessorRejectsShadowBeforeContextCreation() {
StandardEnvironment environment = environment("SHADOW");
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> processor.postProcessEnvironment(environment,
new SpringApplication(Object.class)));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
failure.getMessage());
}
@Test
void adminEnvironmentProcessorAcceptsMissingLegacyAndStandardModes() {
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
processor.postProcessEnvironment(new StandardEnvironment(),
new SpringApplication(Object.class));
processor.postProcessEnvironment(environment("LEGACY"),
new SpringApplication(Object.class));
processor.postProcessEnvironment(environment("STANDARD"),
new SpringApplication(Object.class));
}
@Test
void adminEnvironmentProcessorRejectsUnknownModes() {
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> processor.postProcessEnvironment(environment("unsafe"),
new SpringApplication(Object.class)));
assertEquals("UNSUPPORTED_FT546_WCS_RUNTIME_MODE:UNSAFE",
failure.getMessage());
}
@Test
void registeredProcessorRejectsShadowBeforeAnyBeanConstruction() {
PROBE_CONSTRUCTED.set(false);
SpringApplication application = new SpringApplication(
ProbeConfiguration.class);
application.setWebApplicationType(WebApplicationType.NONE);
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> application.run(
"--ft546.wcs.runtime-mode=SHADOW",
"--spring.main.banner-mode=off",
"--logging.level.root=OFF"));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
rootCause(failure).getMessage());
assertFalse(PROBE_CONSTRUCTED.get());
}
@Test
void registeredProcessorReadsConfigDataBeforeBeanConstruction(
@TempDir Path tempDir) throws Exception {
Path config = tempDir.resolve("application.properties");
Files.write(config, Collections.singletonList(
"ft546.wcs.runtime-mode=SHADOW"),
StandardCharsets.UTF_8);
PROBE_CONSTRUCTED.set(false);
SpringApplication application = new SpringApplication(
ProbeConfiguration.class);
application.setWebApplicationType(WebApplicationType.NONE);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> application.run(
"--spring.config.location="
+ config.toUri().toString(),
"--spring.main.banner-mode=off",
"--logging.level.root=OFF"));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
rootCause(failure).getMessage());
assertFalse(PROBE_CONSTRUCTED.get());
}
@Test
void threadMutationEndpointsKeepTheStableAuthorizationSurface()
throws Exception {
assertNoNewPermission("startAllThread");
assertNoNewPermission("startAllThread2");
assertNoNewPermission("stopAllThread");
assertNoNewPermission("stopAllThread2");
}
@Test
void scheduleStatusKeepsItsStableBinaryReturnType() throws Exception {
Method method = ScheduleThread.class.getMethod(
"getScheduleStatus");
assertEquals(HashMap.class, method.getReturnType());
assertEquals("java.util.HashMap<java.lang.String, java.lang.Boolean>",
method.getGenericReturnType().getTypeName());
Map<?, ?> stableStatus = runtime(
Ft546WcsProperties.RuntimeMode.SHADOW).getScheduleStatus();
assertEquals(2, stableStatus.size());
assertEquals(Boolean.FALSE, stableStatus.get("isRun"));
assertEquals(Boolean.FALSE, stableStatus.get("isRun2"));
}
@Test
void everyWebThreadMutationReturnsAnErrorInShadow() throws Exception {
ThreadOperationController controller =
new ThreadOperationController();
ReflectionTestUtils.setField(controller, "scheduleThread",
runtime(Ft546WcsProperties.RuntimeMode.SHADOW));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
assertWebRejected(mockMvc, "startAllThread");
assertWebRejected(mockMvc, "startAllThread2");
assertWebRejected(mockMvc, "stopAllThread");
assertWebRejected(mockMvc, "stopAllThread2");
}
private static void assertRejected(Runnable action) {
IllegalStateException failure = assertThrows(
IllegalStateException.class, action::run);
assertEquals(SHADOW_REJECTION, failure.getMessage());
}
private static ScheduleThread runtime(Ft546WcsProperties.RuntimeMode mode) {
Ft546WcsProperties properties = new Ft546WcsProperties();
properties.setRuntimeMode(mode);
ScheduleThread runtime = new ScheduleThread();
ReflectionTestUtils.setField(runtime, "ft546WcsProperties", properties);
return runtime;
}
private static StandardEnvironment environment(String runtimeMode) {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource(
"test", Collections.<String, Object>singletonMap(
"ft546.wcs.runtime-mode", runtimeMode)));
return environment;
}
private static void assertNoNewPermission(String methodName)
throws Exception {
Method method = ThreadOperationController.class.getMethod(methodName);
PreAuthorize annotation = AnnotationUtils.findAnnotation(
method, PreAuthorize.class);
assertNull(annotation, methodName
+ " must keep the stable authorization surface");
}
private static void assertWebRejected(MockMvc mockMvc, String operation)
throws Exception {
mockMvc.perform(post("/device/operation/" + operation))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.msg").value(SHADOW_REJECTION));
}
private static Throwable rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
return current;
}
@Configuration(proxyBeanMethods = false)
static class ProbeConfiguration {
@Bean
Object constructionProbe() {
PROBE_CONSTRUCTED.set(true);
return new Object();
}
}
}