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