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

Aias00 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 6b515420 test: protect core dashboard business logic (#660)
6b515420 is described below

commit 6b515420992469e057eb3f78935d0f693f34ddde
Author: Liming Deng <[email protected]>
AuthorDate: Fri Sep 25 11:34:39 2026 +0800

    test: protect core dashboard business logic (#660)
---
 src/components/Authorized/CheckPermissions.js      |   2 +-
 src/components/Authorized/CheckPermissions.test.js |  39 ++++
 src/models/global.js                               |  31 ++-
 src/models/global.test.js                          | 253 +++++++++++++++++++++
 src/routes/System/NamespacePlugin/AddModal.js      |  65 +++---
 src/routes/System/NamespacePlugin/AddModal.test.js |  75 ++++++
 src/routes/System/Plugin/AddModal.js               |  40 ++--
 src/routes/System/User/DataPermModal.js            |  51 +++--
 src/routes/System/User/DataPermModal.test.js       | 115 ++++++++++
 src/services/api.js                                |   8 +-
 src/services/api.test.js                           | 190 ++++++++++++++++
 src/utils/AuthButton.js                            |  27 ---
 src/utils/AuthButton.test.js                       |  49 ++++
 src/utils/AuthRoute.js                             |  18 +-
 src/utils/AuthRoute.test.js                        | 148 ++++++++++++
 src/utils/pluginConfig.js                          |  52 +++++
 src/utils/pluginConfig.test.js                     | 148 ++++++++++++
 src/utils/request.js                               |  10 +-
 src/utils/request.test.js                          | 181 +++++++++++++++
 19 files changed, 1389 insertions(+), 113 deletions(-)

diff --git a/src/components/Authorized/CheckPermissions.js 
b/src/components/Authorized/CheckPermissions.js
index 7c2cd4fd..e64b29c4 100644
--- a/src/components/Authorized/CheckPermissions.js
+++ b/src/components/Authorized/CheckPermissions.js
@@ -65,7 +65,7 @@ const checkPermissions = (authority, currentAuthority, 
target, Exception) => {
     if (Array.isArray(currentAuthority)) {
       for (let i = 0; i < currentAuthority.length; i += 1) {
         const element = currentAuthority[i];
-        if (authority.indexOf(element) >= 0) {
+        if (authority === element) {
           return target;
         }
       }
diff --git a/src/components/Authorized/CheckPermissions.test.js 
b/src/components/Authorized/CheckPermissions.test.js
index fae03bd9..f090321a 100644
--- a/src/components/Authorized/CheckPermissions.test.js
+++ b/src/components/Authorized/CheckPermissions.test.js
@@ -84,3 +84,42 @@ describe("test CheckPermissions", () => {
     expect(checkPermissions(null, ["user"], target, error)).toEqual("ok");
   });
 });
+
+describe("permission boundaries", () => {
+  it.each(["adm", "min", "", "superadmin"])(
+    "does not grant admin to the partial role %p",
+    (role) => {
+      expect(checkPermissions("admin", [role], target, error)).toBe(error);
+    },
+  );
+
+  it("denies an empty set of accepted roles", () => {
+    expect(checkPermissions([], ["admin"], target, error)).toBe(error);
+  });
+
+  it("passes the current authority to a permission predicate", () => {
+    const predicate = jest.fn(() => false);
+    expect(checkPermissions(predicate, ["reader"], target, error)).toBe(error);
+    expect(predicate).toHaveBeenCalledWith(["reader"]);
+  });
+
+  it("propagates errors from permission predicates", () => {
+    const failure = new Error("Invalid permission rule");
+    expect(() =>
+      checkPermissions(
+        () => {
+          throw failure;
+        },
+        "admin",
+        target,
+        error,
+      ),
+    ).toThrow(failure);
+  });
+
+  it("rejects unsupported authority values", () => {
+    expect(() => checkPermissions(42, "admin", target, error)).toThrow(
+      "unsupported parameters",
+    );
+  });
+});
diff --git a/src/models/global.js b/src/models/global.js
index 421f93bd..1cee00b9 100644
--- a/src/models/global.js
+++ b/src/models/global.js
@@ -72,6 +72,12 @@ export default {
         pageSize: 50,
       };
       const json = yield call(getPluginsByNamespace, params);
+      const currentNamespaceId = yield select(
+        ({ global }) => global.currentNamespaceId,
+      );
+      if (currentNamespaceId !== namespaceId) {
+        return;
+      }
       if (json.code === 200) {
         let { dataList } = json.data;
 
@@ -86,7 +92,7 @@ export default {
         });
       }
     },
-    *fetchPluginsByNamespace({ payload }, { call, put }) {
+    *fetchPluginsByNamespace({ payload }, { call }) {
       const { callback, namespaceId } = payload ?? {};
       const params = {
         namespaceId,
@@ -100,12 +106,6 @@ export default {
         if (callback) {
           callback(dataList);
         }
-        yield put({
-          type: "savePlugins",
-          payload: {
-            dataList,
-          },
-        });
       }
     },
     *asyncPlugin(params, { call }) {
@@ -127,6 +127,12 @@ export default {
       if (token && namespaceId) {
         const params = { token, namespaceId };
         const json = yield call(getUserPermissionByNamespace, params);
+        const currentNamespaceId = yield select(
+          ({ global }) => global.currentNamespaceId,
+        );
+        if (currentNamespaceId !== namespaceId) {
+          return;
+        }
         if (json.code === 200) {
           let { menu, currentAuth } = json.data;
           permissions = { menu, button: currentAuth };
@@ -156,6 +162,12 @@ export default {
       if (token && namespaceId) {
         const params = { token, namespaceId };
         const json = yield call(getUserPermissionByNamespace, params);
+        const currentNamespaceId = yield select(
+          ({ global }) => global.currentNamespaceId,
+        );
+        if (currentNamespaceId !== namespaceId) {
+          return;
+        }
         if (json.code === 200) {
           let { menu, currentAuth } = json.data;
           permissions = { menu, button: currentAuth };
@@ -207,9 +219,14 @@ export default {
     },
     saveCurrentNamespaceId(state, { payload }) {
       window.sessionStorage.setItem("currentNamespaceId", payload);
+      if (state.currentNamespaceId === payload) {
+        return state;
+      }
       return {
         ...state,
         currentNamespaceId: payload,
+        plugins: [],
+        permissions: { menu: [], button: [] },
       };
     },
     savePlugins(state, { payload }) {
diff --git a/src/models/global.test.js b/src/models/global.test.js
new file mode 100644
index 00000000..898b7545
--- /dev/null
+++ b/src/models/global.test.js
@@ -0,0 +1,253 @@
+/*
+ * 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 { runSaga, effects } from "dva/saga";
+import { message } from "antd";
+import model from "./global";
+import {
+  getPluginsByNamespace,
+  getUserPermissionByNamespace,
+} from "../services/api";
+
+jest.mock("../services/api", () => ({
+  getPluginsByNamespace: jest.fn(),
+  getUserPermissionByNamespace: jest.fn(),
+}));
+jest.mock("antd", () => ({ message: { warn: jest.fn(), success: jest.fn() } 
}));
+jest.mock("../utils/IntlUtils", () => ({ getIntlContent: (key) => key }));
+// The unrelated TypeScript utility module also exports random color helpers.
+jest.mock("../components/_utils/utils", () => ({
+  defaultNamespaceId: "default-namespace",
+}));
+
+let state;
+let actions;
+const oldPermissions = {
+  menu: [{ url: "/old" }],
+  button: [{ perms: "old:edit" }],
+};
+
+function dispatch(action) {
+  actions.push(action);
+  const reducer = model.reducers[action.type];
+  if (reducer) state = reducer(state, action);
+}
+
+function runEffect(name, payload = {}) {
+  return runSaga(
+    { dispatch, getState: () => ({ global: state }), logger: jest.fn() },
+    model.effects[name],
+    { payload },
+    effects,
+  ).done;
+}
+
+beforeEach(() => {
+  window.sessionStorage.clear();
+  window.sessionStorage.setItem("token", "session-token");
+  state = {
+    ...model.state,
+    currentNamespaceId: "namespace-A",
+    plugins: [{ id: "old-plugin" }],
+    permissions: oldPermissions,
+  };
+  actions = [];
+  getPluginsByNamespace.mockReset();
+  getUserPermissionByNamespace.mockReset();
+});
+
+afterEach(() => window.sessionStorage.clear());
+
+it("switches namespace without retaining the old namespace's plugins or 
permissions", () => {
+  const previous = Object.freeze({ ...state, collapsed: true });
+  const next = model.reducers.saveCurrentNamespaceId(previous, {
+    payload: "namespace-B",
+  });
+  expect(next.currentNamespaceId).toBe("namespace-B");
+  expect(window.sessionStorage.getItem("currentNamespaceId")).toBe(
+    "namespace-B",
+  );
+  expect(next.plugins).toEqual([]);
+  expect(next.permissions).toEqual({ menu: [], button: [] });
+  expect(next.collapsed).toBe(true);
+  expect(previous.permissions).toBe(oldPermissions);
+  expect(previous.currentNamespaceId).toBe("namespace-A");
+});
+
+it("does not clear loaded data when selecting the same namespace", () => {
+  const next = model.reducers.saveCurrentNamespaceId(state, {
+    payload: "namespace-A",
+  });
+  expect(next.plugins).toEqual(state.plugins);
+  expect(next.permissions).toEqual(state.permissions);
+});
+
+it("loads plugins using the selected namespace and replaces old data", async 
() => {
+  dispatch({ type: "saveCurrentNamespaceId", payload: "namespace-B" });
+  const plugins = [{ id: "plugin-B" }];
+  getPluginsByNamespace.mockResolvedValue({
+    code: 200,
+    data: { dataList: plugins },
+  });
+  const callback = jest.fn();
+  await runEffect("fetchPlugins", { callback });
+  expect(getPluginsByNamespace).toHaveBeenCalledWith({
+    namespaceId: "namespace-B",
+    currentPage: 1,
+    pageSize: 50,
+  });
+  expect(state.plugins).toEqual(plugins);
+  expect(callback).toHaveBeenCalledWith(plugins);
+});
+
+it.each(["fetchPermission", "refreshPermission"])(
+  "%s replaces permissions using the selected namespace and session",
+  async (effect) => {
+    const permissions = {
+      menu: [{ url: "/new" }],
+      currentAuth: [{ perms: "new:read" }],
+    };
+    getUserPermissionByNamespace.mockResolvedValue({
+      code: 200,
+      data: permissions,
+    });
+    const callback = jest.fn();
+    await runEffect(effect, { callback });
+    expect(getUserPermissionByNamespace).toHaveBeenCalledWith({
+      token: "session-token",
+      namespaceId: "namespace-A",
+    });
+    expect(state.permissions).toEqual({
+      menu: permissions.menu,
+      button: permissions.currentAuth,
+    });
+    expect(callback).toHaveBeenCalledWith(state.permissions);
+  },
+);
+
+it.each(["fetchPermission", "refreshPermission"])(
+  "%s denies access without a session",
+  async (effect) => {
+    window.sessionStorage.removeItem("token");
+    const callback = jest.fn();
+    await runEffect(effect, { callback });
+    expect(getUserPermissionByNamespace).not.toHaveBeenCalled();
+    expect(state.permissions).toEqual({ menu: [], button: [] });
+    expect(callback).toHaveBeenCalledWith(state.permissions);
+  },
+);
+
+it.each(["fetchPermission", "refreshPermission"])(
+  "%s clears permissions after a rejected business response",
+  async (effect) => {
+    getUserPermissionByNamespace.mockResolvedValue({
+      code: 403,
+      message: "Denied",
+    });
+    await runEffect(effect, { callback: jest.fn() });
+    expect(state.permissions).toEqual({ menu: [], button: [] });
+    if (effect === "fetchPermission") {
+      expect(message.warn).toHaveBeenCalledWith("SHENYU.PERMISSION.EMPTY");
+      expect(actions).toContainEqual(
+        expect.objectContaining({ type: "@@router/CALL_HISTORY_METHOD" }),
+      );
+    }
+  },
+);
+
+it("propagates a failed plugin request without calling its success callback", 
async () => {
+  const failure = new Error("Network unavailable");
+  getPluginsByNamespace.mockRejectedValue(failure);
+  const callback = jest.fn();
+  await expect(runEffect("fetchPlugins", { callback })).rejects.toBe(failure);
+  expect(callback).not.toHaveBeenCalled();
+  expect(actions).toEqual([]);
+});
+
+it.each(["fetchPlugins", "fetchPermission", "refreshPermission"])(
+  "%s ignores an old response arriving after a namespace switch",
+  async (effect) => {
+    let resolveRequest;
+    const pending = new Promise((resolve) => {
+      resolveRequest = resolve;
+    });
+    const api =
+      effect === "fetchPlugins"
+        ? getPluginsByNamespace
+        : getUserPermissionByNamespace;
+    api.mockReturnValue(pending);
+    const callback = jest.fn();
+    const task = runEffect(effect, { callback });
+    expect(api).toHaveBeenCalledTimes(1);
+    dispatch({ type: "saveCurrentNamespaceId", payload: "namespace-B" });
+    // A newer namespace response has already populated state.
+    dispatch({
+      type: "savePlugins",
+      payload: { dataList: [{ id: "plugin-B" }] },
+    });
+    dispatch({
+      type: "savePermissions",
+      payload: { permissions: { menu: [{ url: "/B" }], button: [] } },
+    });
+    resolveRequest({
+      code: 200,
+      data: {
+        dataList: [{ id: "plugin-A" }],
+        menu: [{ url: "/A" }],
+        currentAuth: [{ perms: "A:edit" }],
+      },
+    });
+    await task;
+    expect(state.currentNamespaceId).toBe("namespace-B");
+    expect(state.plugins).toEqual([{ id: "plugin-B" }]);
+    expect(state.permissions).toEqual({ menu: [{ url: "/B" }], button: [] });
+    expect(callback).not.toHaveBeenCalled();
+  },
+);
+
+it("resets permissions on logout without changing unrelated layout state", 
async () => {
+  const collapsed = state.collapsed;
+  await runEffect("resetPermission");
+  expect(state.permissions).toEqual({ menu: [], button: [] });
+  expect(state.collapsed).toBe(collapsed);
+});
+
+it("returns explicit-namespace plugins to the caller without overwriting 
shared plugins", async () => {
+  const sharedPlugins = state.plugins;
+  let resolveRequest;
+  getPluginsByNamespace.mockReturnValue(
+    new Promise((resolve) => {
+      resolveRequest = resolve;
+    }),
+  );
+  const callback = jest.fn();
+  const task = runEffect("fetchPluginsByNamespace", {
+    namespaceId: "modal-namespace",
+    callback,
+  });
+  expect(getPluginsByNamespace).toHaveBeenCalledWith({
+    namespaceId: "modal-namespace",
+    currentPage: 1,
+    pageSize: 50,
+  });
+  const plugins = [{ id: "modal-plugin" }];
+  resolveRequest({ code: 200, data: { dataList: plugins } });
+  await task;
+  expect(callback).toHaveBeenCalledWith(plugins);
+  expect(state.plugins).toBe(sharedPlugins);
+  expect(actions).toEqual([]);
+});
diff --git a/src/routes/System/NamespacePlugin/AddModal.js 
b/src/routes/System/NamespacePlugin/AddModal.js
index e80310e1..5e3ad6f1 100644
--- a/src/routes/System/NamespacePlugin/AddModal.js
+++ b/src/routes/System/NamespacePlugin/AddModal.js
@@ -20,6 +20,10 @@ import { Divider, Form, Input, InputNumber, Modal, Select, 
Switch } from "antd";
 import { connect } from "dva";
 import ReactJson from "react-json-view";
 import { getIntlContent } from "../../../utils/IntlUtils";
+import {
+  getConfigFieldValue,
+  serializePluginConfig,
+} from "../../../utils/pluginConfig";
 
 const { Option } = Select;
 const FormItem = Form.Item;
@@ -33,6 +37,7 @@ class AddModal extends Component {
     this.state = {
       jsonKey: null,
       jsonValue: {},
+      jsonEdited: false,
     };
     this.parseJson();
   }
@@ -52,41 +57,29 @@ class AddModal extends Component {
     }
   };
 
-  updateJson = (obj, fieldName) => {
-    const { form } = this.props;
-    let fieldsValue = form.getFieldsValue();
-    this.state.jsonValue = obj.updated_src;
-    const value = { [fieldName]: this.state.jsonValue };
-    if (!fieldsValue[fieldName]) {
-      form.setFields({ [fieldName]: { value } });
-    } else {
-      form.setFieldsValue(value);
-    }
+  updateJson = (obj) => {
+    this.setState({ jsonValue: obj.updated_src, jsonEdited: true });
   };
 
   handleSubmit = (e) => {
-    const { form, handleOk, id = "", data } = this.props;
-    const { jsonKey, jsonValue } = this.state;
+    const {
+      form,
+      handleOk,
+      id = "",
+      data,
+      config: originalConfig,
+    } = this.props;
+    const { jsonKey, jsonValue, jsonEdited } = this.state;
     e.preventDefault();
     form.validateFieldsAndScroll((err, values) => {
       if (!err) {
-        let { name, enabled, config, sort } = values;
-        if (data && data.length > 0) {
-          config = {};
-          data.forEach((item) => {
-            let fieldName = `__${item.field}__`;
-            if (values[fieldName]) {
-              config[item.field] = values[fieldName];
-            }
-          });
-          if (data.some((i) => i.dataType === 4)) {
-            config[jsonKey] = jsonValue;
-          }
-          config = JSON.stringify(config);
-          if (config === "{}") {
-            config = "";
-          }
-        }
+        const { name, enabled, sort } = values;
+        const config = serializePluginConfig({
+          fields: data,
+          values,
+          config: originalConfig,
+          jsonValues: jsonEdited && jsonKey ? { [jsonKey]: jsonValue } : {},
+        });
         handleOk({ name, enabled, config, id, sort });
       }
     });
@@ -163,9 +156,11 @@ class AddModal extends Component {
                 if (eachField.extObj) {
                   let extObj = JSON.parse(eachField.extObj);
                   required = extObj.required === "0" ? "" : extObj.required;
-                  if (!fieldInitialValue) {
-                    fieldInitialValue = extObj.defaultValue;
-                  }
+                  fieldInitialValue = getConfigFieldValue(
+                    config,
+                    eachField.field,
+                    extObj.defaultValue,
+                  );
                   if (extObj.rule) {
                     checkRule = extObj.rule;
                   }
@@ -245,9 +240,9 @@ class AddModal extends Component {
                         theme="monokai"
                         displayDataTypes={false}
                         name={false}
-                        onAdd={(obj) => this.updateJson(obj, fieldName)}
-                        onEdit={(obj) => this.updateJson(obj, fieldName)}
-                        onDelete={(obj) => this.updateJson(obj, fieldName)}
+                        onAdd={this.updateJson}
+                        onEdit={this.updateJson}
+                        onDelete={this.updateJson}
                         style={{ borderRadius: 4, padding: 16 }}
                       />
                     </FormItem>
diff --git a/src/routes/System/NamespacePlugin/AddModal.test.js 
b/src/routes/System/NamespacePlugin/AddModal.test.js
new file mode 100644
index 00000000..f4eb945a
--- /dev/null
+++ b/src/routes/System/NamespacePlugin/AddModal.test.js
@@ -0,0 +1,75 @@
+/*
+ * 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 React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import AddModal from "./AddModal";
+
+jest.mock("dva", () => ({ connect: () => (Component) => Component }));
+jest.mock("../../../utils/IntlUtils", () => ({ getIntlContent: (key) => key 
}));
+jest.mock("react-json-view", () => (props) => (
+  <button
+    type="button"
+    onClick={() => props.onEdit({ updated_src: { edited: true } })}
+  >
+    Edit JSON
+  </button>
+));
+
+it.each([null, false, 0, "", { existing: [1, 2] }])(
+  "preserves untouched JSON configuration %p on a form save",
+  async (value) => {
+    const handleOk = jest.fn();
+    render(
+      <AddModal
+        name="divide"
+        sort={0}
+        id="plugin-1"
+        config={JSON.stringify({ settings: value, unknown: "keep" })}
+        data={[{ field: "settings", label: "Settings", dataType: 4 }]}
+        handleOk={handleOk}
+      />,
+    );
+    fireEvent.click(screen.getByRole("button", { name: "SHENYU.COMMON.SURE" 
}));
+    await waitFor(() => expect(handleOk).toHaveBeenCalledTimes(1));
+    expect(JSON.parse(handleOk.mock.calls[0][0].config)).toEqual({
+      settings: value,
+      unknown: "keep",
+    });
+  },
+);
+
+it("saves actual JSON edits while preserving other stored fields", async () => 
{
+  const handleOk = jest.fn();
+  render(
+    <AddModal
+      name="divide"
+      sort={0}
+      id="plugin-1"
+      config={JSON.stringify({ settings: null, unknown: "keep" })}
+      data={[{ field: "settings", label: "Settings", dataType: 4 }]}
+      handleOk={handleOk}
+    />,
+  );
+  fireEvent.click(screen.getByRole("button", { name: "Edit JSON" }));
+  fireEvent.click(screen.getByRole("button", { name: "SHENYU.COMMON.SURE" }));
+  await waitFor(() => expect(handleOk).toHaveBeenCalledTimes(1));
+  expect(JSON.parse(handleOk.mock.calls[0][0].config)).toEqual({
+    settings: { edited: true },
+    unknown: "keep",
+  });
+});
diff --git a/src/routes/System/Plugin/AddModal.js 
b/src/routes/System/Plugin/AddModal.js
index 0b62435f..cb46adb0 100644
--- a/src/routes/System/Plugin/AddModal.js
+++ b/src/routes/System/Plugin/AddModal.js
@@ -28,6 +28,10 @@ import {
 } from "antd";
 import { connect } from "dva";
 import { getIntlContent } from "../../../utils/IntlUtils";
+import {
+  getConfigFieldValue,
+  serializePluginConfig,
+} from "../../../utils/pluginConfig";
 
 const { Option } = Select;
 const FormItem = Form.Item;
@@ -61,24 +65,22 @@ const ChooseFile = forwardRef(({ onChange, file }, ref) => {
 }))
 class AddModal extends Component {
   handleSubmit = (e) => {
-    const { form, handleOk, id = "", data } = this.props;
+    const {
+      form,
+      handleOk,
+      id = "",
+      data,
+      config: originalConfig,
+    } = this.props;
     e.preventDefault();
     form.validateFieldsAndScroll((err, values) => {
       if (!err) {
-        let { name, role, enabled, config, sort, file } = values;
-        if (data && data.length > 0) {
-          config = {};
-          data.forEach((item) => {
-            let fieldName = `__${item.field}__`;
-            if (values[fieldName]) {
-              config[item.field] = values[fieldName];
-            }
-          });
-          config = JSON.stringify(config);
-          if (config === "{}") {
-            config = "";
-          }
-        }
+        const { name, role, enabled, sort, file } = values;
+        const config = serializePluginConfig({
+          fields: data,
+          values,
+          config: originalConfig,
+        });
         handleOk({ name, role, enabled, config, id, sort, file });
       }
     });
@@ -156,9 +158,11 @@ class AddModal extends Component {
                 if (eachField.extObj) {
                   let extObj = JSON.parse(eachField.extObj);
                   required = extObj.required === "0" ? "" : extObj.required;
-                  if (!fieldInitialValue) {
-                    fieldInitialValue = extObj.defaultValue;
-                  }
+                  fieldInitialValue = getConfigFieldValue(
+                    config,
+                    eachField.field,
+                    extObj.defaultValue,
+                  );
                   if (extObj.rule) {
                     checkRule = extObj.rule;
                   }
diff --git a/src/routes/System/User/DataPermModal.js 
b/src/routes/System/User/DataPermModal.js
index ed1ac63a..dfd952d2 100644
--- a/src/routes/System/User/DataPermModal.js
+++ b/src/routes/System/User/DataPermModal.js
@@ -41,7 +41,6 @@ const { Search } = Input;
 @connect(({ dataPermission, resource, global, loading }) => ({
   dataPermission,
   resource,
-  global,
   namespaces: global.namespaces,
   selectorPermisionLoading:
     loading.effects["dataPermission/fetchDataPermisionSelectors"],
@@ -49,10 +48,13 @@ const { Search } = Input;
     loading.effects["dataPermission/fetchDataPermisionRules"],
 }))
 export default class DataPermModal extends Component {
+  pluginRequestId = 0;
+
   constructor(props) {
     super(props);
     this.state = {
       currentPlugin: null,
+      plugins: [],
       currentPermissionSelectorPage: 1,
       selectorData: null,
       pageSize: 12,
@@ -67,16 +69,32 @@ export default class DataPermModal extends Component {
     this.getPluginTreeData();
   }
 
+  componentWillUnmount() {
+    this.pluginRequestId += 1;
+  }
+
   getPluginTreeData = () => {
     const { dispatch } = this.props;
-    const { currentNamespaceId } = this.state;
     dispatch({
       type: "resource/fetchMenuTree",
     });
+    this.loadPlugins();
+  };
+
+  loadPlugins = () => {
+    const { dispatch } = this.props;
+    const { currentNamespaceId } = this.state;
+    this.pluginRequestId += 1;
+    const requestId = this.pluginRequestId;
     dispatch({
       type: "global/fetchPluginsByNamespace",
       payload: {
         namespaceId: currentNamespaceId,
+        callback: (plugins) => {
+          if (requestId === this.pluginRequestId) {
+            this.setState({ plugins });
+          }
+        },
       },
     });
   };
@@ -226,10 +244,9 @@ export default class DataPermModal extends Component {
 
   filterPlugin = () => {
     let {
-      global: { plugins },
       resource: { menuTree },
     } = this.props;
-    const { searchValue } = this.state;
+    const { searchValue, plugins } = this.state;
     let pluginMenuList = menuTree.filter((e) => e.url === "/plug");
     if (pluginMenuList && pluginMenuList.length > 0) {
       pluginMenuList = pluginMenuList[0].children;
@@ -429,20 +446,18 @@ export default class DataPermModal extends Component {
   };
 
   handleNamespacesValueChange = (value) => {
-    const { currentPlugin } = this.state;
-    const { dispatch } = this.props;
-    this.setState({ currentNamespaceId: value.key }, () => {
-      if (currentPlugin) {
-        this.setState({ selectorExpandedRowKeys: [] });
-        this.getPermissionSelectorList(1);
-      }
-      dispatch({
-        type: "global/fetchPluginsByNamespace",
-        payload: {
-          namespaceId: value.key,
-        },
-      });
-    });
+    this.setState(
+      {
+        currentNamespaceId: value.key,
+        plugins: [],
+        currentPlugin: null,
+        selectorData: null,
+        currentPermissionSelectorPage: 1,
+        selectorExpandedRowKeys: [],
+        ruleListMap: {},
+      },
+      this.loadPlugins,
+    );
   };
 
   render() {
diff --git a/src/routes/System/User/DataPermModal.test.js 
b/src/routes/System/User/DataPermModal.test.js
new file mode 100644
index 00000000..4ce5e2c2
--- /dev/null
+++ b/src/routes/System/User/DataPermModal.test.js
@@ -0,0 +1,115 @@
+/*
+ * 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 React from "react";
+import { act, render, screen } from "@testing-library/react";
+import ConnectedDataPermModal from "./DataPermModal";
+
+jest.mock("../../../utils/IntlUtils", () => ({ getIntlContent: (key) => key 
}));
+jest.mock("../../../components/_utils/utils", () => ({
+  defaultNamespaceId: "A",
+}));
+
+const DataPermModal = ConnectedDataPermModal.WrappedComponent;
+const plugin = (name) => ({ name, role: "0", pluginId: name });
+
+function setup() {
+  const dispatch = jest.fn();
+  const ref = React.createRef();
+  const view = render(
+    <DataPermModal
+      ref={ref}
+      dispatch={dispatch}
+      global={{ plugins: [plugin("global")] }}
+      resource={{
+        menuTree: [
+          {
+            url: "/plug",
+            children: ["global", "divide", "dubbo"].map((name) => ({
+              name,
+              meta: { icon: "api" },
+              sort: 0,
+            })),
+          },
+        ],
+      }}
+      namespaces={[
+        { namespaceId: "A", name: "A" },
+        { namespaceId: "B", name: "B" },
+      ]}
+    />,
+  );
+  function requests() {
+    return dispatch.mock.calls
+      .map(([action]) => action)
+      .filter((action) => action.type === "global/fetchPluginsByNamespace");
+  }
+  function switchNamespace(key) {
+    act(() => ref.current.handleNamespacesValueChange({ key }));
+  }
+  function respond(index, name) {
+    const { callback } = requests()[index].payload;
+    expect(callback).toEqual(expect.any(Function));
+    act(() => callback([plugin(name)]));
+  }
+  return { ...view, requests, switchNamespace, respond };
+}
+
+it("displays its own namespace plugins and discards delayed responses", () => {
+  const modal = setup();
+  expect(screen.queryByText("Global")).toBeNull();
+  expect(modal.requests()[0].payload.namespaceId).toBe("A");
+  modal.switchNamespace("B");
+  modal.respond(1, "dubbo");
+  expect(screen.getByText("Dubbo")).toBeTruthy();
+  modal.respond(0, "divide");
+  expect(screen.queryByText("Divide")).toBeNull();
+  expect(screen.getByText("Dubbo")).toBeTruthy();
+});
+
+it("clears the previous plugin list when switching namespaces", () => {
+  const modal = setup();
+  modal.respond(0, "divide");
+  expect(screen.getByText("Divide")).toBeTruthy();
+  modal.switchNamespace("B");
+  expect(screen.queryByText("Divide")).toBeNull();
+  modal.switchNamespace("A");
+  modal.respond(2, "dubbo");
+  modal.respond(1, "divide");
+  expect(screen.queryByText("Divide")).toBeNull();
+  expect(screen.getByText("Dubbo")).toBeTruthy();
+});
+
+it("ignores plugin responses after the modal is unmounted", () => {
+  const modal = setup();
+  const { callback } = modal.requests()[0].payload;
+  expect(callback).toEqual(expect.any(Function));
+  const error = jest.spyOn(console, "error").mockImplementation(() => {});
+  modal.unmount();
+  act(() => callback([plugin("divide")]));
+  expect(error).not.toHaveBeenCalled();
+});
+
+it("ignores an earlier response from the same namespace after switching back", 
() => {
+  const modal = setup();
+  modal.switchNamespace("B");
+  modal.switchNamespace("A");
+  modal.respond(2, "dubbo");
+  modal.respond(0, "divide");
+  expect(screen.queryByText("Divide")).toBeNull();
+  expect(screen.getByText("Dubbo")).toBeTruthy();
+});
diff --git a/src/services/api.js b/src/services/api.js
index b5ad6528..edadc785 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -177,7 +177,9 @@ export async function findUser(params) {
 export async function addPlugin(params) {
   const formData = new FormData();
   formData.append("name", params.name);
-  if (params.config) formData.append("config", params.config);
+  if (params.config !== undefined && params.config !== null) {
+    formData.append("config", params.config);
+  }
   formData.append("sort", params.sort);
   formData.append("role", params.role);
   formData.append("enabled", params.enabled);
@@ -232,7 +234,9 @@ export async function updatePlugin(params) {
   const formData = new FormData();
   formData.append("ids", params.id);
   formData.append("name", params.name);
-  if (params.config) formData.append("config", params.config);
+  if (params.config !== undefined && params.config !== null) {
+    formData.append("config", params.config);
+  }
   formData.append("sort", params.sort);
   formData.append("role", params.role);
   formData.append("enabled", params.enabled);
diff --git a/src/services/api.test.js b/src/services/api.test.js
new file mode 100644
index 00000000..b2848f2d
--- /dev/null
+++ b/src/services/api.test.js
@@ -0,0 +1,190 @@
+/*
+ * 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 fetch from "dva/fetch";
+
+jest.mock("dva/fetch", () => jest.fn());
+jest.mock("../index", () => ({ dispatch: jest.fn() }));
+jest.mock("antd", () => ({ notification: { error: jest.fn() } }));
+jest.mock("../utils/IntlUtils", () => ({ getIntlContent: (key) => key }));
+
+let api;
+const namespaceId = "namespace-A";
+const success = { code: 200, data: {} };
+
+beforeAll(() => {
+  document.body.innerHTML = '<span id="httpPath">/admin</span>';
+  // The service reads its base URL from the document at import time.
+  // eslint-disable-next-line global-require
+  api = require("./api");
+});
+
+beforeEach(() => {
+  window.sessionStorage.clear();
+  window.sessionStorage.setItem("token", "test-token");
+  fetch.mockReset();
+  fetch.mockResolvedValue({ status: 200, json: async () => success });
+});
+
+afterEach(() => window.sessionStorage.clear());
+afterAll(() => {
+  document.body.innerHTML = "";
+});
+
+it.each([
+  ["getAllSelectors", "/admin/selector"],
+  ["getAllRules", "/admin/rule"],
+  ["getPluginsByNamespace", "/admin/namespace-plugin"],
+  ["getAllMetadata", "/admin/meta-data/queryList"],
+])(
+  "%s scopes and encodes list filters without changing the caller's 
parameters",
+  async (method, path) => {
+    const params = Object.freeze({
+      namespaceId,
+      currentPage: 2,
+      pageSize: 10,
+      name: "a&b / 中文",
+      path: "/hello?x=1&y=2",
+    });
+    await expect(api[method](params)).resolves.toEqual(success);
+    const [url, options] = fetch.mock.calls[0];
+    const parsed = new URL(url, "http://localhost";);
+    expect(parsed.pathname).toBe(path);
+    expect(parsed.searchParams.get("namespaceId")).toBe(namespaceId);
+    expect(parsed.searchParams.get("currentPage")).toBe("2");
+    expect(parsed.searchParams.get("name")).toBe(params.name);
+    expect(parsed.searchParams.get("path")).toBe(params.path);
+    expect(options.method).toBe("GET");
+    expect(options.headers["X-Access-Token"]).toBe("test-token");
+  },
+);
+
+it.each([
+  ["deleteSelector", "/admin/selector/batch"],
+  ["deleteRule", "/admin/rule/batch"],
+  ["deleteMetadata", "/admin/meta-data/batchDeleted"],
+  ["deleteNamespacePlugin", "/admin/namespace-plugin/batch"],
+])("%s sends namespace and resource IDs together", async (method, path) => {
+  const list = Object.freeze(["id-1", "id-2"]);
+  await api[method](Object.freeze({ list, namespaceId }));
+  const [url, options] = fetch.mock.calls[0];
+  expect(url).toBe(path);
+  expect(options.method).toBe("DELETE");
+  expect(JSON.parse(options.body)).toEqual({
+    ids: ["id-1", "id-2"],
+    namespaceId,
+  });
+});
+
+it.each([
+  ["enableSelector", "/admin/selector/batchEnabled"],
+  ["enableRule", "/admin/rule/batchEnabled"],
+  ["updateNamespacePluginEnabled", "/admin/namespace-plugin/enabled"],
+  [
+    "updateNamespacePluginEnabledByNamespace",
+    "/admin/namespace-plugin/enabledByNamespace",
+  ],
+])(
+  "%s preserves an explicit disabled value and its namespace",
+  async (method, path) => {
+    await api[method]({ list: ["id-1"], enabled: false, namespaceId });
+    const [url, options] = fetch.mock.calls[0];
+    expect(url).toBe(path);
+    expect(options.method).toBe("POST");
+    expect(JSON.parse(options.body)).toEqual({
+      ids: ["id-1"],
+      enabled: false,
+      namespaceId,
+    });
+  },
+);
+
+it("includes and encodes the namespace when deleting discovery configuration", 
async () => {
+  await api.deleteDiscovery({
+    discoveryId: "discovery-1",
+    namespaceId: "space & other",
+  });
+  const [url, options] = fetch.mock.calls[0];
+  const parsed = new URL(url, "http://localhost";);
+  expect(parsed.pathname).toBe("/admin/discovery/discovery-1");
+  expect(parsed.searchParams.get("namespaceId")).toBe("space & other");
+  expect(options.method).toBe("DELETE");
+});
+
+it("targets the selected namespace when loading its permissions", async () => {
+  await api.getUserPermissionByNamespace({ namespaceId });
+  const [url, options] = fetch.mock.calls[0];
+  expect(new URL(url, 
"http://localhost";).searchParams.get("namespaceId")).toBe(
+    namespaceId,
+  );
+  expect(options.headers["X-Access-Token"]).toBe("test-token");
+});
+
+it.each([
+  ["updateSelector", "/admin/selector/id-1"],
+  ["updateRule", "/admin/rule/id-1"],
+  ["updateNamespacePlugin", "/admin/namespace-plugin/id-1"],
+])(
+  "%s retains configuration and fields unrelated to the edit",
+  async (method, path) => {
+    const params = Object.freeze({
+      id: "id-1",
+      namespaceId,
+      enabled: false,
+      sort: 0,
+      name: "",
+      config: JSON.stringify({
+        enabled: false,
+        timeout: 0,
+        extension: { tags: ["x"] },
+      }),
+      handle: { conditions: [] },
+    });
+    await api[method](params);
+    const [url, options] = fetch.mock.calls[0];
+    expect(url).toBe(path);
+    expect(options.method).toBe("PUT");
+    expect(JSON.parse(options.body)).toEqual(params);
+  },
+);
+
+it("carries request failures through the service boundary", async () => {
+  const failure = new TypeError("Network unavailable");
+  fetch.mockRejectedValue(failure);
+  await expect(api.updateRule({ id: "id-1", namespaceId })).rejects.toBe(
+    failure,
+  );
+});
+
+it.each(["addPlugin", "updatePlugin"])(
+  "%s sends an explicitly emptied configuration in multipart data",
+  async (method) => {
+    await api[method]({
+      id: "id-1",
+      name: "divide",
+      role: "proxy",
+      sort: 0,
+      enabled: false,
+      config: "",
+    });
+    const [, options] = fetch.mock.calls[0];
+    expect(options.body).toBeInstanceOf(FormData);
+    expect(options.body.get("config")).toBe("");
+    expect(options.body.get("sort")).toBe("0");
+    expect(options.body.get("enabled")).toBe("false");
+  },
+);
diff --git a/src/utils/AuthButton.js b/src/utils/AuthButton.js
index 5ed86d0d..bf94a4a6 100644
--- a/src/utils/AuthButton.js
+++ b/src/utils/AuthButton.js
@@ -20,17 +20,6 @@ import { Button, Popconfirm } from "antd";
 import PropTypes from "prop-types";
 import { connect } from "dva";
 
-// button cache
-let buttonCache = {};
-
-/**
- *  reset authorized button cache
- *
- */
-export function resetAuthButtonCache() {
-  buttonCache = {};
-}
-
 /**
  * check button's authority
  *
@@ -44,18 +33,12 @@ export function checkButtonAuth(perms, permissions) {
     permissions.button &&
     permissions.button.length > 0
   ) {
-    if (buttonCache && buttonCache[perms]) {
-      return buttonCache[perms];
-    }
     let { button: functionsList } = permissions;
     let authFunctions = functionsList.filter((item) => {
       return item.perms === perms;
     });
     const authFunction =
       authFunctions && authFunctions.length > 0 ? authFunctions[0] : null;
-    if (authFunction) {
-      buttonCache.perms = authFunction;
-    }
     return authFunction;
   } else {
     return false;
@@ -66,16 +49,6 @@ export function checkButtonAuth(perms, permissions) {
   global,
 }))
 export default class AuthButton extends Component {
-  constructor(props) {
-    super(props);
-    const {
-      global: { permissions },
-    } = props;
-    if (!permissions || !permissions.menu || permissions.menu.length === 0) {
-      resetAuthButtonCache();
-    }
-  }
-
   render() {
     const {
       perms,
diff --git a/src/utils/AuthButton.test.js b/src/utils/AuthButton.test.js
new file mode 100644
index 00000000..2bb4b2cb
--- /dev/null
+++ b/src/utils/AuthButton.test.js
@@ -0,0 +1,49 @@
+/*
+ * 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 { checkButtonAuth } from "./AuthButton";
+
+it("returns only the exact permission granted by the current namespace", () => 
{
+  const edit = { perms: "rule:edit", icon: "edit" };
+  expect(checkButtonAuth("rule:edit", { button: [edit] })).toBe(edit);
+  expect(checkButtonAuth("rule", { button: [edit] })).toBeNull();
+  expect(checkButtonAuth("rule:delete", { button: [edit] })).toBeNull();
+});
+
+it.each([undefined, {}, { button: [] }])(
+  "denies access with missing permissions %p",
+  (permissions) => {
+    expect(checkButtonAuth("rule:edit", permissions)).toBe(false);
+  },
+);
+
+it("does not reuse a grant after namespace permissions change", () => {
+  const first = { button: [{ perms: "rule:edit" }] };
+  const second = { button: [{ perms: "rule:read" }] };
+  expect(checkButtonAuth("rule:edit", first)).toEqual(first.button[0]);
+  expect(checkButtonAuth("rule:edit", second)).toBeNull();
+  expect(checkButtonAuth("rule:read", second)).toEqual(second.button[0]);
+});
+
+it.each(["toString", "constructor", "perms"])(
+  "does not treat an object property %s as a permission",
+  (perms) => {
+    const permissions = { button: [{ perms: "rule:edit" }] };
+    checkButtonAuth("rule:edit", permissions);
+    expect(checkButtonAuth(perms, permissions)).toBeNull();
+  },
+);
diff --git a/src/utils/AuthRoute.js b/src/utils/AuthRoute.js
index a4a7b1d5..369f0778 100644
--- a/src/utils/AuthRoute.js
+++ b/src/utils/AuthRoute.js
@@ -28,8 +28,10 @@ const notCheckRouteUrl = ["/", "/home"];
 
 // menuItem cache
 let menuCache = [];
+let menuCacheSource;
 // menus cache
 let authMenusCache = {};
+let authMenusCacheSource;
 
 function formatRouteUrl(routeUrl) {
   if (routeUrl.startsWith("/plug/")) {
@@ -45,7 +47,9 @@ function formatRouteUrl(routeUrl) {
  */
 export function resetAuthMenuCache() {
   menuCache = [];
+  menuCacheSource = undefined;
   authMenusCache = {};
+  authMenusCacheSource = undefined;
 }
 
 /**
@@ -64,7 +68,9 @@ export function checkMenuAuth(routeUrl, permissions) {
     return routeUrl;
   }
   if (permissions && permissions.menu && permissions.menu.length > 0) {
-    if (!menuCache || menuCache.length === 0) {
+    if (menuCacheSource !== permissions.menu) {
+      menuCache = [];
+      menuCacheSource = permissions.menu;
       permissions.menu.forEach((m) => {
         filterTree(m, (menuItem) => {
           menuCache.push(menuItem);
@@ -92,6 +98,16 @@ export function checkMenuAuth(routeUrl, permissions) {
  * @param {Boolean} beginCache
  */
 export function getAuthMenus(plugins, menuTree, permissions, beginCache) {
+  const permissionMenu = permissions && permissions.menu;
+  if (
+    !authMenusCacheSource ||
+    authMenusCacheSource.plugins !== plugins ||
+    authMenusCacheSource.menuTree !== menuTree ||
+    authMenusCacheSource.permissionMenu !== permissionMenu
+  ) {
+    authMenusCache = {};
+    authMenusCacheSource = { plugins, menuTree, permissionMenu };
+  }
   if (beginCache && authMenusCache && Object.keys(authMenusCache).length > 0) {
     let locale = window.sessionStorage.getItem("locale");
     let authCacheMenus = authMenusCache[locale];
diff --git a/src/utils/AuthRoute.test.js b/src/utils/AuthRoute.test.js
new file mode 100644
index 00000000..2751c8f8
--- /dev/null
+++ b/src/utils/AuthRoute.test.js
@@ -0,0 +1,148 @@
+/*
+ * 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 { checkMenuAuth, getAuthMenus, resetAuthMenuCache } from "./AuthRoute";
+
+jest.mock("./IntlUtils", () => ({ getIntlContent: (key) => key }));
+
+beforeEach(() => resetAuthMenuCache());
+afterEach(() => resetAuthMenuCache());
+
+it.each(["/", "/home", "/exception/403"])(
+  "allows the public route %s without permissions",
+  (path) => {
+    expect(checkMenuAuth(path, {})).toBe(path);
+  },
+);
+
+it("denies protected routes when permissions are missing or empty", () => {
+  expect(checkMenuAuth("/system/user", undefined)).toBe(false);
+  expect(checkMenuAuth("/system/user", { menu: [] })).toBe(false);
+});
+
+it("finds nested menu permissions without granting adjacent routes", () => {
+  const permissions = {
+    menu: [
+      { url: "/system", children: [{ url: "/system/user", children: [] }] },
+    ],
+  };
+  expect(checkMenuAuth("/system/user", permissions)).toBe("/system/user");
+  expect(checkMenuAuth("/system/user-extra", permissions)).toBe(false);
+});
+
+it("matches a plugin instance against its normalized permission route", () => {
+  const permissions = { menu: [{ url: "/plug/divide" }] };
+  expect(checkMenuAuth("/plug/42/divide", 
permissions)).toBe("/plug/42/divide");
+  expect(checkMenuAuth("/plug/42/dubbo", permissions)).toBe(false);
+});
+
+it("uses the new namespace's permissions even when a previous menu was 
cached", () => {
+  const first = { menu: [{ url: "/system/user" }] };
+  const second = { menu: [{ url: "/system/role" }] };
+  expect(checkMenuAuth("/system/user", first)).toBe("/system/user");
+  expect(checkMenuAuth("/system/user", second)).toBe(false);
+  expect(checkMenuAuth("/system/role", second)).toBe("/system/role");
+});
+
+const sidebarPermissions = {
+  menu: [
+    { url: "/plug", meta: {}, children: [{ url: "/plug/divide", meta: {} }] },
+    {
+      url: "/system",
+      meta: {},
+      children: [
+        { url: "/system/role", meta: {} },
+        { url: "/system/manage", meta: {} },
+      ],
+    },
+  ],
+};
+
+it("clears cached sidebar entries immediately when permissions are cleared", 
() => {
+  const plugins = [];
+  const tree = [];
+  expect(
+    getAuthMenus(plugins, tree, sidebarPermissions, true).length,
+  ).toBeGreaterThan(0);
+  expect(getAuthMenus(plugins, tree, { menu: [], button: [] }, true)).toEqual(
+    [],
+  );
+});
+
+it("rebuilds cached plugin entries when plugins finish loading or change 
namespace", () => {
+  const tree = [];
+  getAuthMenus([], tree, sidebarPermissions, true);
+  const first = getAuthMenus(
+    [{ name: "divide", role: "0", id: "plugin-A" }],
+    tree,
+    sidebarPermissions,
+    true,
+  );
+  expect(first[0].children[0].children[0].id).toBe("plugin-A");
+  const second = getAuthMenus(
+    [{ name: "divide", role: "0", id: "plugin-B" }],
+    tree,
+    sidebarPermissions,
+    true,
+  );
+  expect(second[0].children[0].children[0].id).toBe("plugin-B");
+});
+
+it("rebuilds cached sidebar entries when the resource menu tree changes", () 
=> {
+  const plugins = [];
+  function treeFor(child) {
+    return [
+      {
+        name: "system",
+        url: "/system",
+        meta: { title: "System" },
+        children: [{ url: `/system/${child}`, meta: { title: child } }],
+      },
+    ];
+  }
+  const first = getAuthMenus(
+    plugins,
+    treeFor("role"),
+    sidebarPermissions,
+    true,
+  );
+  expect(first.find((menu) => menu.path === "/system").children[0].path).toBe(
+    "/system/role",
+  );
+  const second = getAuthMenus(
+    plugins,
+    treeFor("manage"),
+    sidebarPermissions,
+    true,
+  );
+  expect(second.find((menu) => menu.path === "/system").children[0].path).toBe(
+    "/system/manage",
+  );
+});
+
+it("keeps locale-specific caches consistent after their permission source 
changes", () => {
+  const plugins = [];
+  const tree = [];
+  window.sessionStorage.setItem("locale", "en-US");
+  getAuthMenus(plugins, tree, sidebarPermissions, true);
+  window.sessionStorage.setItem("locale", "zh-CN");
+  const emptyPermissions = { menu: [] };
+  expect(getAuthMenus(plugins, tree, emptyPermissions, true)).toEqual([]);
+  window.sessionStorage.setItem("locale", "en-US");
+  expect(getAuthMenus(plugins, tree, emptyPermissions, true)).toEqual([]);
+  window.sessionStorage.removeItem("locale");
+});
diff --git a/src/utils/pluginConfig.js b/src/utils/pluginConfig.js
new file mode 100644
index 00000000..b753a2b4
--- /dev/null
+++ b/src/utils/pluginConfig.js
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+// Shared conversion for plugin-template and namespace-plugin forms.
+export function getConfigFieldValue(config, field, defaultValue) {
+  const value = config ? config[field] : undefined;
+  return value === undefined ? defaultValue : value;
+}
+
+export function serializePluginConfig({
+  fields,
+  values,
+  config,
+  jsonValues = {},
+}) {
+  if (!fields || fields.length === 0) {
+    return values.config === undefined ? config : values.config;
+  }
+  // Merge edits into stored configuration so fields absent from the schema
+  // survive a read/edit/write cycle.
+  const stored =
+    typeof config === "string" && config ? JSON.parse(config) : config;
+  const result = { ...stored };
+  fields.forEach(({ field }) => {
+    const fieldName = `__${field}__`;
+    if (Object.prototype.hasOwnProperty.call(values, fieldName)) {
+      if (values[fieldName] === undefined) {
+        delete result[field];
+      } else {
+        result[field] = values[fieldName];
+      }
+    }
+    if (Object.prototype.hasOwnProperty.call(jsonValues, field)) {
+      result[field] = jsonValues[field];
+    }
+  });
+  return Object.keys(result).length ? JSON.stringify(result) : "";
+}
diff --git a/src/utils/pluginConfig.test.js b/src/utils/pluginConfig.test.js
new file mode 100644
index 00000000..819356e5
--- /dev/null
+++ b/src/utils/pluginConfig.test.js
@@ -0,0 +1,148 @@
+/*
+ * 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 { getConfigFieldValue, serializePluginConfig } from "./pluginConfig";
+
+const fields = ["timeout", "enabled", "name", "settings"].map((field) => ({
+  field,
+}));
+
+it.each([0, false, "", null])(
+  "keeps a stored %p instead of replacing it with the default",
+  (value) => {
+    expect(getConfigFieldValue({ timeout: value }, "timeout", 30)).toBe(value);
+  },
+);
+
+it("uses defaults only for missing values", () => {
+  expect(getConfigFieldValue({}, "timeout", 30)).toBe(30);
+  expect(getConfigFieldValue(undefined, "timeout", 30)).toBe(30);
+  expect(getConfigFieldValue({ timeout: undefined }, "timeout", 30)).toBe(30);
+});
+
+it("preserves zero, booleans, empty strings and explicit null on submission", 
() => {
+  const config = serializePluginConfig({
+    fields,
+    values: {
+      __timeout__: 0,
+      __enabled__: false,
+      __name__: "",
+      __settings__: null,
+    },
+  });
+  expect(JSON.parse(config)).toEqual({
+    timeout: 0,
+    enabled: false,
+    name: "",
+    settings: null,
+  });
+});
+
+it("preserves fields outside the form and unchanged nested JSON during 
edit-save", () => {
+  const original = {
+    timeout: 5,
+    enabled: false,
+    name: "",
+    settings: {
+      retries: 0,
+      enabled: false,
+      nodes: [{ host: "upstream", weight: 0 }],
+    },
+    extension: { vendor: "custom" },
+  };
+  const config = serializePluginConfig({
+    fields,
+    values: { __timeout__: 5, __enabled__: false, __name__: "" },
+    config: JSON.stringify(original),
+  });
+  expect(JSON.parse(config)).toEqual(original);
+});
+
+it("changes only submitted fields, without mutating the original configuration 
or form values", () => {
+  const original = Object.freeze({
+    timeout: 30,
+    extension: Object.freeze({ keep: true }),
+  });
+  const values = Object.freeze({
+    __timeout__: 0,
+    name: "plugin",
+    __unknown__: "ignored",
+  });
+  const config = serializePluginConfig({ fields, values, config: original });
+  expect(JSON.parse(config)).toEqual({ timeout: 0, extension: { keep: true } 
});
+  expect(original.timeout).toBe(30);
+  expect(values).toHaveProperty("__timeout__", 0);
+});
+
+it("distinguishes an omitted form field from an explicitly cleared one", () => 
{
+  const config = serializePluginConfig({
+    fields,
+    values: { __timeout__: undefined },
+    config: '{"timeout":30,"enabled":false}',
+  });
+  expect(JSON.parse(config)).toEqual({ enabled: false });
+});
+
+it("preserves the raw configuration when there is no generated form", () => {
+  const config = ' { "enabled": false, "items": [] } ';
+  expect(serializePluginConfig({ values: {}, config })).toBe(config);
+  expect(serializePluginConfig({ fields: [], values: {}, config })).toBe(
+    config,
+  );
+  expect(
+    serializePluginConfig({ fields: [], values: { config: "" }, config }),
+  ).toBe("");
+});
+
+it("keeps an empty configuration empty", () => {
+  expect(serializePluginConfig({ fields, values: {}, config: "" })).toBe("");
+});
+
+it("retains nested JSON types supplied by the JSON editor and unrelated 
fields", () => {
+  const edited = {
+    checks: [{ enabled: false, retries: 0 }],
+    empty: {},
+    list: [],
+  };
+  const config = serializePluginConfig({
+    fields,
+    values: { __name__: "edited" },
+    config: '{"extension":"keep","settings":{"old":true}}',
+    jsonValues: { settings: edited },
+  });
+  expect(JSON.parse(config)).toEqual({
+    name: "edited",
+    extension: "keep",
+    settings: edited,
+  });
+});
+
+it("can explicitly clear a JSON object without restoring its old contents", () 
=> {
+  const config = serializePluginConfig({
+    fields,
+    values: {},
+    config: '{"settings":{"old":true}}',
+    jsonValues: { settings: {} },
+  });
+  expect(JSON.parse(config)).toEqual({ settings: {} });
+});
+
+it("does not silently discard malformed stored JSON", () => {
+  expect(() =>
+    serializePluginConfig({ fields, values: {}, config: "{invalid" }),
+  ).toThrow(SyntaxError);
+});
diff --git a/src/utils/request.js b/src/utils/request.js
index d9cd568f..e8ee1586 100644
--- a/src/utils/request.js
+++ b/src/utils/request.js
@@ -77,7 +77,8 @@ const checkResponseCode = (response) => {
  *
  * @param  {string} url       The URL we want to request
  * @param  {object} [options] The options we want to pass to "fetch"
- * @return {object}           An object containing either "data" or "err"
+ * @return {Promise}          The API payload, or null for HTTP 204. Rejects on
+ *                            HTTP, authentication, network, or JSON errors.
  */
 export default function request(url, options) {
   const defaultOptions = {};
@@ -116,13 +117,13 @@ export default function request(url, options) {
   return fetch(url, newOptions)
     .then(checkStatus)
     .then((response) => {
-      if (newOptions.method === "DELETE" || response.status === 204) {
-        return response.json();
+      if (response.status === 204) {
+        return null;
       }
       return response.json();
     })
     .then((res) => {
-      if (checkResponseCode(res)) {
+      if (res === null || checkResponseCode(res)) {
         return res;
       }
     })
@@ -137,5 +138,6 @@ export default function request(url, options) {
           type: "global/resetPermission",
         });
       }
+      throw e;
     });
 }
diff --git a/src/utils/request.test.js b/src/utils/request.test.js
new file mode 100644
index 00000000..bca38dab
--- /dev/null
+++ b/src/utils/request.test.js
@@ -0,0 +1,181 @@
+/*
+ * 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 fetch from "dva/fetch";
+import { notification } from "antd";
+import store from "../index";
+import request from "./request";
+
+jest.mock("dva/fetch", () => jest.fn());
+jest.mock("antd", () => ({ notification: { error: jest.fn() } }));
+jest.mock("../index", () => ({ dispatch: jest.fn() }));
+jest.mock("./IntlUtils", () => ({ getIntlContent: (key) => key }));
+
+const payload = { code: 200, data: { id: "rule-1" } };
+function response(status = 200, body = payload) {
+  return {
+    status,
+    statusText: "Request failed",
+    url: "/rule",
+    json: jest.fn().mockResolvedValue(body),
+  };
+}
+
+beforeEach(() => {
+  window.sessionStorage.clear();
+  fetch.mockReset();
+  fetch.mockResolvedValue(response());
+});
+
+afterEach(() => window.sessionStorage.clear());
+
+it("returns the API payload without requiring options or a token", async () => 
{
+  await expect(request("/rule")).resolves.toEqual(payload);
+  expect(fetch).toHaveBeenCalledWith("/rule", {});
+});
+
+it.each(["POST", "PUT", "DELETE"])(
+  "serializes %s JSON without losing false, zero, empty or nested values",
+  async (method) => {
+    const body = Object.freeze({
+      enabled: false,
+      sort: 0,
+      name: "",
+      nullable: null,
+      config: { nodes: ["a", "b"] },
+    });
+    const headers = Object.freeze({ "X-Custom": "value" });
+    const options = Object.freeze({ method, body, headers });
+    window.sessionStorage.setItem("token", "session-token");
+    await request("/rule", options);
+    const sent = fetch.mock.calls[0][1];
+    expect(JSON.parse(sent.body)).toEqual(body);
+    expect(sent.headers).toMatchObject({
+      "Content-Type": "application/json; charset=utf-8",
+      "X-Access-Token": "session-token",
+      "X-Custom": "value",
+    });
+    expect(options.body).toBe(body);
+    expect(options.headers).toEqual({ "X-Custom": "value" });
+  },
+);
+
+it("keeps FormData intact and lets the browser supply the multipart boundary", 
async () => {
+  const body = new FormData();
+  body.append("file", new File(["plugin"], "plugin.jar"));
+  window.sessionStorage.setItem("token", "session-token");
+  await request("/plugin-template", { method: "POST", body });
+  const sent = fetch.mock.calls[0][1];
+  expect(sent.body).toBe(body);
+  expect(sent.headers["X-Access-Token"]).toBe("session-token");
+  expect(sent.headers).not.toHaveProperty("Content-Type");
+});
+
+it("reads the current token for each call instead of retaining an old 
session", async () => {
+  window.sessionStorage.setItem("token", "first");
+  await request("/rule");
+  window.sessionStorage.setItem("token", "second");
+  await request("/rule");
+  window.sessionStorage.removeItem("token");
+  await request("/rule");
+  expect(fetch.mock.calls[0][1].headers["X-Access-Token"]).toBe("first");
+  expect(fetch.mock.calls[1][1].headers["X-Access-Token"]).toBe("second");
+  expect(fetch.mock.calls[2][1].headers).toBeUndefined();
+});
+
+it("preserves custom content types", async () => {
+  await request("/rule", {
+    method: "POST",
+    headers: { "Content-Type": "application/custom+json" },
+    body: {},
+  });
+  expect(fetch.mock.calls[0][1].headers["Content-Type"]).toBe(
+    "application/custom+json",
+  );
+});
+
+it.each([200, 201])("returns JSON for successful status %s", async (status) => 
{
+  fetch.mockResolvedValue(response(status));
+  await expect(request("/rule", { method: "DELETE" })).resolves.toEqual(
+    payload,
+  );
+});
+
+it("does not parse JSON from an empty 204 response", async () => {
+  const empty = response(204);
+  empty.json.mockRejectedValue(new SyntaxError("Unexpected end of JSON 
input"));
+  fetch.mockResolvedValue(empty);
+  await expect(request("/rule", { method: "DELETE" })).resolves.toBeNull();
+  expect(empty.json).not.toHaveBeenCalled();
+});
+
+it.each(["http", "application"])(
+  "rejects %s 401 and resets login and permissions",
+  async (kind) => {
+    const unauthorized =
+      kind === "http"
+        ? response(401)
+        : response(200, { code: 401, message: "Session expired" });
+    fetch.mockResolvedValue(unauthorized);
+    await expect(request("/rule")).rejects.toMatchObject({ name: 401 });
+    expect(store.dispatch.mock.calls).toEqual([
+      [{ type: "login/logout" }],
+      [{ type: "global/resetPermission" }],
+    ]);
+    expect(notification.error).toHaveBeenCalledTimes(1);
+  },
+);
+
+it.each([403, 404, 500, 503])(
+  "rejects HTTP %s without logging the user out",
+  async (status) => {
+    const failed = response(status);
+    fetch.mockResolvedValue(failed);
+    await expect(request("/rule")).rejects.toMatchObject({
+      name: status,
+      response: failed,
+    });
+    expect(failed.json).not.toHaveBeenCalled();
+    expect(notification.error).toHaveBeenCalledTimes(1);
+    expect(store.dispatch).not.toHaveBeenCalled();
+  },
+);
+
+it("propagates network failures unchanged to callers", async () => {
+  const error = new TypeError("Failed to fetch");
+  fetch.mockRejectedValue(error);
+  await expect(request("/rule")).rejects.toBe(error);
+  expect(store.dispatch).not.toHaveBeenCalled();
+});
+
+it("propagates malformed JSON instead of resolving with undefined", async () 
=> {
+  const invalid = response();
+  const error = new SyntaxError("Invalid JSON");
+  invalid.json.mockRejectedValue(error);
+  fetch.mockResolvedValue(invalid);
+  await expect(request("/rule")).rejects.toBe(error);
+});
+
+it("leaves non-authentication business errors available to model callers", 
async () => {
+  const rejected = {
+    code: 400,
+    message: "A rule with this name already exists",
+  };
+  fetch.mockResolvedValue(response(200, rejected));
+  await expect(request("/rule")).resolves.toEqual(rejected);
+  expect(store.dispatch).not.toHaveBeenCalled();
+});

Reply via email to