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

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


The following commit(s) were added to refs/heads/master by this push:
     new 95d05bf9 fix(login): wait for secret initialization and guard failures 
(#670)
95d05bf9 is described below

commit 95d05bf9229347256c3920898cf5a5691a781659
Author: Efe <[email protected]>
AuthorDate: Sun Sep 27 07:47:04 2026 +0300

    fix(login): wait for secret initialization and guard failures (#670)
    
    * fix(login): wait for secret initialization
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    
    * fix(login): allow empty admin secrets
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    
    ---------
    
    Signed-off-by: Efe Gökdemir <[email protected]>
---
 src/models/login.js                 | 17 ++++++++++---
 src/models/login.test.js            | 45 +++++++++++++++++++++++++++++++++
 src/routes/User/Login.js            | 48 +++++++++++------------------------
 src/routes/User/Login.test.js       | 34 +++++++++++++++++++++++++
 src/routes/User/loginSecret.js      | 50 +++++++++++++++++++++++++++++++++++++
 src/routes/User/loginSecret.test.js | 43 +++++++++++++++++++++++++++++++
 6 files changed, 199 insertions(+), 38 deletions(-)

diff --git a/src/models/login.js b/src/models/login.js
index e5e5b846..00f40e04 100644
--- a/src/models/login.js
+++ b/src/models/login.js
@@ -31,12 +31,19 @@ export default {
   effects: {
     *login({ payload }, { call, put, select }) {
       const { callback } = payload;
-      const response = yield call(queryLogin, payload);
+      let response;
+      let requestError;
+      try {
+        response = yield call(queryLogin, payload);
+      } catch (error) {
+        requestError = error;
+        response = error.response?.data ?? error.response;
+      }
       yield call(callback, response);
       const namespaces = yield select((state) => state.global.namespaces);
 
       // Login successfully
-      if (response.data) {
+      if (response?.data) {
         yield put({
           type: "changeLoginStatus",
           payload: {
@@ -76,10 +83,12 @@ export default {
         yield put(routerRedux.push("/home"));
       } else {
         message.destroy();
-        if (response.code === 404) {
+        if (response?.code === 404) {
           message.error("Incorrect user name or password");
         } else {
-          message.error(response.message);
+          message.error(
+            response?.message || requestError?.message || "Login failed",
+          );
         }
       }
     },
diff --git a/src/models/login.test.js b/src/models/login.test.js
new file mode 100644
index 00000000..af04b926
--- /dev/null
+++ b/src/models/login.test.js
@@ -0,0 +1,45 @@
+import { runSaga, effects } from "dva/saga";
+import { message } from "antd";
+import model from "./login";
+import { queryLogin } from "../services/api";
+
+jest.mock("../services/api", () => ({
+  queryLogin: jest.fn(),
+}));
+jest.mock("antd", () => ({
+  message: { destroy: jest.fn(), error: jest.fn() },
+}));
+jest.mock("../components/_utils/utils", () => ({
+  defaultNamespaceId: "default",
+}));
+
+it("does not dereference an absent login response", async () => {
+  queryLogin.mockResolvedValueOnce(undefined);
+  const callback = jest.fn();
+
+  await runSaga(
+    { dispatch: jest.fn(), getState: () => ({ global: { namespaces: [] } }) },
+    model.effects.login,
+    { payload: { callback } },
+    effects,
+  ).done;
+
+  expect(callback).toHaveBeenCalledWith(undefined);
+  expect(message.error).toHaveBeenCalledWith("Login failed");
+});
+
+it("handles rejected login requests without dereferencing a response", async 
() => {
+  const error = new Error("request failed");
+  queryLogin.mockRejectedValueOnce(error);
+  const callback = jest.fn();
+
+  await runSaga(
+    { dispatch: jest.fn(), getState: () => ({ global: { namespaces: [] } }) },
+    model.effects.login,
+    { payload: { callback } },
+    effects,
+  ).done;
+
+  expect(callback).toHaveBeenCalledWith(undefined);
+  expect(message.error).toHaveBeenCalledWith("request failed");
+});
diff --git a/src/routes/User/Login.js b/src/routes/User/Login.js
index 12e2da99..e1386867 100644
--- a/src/routes/User/Login.js
+++ b/src/routes/User/Login.js
@@ -22,35 +22,9 @@ import { Alert } from "antd";
 import UUID from "uuid";
 import Login from "components/Login";
 import styles from "./Login.less";
-import { querySecretInfo } from "../../services/api";
+import { ensureSecret, getSecret } from "./loginSecret";
 
 const { UserName, Password, Submit, VerifyCode, LoginCode } = Login;
-
-let secretKey = "";
-let secretIv = "";
-async function initSecret() {
-  try {
-    let promise = await querySecretInfo();
-    if (typeof promise !== "undefined") {
-      if (promise.status === 200) {
-        let body = await promise.json();
-        let secret = JSON.parse(atob(body.data));
-        if (
-          secret.key != null &&
-          secret.key !== "" &&
-          secret.iv != null &&
-          secret.iv !== ""
-        ) {
-          secretKey = secret.key;
-          secretIv = secret.iv;
-        }
-      }
-    }
-  } catch (e) {
-    // ignore error
-  }
-}
-initSecret().then(() => {});
 @connect(({ login, loading }) => ({
   login,
   submitting: loading.effects["login/login"],
@@ -70,7 +44,7 @@ export default class LoginPage extends Component {
     this.ChildRef.current?.handleChange();
   }
 
-  handleSubmit = (err, values) => {
+  handleSubmit = async (err, values) => {
     const { dispatch } = this.props;
     const { needCode } = this.state;
     if (!err) {
@@ -79,7 +53,11 @@ export default class LoginPage extends Component {
         this.ChildRef.current.handleChange();
         return;
       }
-      if (secretKey !== "" && secretIv !== "") {
+      if (!(await ensureSecret())) {
+        return;
+      }
+      const { key: secretKey, iv: secretIv } = getSecret();
+      if (secretKey && secretIv) {
         const keyByte = CryptoJS.enc.Utf8.parse(secretKey);
         const ivByte = CryptoJS.enc.Utf8.parse(secretIv);
         const encryptedPassword = CryptoJS.AES.encrypt(
@@ -99,16 +77,18 @@ export default class LoginPage extends Component {
         payload: {
           ...values,
           clientId: UUID.v4().replaceAll("-", ""),
-          callback: (res) => {
-            if (res.code === 500) {
-              this.setState({ needCode: true });
-            }
-          },
+          callback: this.handleLoginResponse,
         },
       });
     }
   };
 
+  handleLoginResponse = (res) => {
+    if (res?.code === 500) {
+      this.setState({ needCode: true });
+    }
+  };
+
   // eslint-disable-next-line react/no-unused-class-component-methods
   renderMessage = (content) => {
     return (
diff --git a/src/routes/User/Login.test.js b/src/routes/User/Login.test.js
new file mode 100644
index 00000000..f50d27ad
--- /dev/null
+++ b/src/routes/User/Login.test.js
@@ -0,0 +1,34 @@
+import LoginPage from "./Login";
+
+jest.mock("dva", () => ({
+  connect: () => (Component) => Component,
+}));
+jest.mock("components/Login", () => {
+  const Login = () => null;
+  Login.UserName = () => null;
+  Login.Password = () => null;
+  Login.Submit = () => null;
+  Login.VerifyCode = () => null;
+  Login.LoginCode = () => null;
+  return Login;
+});
+jest.mock("./loginSecret", () => ({
+  ensureSecret: jest.fn().mockResolvedValue(true),
+  getSecret: () => ({ key: "", iv: "" }),
+}));
+jest.mock("crypto-js", () => ({
+  enc: { Utf8: { parse: jest.fn() } },
+  AES: { encrypt: jest.fn() },
+  mode: { CBC: {} },
+  pad: { Pkcs7: {} },
+}));
+jest.mock("uuid", () => ({ v4: () => "client-id" }));
+
+it("does not throw when the login callback receives no response", async () => {
+  const page = new LoginPage({ dispatch: jest.fn() });
+  page.setState = jest.fn();
+
+  expect(() => page.handleLoginResponse(undefined)).not.toThrow();
+  expect(() => page.handleLoginResponse(null)).not.toThrow();
+  expect(page.setState).not.toHaveBeenCalled();
+});
diff --git a/src/routes/User/loginSecret.js b/src/routes/User/loginSecret.js
new file mode 100644
index 00000000..b7094d28
--- /dev/null
+++ b/src/routes/User/loginSecret.js
@@ -0,0 +1,50 @@
+import { querySecretInfo } from "../../services/api";
+
+let secretKey = "";
+let secretIv = "";
+let secretInitialized = false;
+let secretPromise;
+
+async function loadSecret() {
+  try {
+    const response = await querySecretInfo();
+    if (response?.status !== 200) {
+      return false;
+    }
+
+    const body = await response.json();
+    const secret = JSON.parse(atob(body.data));
+    const hasKey = Boolean(secret.key);
+    const hasIv = Boolean(secret.iv);
+    if (hasKey !== hasIv) {
+      return false;
+    }
+    secretKey = secret.key || "";
+    secretIv = secret.iv || "";
+    secretInitialized = true;
+    return true;
+  } catch (error) {
+    // The submit path retries once when the initial request is still 
unavailable.
+  }
+  return false;
+}
+
+export async function ensureSecret() {
+  if (secretInitialized) {
+    return true;
+  }
+
+  if (!secretPromise) {
+    secretPromise = loadSecret();
+  }
+  const loaded = await secretPromise;
+  if (!loaded && !secretInitialized) {
+    secretPromise = loadSecret();
+    await secretPromise;
+  }
+  return secretInitialized;
+}
+
+export function getSecret() {
+  return { key: secretKey, iv: secretIv };
+}
diff --git a/src/routes/User/loginSecret.test.js 
b/src/routes/User/loginSecret.test.js
new file mode 100644
index 00000000..75e773c6
--- /dev/null
+++ b/src/routes/User/loginSecret.test.js
@@ -0,0 +1,43 @@
+import { ensureSecret, getSecret } from "./loginSecret";
+import { querySecretInfo } from "../../services/api";
+
+jest.mock("../../services/api", () => ({
+  querySecretInfo: jest.fn(),
+}));
+
+const response = (key, iv) => ({
+  status: 200,
+  json: async () => ({ data: btoa(JSON.stringify({ key, iv })) }),
+});
+
+it("retries secret initialization when the eager request fails", async () => {
+  querySecretInfo.mockRejectedValueOnce(new Error("not ready"));
+  querySecretInfo.mockResolvedValueOnce(response("key", "iv"));
+
+  await expect(ensureSecret()).resolves.toBe(true);
+  expect(getSecret()).toEqual({ key: "key", iv: "iv" });
+  expect(querySecretInfo).toHaveBeenCalledTimes(2);
+});
+
+it("accepts an initialized Admin response without client-side secrets", async 
() => {
+  jest.resetModules();
+  let isolatedQuerySecretInfo;
+  jest.doMock("../../services/api", () => ({
+    querySecretInfo: (() => {
+      isolatedQuerySecretInfo = jest.fn();
+      return isolatedQuerySecretInfo;
+    })(),
+  }));
+  let ensureInitializedSecret;
+  let getInitializedSecret;
+  jest.isolateModules(() => {
+    ({
+      ensureSecret: ensureInitializedSecret,
+      getSecret: getInitializedSecret,
+    } = require("./loginSecret"));
+  });
+  isolatedQuerySecretInfo.mockResolvedValueOnce(response("", ""));
+
+  await expect(ensureInitializedSecret()).resolves.toBe(true);
+  expect(getInitializedSecret()).toEqual({ key: "", iv: "" });
+});

Reply via email to