fix(ft546): isolate shadow from legacy and PLC threads
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user