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);
+});