This is an automated email from the ASF dual-hosted git repository.

dengliming pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new befbe15e fix: make rule copy namespace-safe (#684)
befbe15e is described below

commit befbe15e052c7b5987308aa3dfe21ef5c27f0271
Author: Carlos Acosta <[email protected]>
AuthorDate: Sun Sep 27 00:31:28 2026 -0500

    fix: make rule copy namespace-safe (#684)
    
    Co-authored-by: Aeontra <aeontra@localhost>
---
 src/routes/Plugin/Common/RuleCopy.js      | 102 ++++++++++----
 src/routes/Plugin/Common/RuleCopy.test.js | 214 ++++++++++++++++++++++++++++++
 2 files changed, 292 insertions(+), 24 deletions(-)

diff --git a/src/routes/Plugin/Common/RuleCopy.js 
b/src/routes/Plugin/Common/RuleCopy.js
index 511633e7..77451a69 100644
--- a/src/routes/Plugin/Common/RuleCopy.js
+++ b/src/routes/Plugin/Common/RuleCopy.js
@@ -16,7 +16,7 @@
  */
 
 import React, { Component } from "react";
-import { Modal, TreeSelect, Dropdown, Menu, Button, Icon } from "antd";
+import { Modal, TreeSelect, Dropdown, Menu, Button, Icon, message } from 
"antd";
 import { connect } from "dva";
 import {
   getPluginDropDownListByNamespace,
@@ -38,8 +38,9 @@ class RuleCopy extends Component {
       ruleTree: [],
       value: undefined,
       loading: false,
-      currentNamespaceId: defaultNamespaceId,
+      currentNamespaceId: props.currentNamespaceId || defaultNamespaceId,
     };
+    this.ruleRequestId = 0;
   }
 
   componentDidMount() {
@@ -47,48 +48,87 @@ class RuleCopy extends Component {
   }
 
   handleNamespacesValueChange = (value) => {
-    this.setState({ currentNamespaceId: value.key }, () => {
-      this.getAllRule();
-    });
+    this.setState(
+      {
+        currentNamespaceId: value.key,
+        value: undefined,
+        ruleTree: [],
+      },
+      () => {
+        this.getAllRule();
+      },
+    );
   };
 
   getAllRule = async () => {
-    const { currentNamespaceId } = this.props;
+    const { currentNamespaceId } = this.state;
+    this.ruleRequestId += 1;
+    const requestId = this.ruleRequestId;
+    const pageSize = 9999;
+
     const { code: pluginCode, data: pluginList = [] } =
       await getPluginDropDownListByNamespace({
         namespace: currentNamespaceId,
       });
-    const {
-      code: selectorCode,
-      data: { dataList: selectorList = [] },
-    } = await getAllSelectors({
+    if (requestId !== this.ruleRequestId) {
+      return;
+    }
+
+    const selectorResponse = await getAllSelectors({
       currentPage: 1,
-      pageSize: 9999,
+      pageSize,
       namespaceId: currentNamespaceId,
     });
-    const {
-      code: ruleCode,
-      data: { dataList: ruleList = [] },
-    } = await getAllRules({
+    if (requestId !== this.ruleRequestId) {
+      return;
+    }
+
+    const ruleResponse = await getAllRules({
       currentPage: 1,
-      pageSize: 9999,
+      pageSize,
       namespaceId: currentNamespaceId,
     });
+    if (requestId !== this.ruleRequestId) {
+      return;
+    }
+
+    const { code: selectorCode, data: selectorData = {} } = selectorResponse;
+    const { dataList: selectorList = [], page: selectorPage = {} } =
+      selectorData;
+    const { code: ruleCode, data: ruleData = {} } = ruleResponse;
+    const { dataList: ruleList = [], page: rulePage = {} } = ruleData;
 
     const pluginMap = {};
     const selectorMap = {};
     const ruleTree = [];
+    let incomplete =
+      pluginCode !== 200 ||
+      selectorCode !== 200 ||
+      ruleCode !== 200 ||
+      Number(selectorPage.totalCount || 0) > selectorList.length ||
+      Number(rulePage.totalCount || 0) > ruleList.length;
+
     if (ruleCode === 200) {
-      ruleList.forEach((v) => {
-        if (!selectorMap[v.selectorId]) {
-          selectorMap[v.selectorId] = [];
+      ruleList.forEach((rule) => {
+        if (!selectorMap[rule.selectorId]) {
+          selectorMap[rule.selectorId] = [];
         }
-        selectorMap[v.selectorId].push({ title: v.name, value: v.id });
+        selectorMap[rule.selectorId].push({
+          title: rule.name,
+          value: rule.id,
+        });
       });
     }
+
     if (Object.keys(selectorMap).length && selectorCode === 200) {
       Object.keys(selectorMap).forEach((selectorId) => {
-        const currentSelector = selectorList.find((v) => v.id === selectorId);
+        const currentSelector = selectorList.find(
+          (selector) => selector.id === selectorId,
+        );
+        if (!currentSelector) {
+          incomplete = true;
+          return;
+        }
         if (!pluginMap[currentSelector.pluginId]) {
           pluginMap[currentSelector.pluginId] = [];
         }
@@ -100,17 +140,31 @@ class RuleCopy extends Component {
         });
       });
     }
+
     if (Object.keys(pluginMap).length && pluginCode === 200) {
-      Object.keys(pluginMap).forEach((key) => {
-        const plugin = pluginList.find((v) => v.id === key);
+      Object.keys(pluginMap).forEach((pluginId) => {
+        const plugin = pluginList.find((item) => item.id === pluginId);
+        if (!plugin) {
+          incomplete = true;
+          return;
+        }
         ruleTree.push({
           title: plugin.name,
           value: plugin.id,
           disabled: true,
-          children: pluginMap[key],
+          children: pluginMap[pluginId],
         });
       });
     }
+
+    if (requestId !== this.ruleRequestId) {
+      return;
+    }
+    if (incomplete) {
+      message.warn(
+        "Some source rules could not be loaded. Please refresh and try again.",
+      );
+    }
     this.setState({ ruleTree });
   };
 
diff --git a/src/routes/Plugin/Common/RuleCopy.test.js 
b/src/routes/Plugin/Common/RuleCopy.test.js
new file mode 100644
index 00000000..4563dca6
--- /dev/null
+++ b/src/routes/Plugin/Common/RuleCopy.test.js
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { message } from "antd";
+import {
+  getAllRules,
+  getAllSelectors,
+  getPluginDropDownListByNamespace,
+} from "../../../services/api";
+import ConnectedRuleCopy from "./RuleCopy";
+
+jest.mock("../../../services/api", () => ({
+  findRule: jest.fn(),
+  getAllRules: jest.fn(),
+  getAllSelectors: jest.fn(),
+  getPluginDropDownListByNamespace: jest.fn(),
+}));
+jest.mock("../../../utils/IntlUtils", () => ({
+  getIntlContent: (key) => key,
+}));
+jest.mock("../../../components/_utils/utils", () => ({
+  defaultNamespaceId: "default-namespace",
+}));
+
+const RuleCopy = ConnectedRuleCopy.WrappedComponent;
+
+const pageResponse = (dataList, totalCount = dataList.length) => ({
+  code: 200,
+  data: {
+    dataList,
+    page: { totalCount },
+  },
+});
+
+const makeComponent = (currentNamespaceId = "namespace-1") => {
+  const component = new RuleCopy({
+    currentNamespaceId,
+    namespaces: [],
+  });
+  component.setState = (update, callback) => {
+    const next =
+      typeof update === "function"
+        ? update(component.state, component.props)
+        : update;
+    component.state = { ...component.state, ...next };
+    if (callback) callback();
+  };
+  return component;
+};
+
+const mockEmptyResponses = () => {
+  getPluginDropDownListByNamespace.mockResolvedValue({ code: 200, data: [] });
+  getAllSelectors.mockResolvedValue(pageResponse([]));
+  getAllRules.mockResolvedValue(pageResponse([]));
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  jest.spyOn(message, "warn").mockImplementation(() => {});
+});
+
+afterEach(() => {
+  jest.restoreAllMocks();
+});
+
+it("uses the active namespace for the initial query", async () => {
+  mockEmptyResponses();
+  const component = makeComponent("namespace-current");
+
+  expect(component.state.currentNamespaceId).toBe("namespace-current");
+
+  await component.getAllRule();
+
+  expect(getPluginDropDownListByNamespace).toHaveBeenCalledWith({
+    namespace: "namespace-current",
+  });
+  expect(getAllSelectors).toHaveBeenCalledWith({
+    currentPage: 1,
+    pageSize: 9999,
+    namespaceId: "namespace-current",
+  });
+  expect(getAllRules).toHaveBeenCalledWith({
+    currentPage: 1,
+    pageSize: 9999,
+    namespaceId: "namespace-current",
+  });
+});
+
+it("switches the source namespace and clears the old selection", () => {
+  const component = makeComponent("namespace-1");
+  component.state.value = "rule-1";
+  component.state.ruleTree = [{ title: "Old" }];
+  component.getAllRule = jest.fn();
+
+  component.handleNamespacesValueChange({ key: "namespace-2" });
+
+  expect(component.state.currentNamespaceId).toBe("namespace-2");
+  expect(component.state.value).toBeUndefined();
+  expect(component.state.ruleTree).toEqual([]);
+  expect(component.getAllRule).toHaveBeenCalledTimes(1);
+});
+
+it("skips a rule whose selector is missing instead of throwing", async () => {
+  getPluginDropDownListByNamespace.mockResolvedValue({
+    code: 200,
+    data: [{ id: "plugin-1", name: "Plugin" }],
+  });
+  getAllSelectors.mockResolvedValue(pageResponse([]));
+  getAllRules.mockResolvedValue(
+    pageResponse([{ id: "rule-1", name: "Rule", selectorId: "missing" }]),
+  );
+  const component = makeComponent();
+
+  await expect(component.getAllRule()).resolves.toBeUndefined();
+
+  expect(component.state.ruleTree).toEqual([]);
+  expect(message.warn).toHaveBeenCalled();
+});
+
+it("skips a selector whose plugin is missing instead of throwing", async () => 
{
+  getPluginDropDownListByNamespace.mockResolvedValue({ code: 200, data: [] });
+  getAllSelectors.mockResolvedValue(
+    pageResponse([
+      { id: "selector-1", name: "Selector", pluginId: "missing-plugin" },
+    ]),
+  );
+  getAllRules.mockResolvedValue(
+    pageResponse([{ id: "rule-1", name: "Rule", selectorId: "selector-1" }]),
+  );
+  const component = makeComponent();
+
+  await expect(component.getAllRule()).resolves.toBeUndefined();
+
+  expect(component.state.ruleTree).toEqual([]);
+  expect(message.warn).toHaveBeenCalled();
+});
+
+it("warns when the fetched page is truncated", async () => {
+  getPluginDropDownListByNamespace.mockResolvedValue({
+    code: 200,
+    data: [{ id: "plugin-1", name: "Plugin" }],
+  });
+  getAllSelectors.mockResolvedValue(
+    pageResponse(
+      [{ id: "selector-1", name: "Selector", pluginId: "plugin-1" }],
+      10000,
+    ),
+  );
+  getAllRules.mockResolvedValue(
+    pageResponse([{ id: "rule-1", name: "Rule", selectorId: "selector-1" }]),
+  );
+  const component = makeComponent();
+
+  await component.getAllRule();
+
+  expect(component.state.ruleTree[0].title).toBe("Plugin");
+  expect(message.warn).toHaveBeenCalled();
+});
+
+it("ignores an older response after the namespace changes", async () => {
+  let resolveOldPlugins;
+  getPluginDropDownListByNamespace
+    .mockImplementationOnce(
+      () =>
+        new Promise((resolve) => {
+          resolveOldPlugins = resolve;
+        }),
+    )
+    .mockResolvedValueOnce({
+      code: 200,
+      data: [{ id: "plugin-2", name: "Plugin 2" }],
+    });
+  getAllSelectors.mockResolvedValue(
+    pageResponse([
+      { id: "selector-2", name: "Selector 2", pluginId: "plugin-2" },
+    ]),
+  );
+  getAllRules.mockResolvedValue(
+    pageResponse([{ id: "rule-2", name: "Rule 2", selectorId: "selector-2" }]),
+  );
+
+  const component = makeComponent("namespace-1");
+  const oldRequest = component.getAllRule();
+
+  component.state.currentNamespaceId = "namespace-2";
+  const latestRequest = component.getAllRule();
+  await latestRequest;
+
+  expect(component.state.ruleTree[0].title).toBe("Plugin 2");
+
+  resolveOldPlugins({
+    code: 200,
+    data: [{ id: "plugin-1", name: "Plugin 1" }],
+  });
+  await oldRequest;
+
+  expect(component.state.ruleTree[0].title).toBe("Plugin 2");
+  expect(getAllSelectors).toHaveBeenCalledTimes(1);
+  expect(getAllRules).toHaveBeenCalledTimes(1);
+});

Reply via email to