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

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


The following commit(s) were added to refs/heads/master by this push:
     new 318bca8f4 fix: harden stream_routes error rendering and pagination 
search params (#3429)
318bca8f4 is described below

commit 318bca8f444782bf37ba16571b3ee841e2a1bab0
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 14 14:48:29 2026 +0800

    fix: harden stream_routes error rendering and pagination search params 
(#3429)
---
 .../pagination.invalid-search-params.spec.ts       | 77 ++++++++++++++++++++++
 .../stream-routes.error-400-no-body.spec.ts        | 57 ++++++++++++++++
 .../page-slice/stream_routes/ErrorComponent.tsx    |  4 +-
 src/types/schema/pageSearch.ts                     | 16 ++---
 4 files changed, 143 insertions(+), 11 deletions(-)

diff --git a/e2e/tests/regression/pagination.invalid-search-params.spec.ts 
b/e2e/tests/regression/pagination.invalid-search-params.spec.ts
new file mode 100644
index 000000000..2945acba0
--- /dev/null
+++ b/e2e/tests/regression/pagination.invalid-search-params.spec.ts
@@ -0,0 +1,77 @@
+/**
+ * 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.
+ */
+
+// Regression: pageSearchSchema accepted any string for page/page_size and
+// transformed it with Number(), so `?page=abc` sent page=NaN to the Admin
+// API, and malformed inputs that failed the union (e.g. duplicated params
+// parsed as arrays) threw a ZodError out of validateSearch. The schema now
+// coerces and falls back to the defaults (page=1, page_size=10) for any
+// invalid input, so hand-edited URLs and stale bookmarks degrade
+// gracefully instead of failing.
+//
+// Part of apache/apisix-dashboard#3417 (error handling item 8).
+
+import { test } from '@e2e/utils/test';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+test('?page=abc degrades to page=1 instead of sending NaN', async ({
+  page,
+  baseURL,
+}) => {
+  const crashes = watchForCrashes(page);
+
+  const listRequests: string[] = [];
+  await page.route(
+    (url) => url.pathname.endsWith('/apisix/admin/routes'),
+    async (route) => {
+      if (route.request().method() === 'GET') {
+        listRequests.push(route.request().url());
+      }
+      await route.fallback();
+    }
+  );
+
+  await page.goto(`${baseURL}routes?page=abc&page_size=xyz`);
+
+  // The list must render normally on the fallback values.
+  await expect(
+    page.getByRole('heading', { name: 'Routes', exact: true })
+  ).toBeVisible({ timeout: 15_000 });
+  crashes.expectNoCrash('routes list with ?page=abc');
+
+  expect(listRequests.length).toBeGreaterThan(0);
+  for (const url of listRequests) {
+    const params = new URL(url).searchParams;
+    expect(params.get('page')).toBe('1');
+    expect(params.get('page_size')).toBe('10');
+  }
+});
+
+test('duplicated pagination params do not crash the list page', async ({
+  page,
+  baseURL,
+}) => {
+  const crashes = watchForCrashes(page);
+
+  await page.goto(`${baseURL}routes?page=1&page=2`);
+
+  await expect(
+    page.getByRole('heading', { name: 'Routes', exact: true })
+  ).toBeVisible({ timeout: 15_000 });
+  crashes.expectNoCrash('routes list with duplicated page params');
+});
diff --git a/e2e/tests/regression/stream-routes.error-400-no-body.spec.ts 
b/e2e/tests/regression/stream-routes.error-400-no-body.spec.ts
new file mode 100644
index 000000000..2d605eb06
--- /dev/null
+++ b/e2e/tests/regression/stream-routes.error-400-no-body.spec.ts
@@ -0,0 +1,57 @@
+/**
+ * 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.
+ */
+
+// Regression: StreamRoutesErrorComponent special-cases 400 responses to
+// show APISIX's error_msg, but dereferenced `err.response?.data.error_msg`
+// — guarding response, not data. A 400 with an empty or non-JSON body
+// rendered an empty error area (or crashed the error component itself for
+// nullish data). It now guards data and falls back to the axios error
+// message so the error area is never blank.
+//
+// Part of apache/apisix-dashboard#3417 (error handling item 2, residual).
+
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { watchForCrashes } from '@e2e/utils/ui/crash';
+import { expect } from '@playwright/test';
+
+test('stream routes 400 without a body renders a visible error, not a blank 
page', async ({
+  page,
+}) => {
+  const crashes = watchForCrashes(page);
+
+  // A 400 with an EMPTY body: axios cannot parse error_msg out of it.
+  await page.route(
+    (url) => url.pathname.endsWith('/apisix/admin/stream_routes'),
+    async (route) => {
+      if (route.request().method() === 'GET') {
+        await route.fulfill({ status: 400, body: '' });
+      } else {
+        await route.fallback();
+      }
+    }
+  );
+
+  await uiGoto(page, '/stream_routes');
+
+  // Queries retry 3 times (~7s) before the loader error surfaces.
+  await expect(
+    page.getByText('Request failed with status code 400')
+  ).toBeVisible({ timeout: 15_000 });
+
+  crashes.expectNoCrash('stream_routes 400-no-body error render');
+});
diff --git a/src/components/page-slice/stream_routes/ErrorComponent.tsx 
b/src/components/page-slice/stream_routes/ErrorComponent.tsx
index 6e680179b..7dfea38b2 100644
--- a/src/components/page-slice/stream_routes/ErrorComponent.tsx
+++ b/src/components/page-slice/stream_routes/ErrorComponent.tsx
@@ -26,7 +26,9 @@ export const StreamRoutesErrorComponent = (props: 
ErrorComponentProps) => {
   if (props.error instanceof AxiosError) {
     const err = props.error as AxiosError<APISIXRespErr>;
     if (err.response?.status === 400) {
-      return <span>{err.response?.data.error_msg}</span>;
+      // data can be missing or unparsed (empty/non-JSON body); never
+      // dereference it bare and never render an empty error area
+      return <span>{err.response.data?.error_msg ?? err.message}</span>;
     }
   }
   return <ErrorComponent {...props} />;
diff --git a/src/types/schema/pageSearch.ts b/src/types/schema/pageSearch.ts
index fb663566b..55213c5d7 100644
--- a/src/types/schema/pageSearch.ts
+++ b/src/types/schema/pageSearch.ts
@@ -17,18 +17,14 @@
 import { z } from 'zod';
 
 
+// Search params come from the URL and can be arbitrary garbage
+// (hand-edited, stale bookmarks, duplicated keys parsed as arrays).
+// `catch` degrades every invalid shape to the default instead of sending
+// NaN to the Admin API or throwing a ZodError out of validateSearch.
 export const pageSearchSchema = z
   .object({
-    page: z
-      .union([z.string(), z.number()])
-      .optional()
-      .default(1)
-      .transform((val) => (val ? Number(val) : 1)),
-    page_size: z
-      .union([z.string(), z.number()])
-      .optional()
-      .default(10)
-      .transform((val) => (val ? Number(val) : 10)),
+    page: z.coerce.number().int().min(1).catch(1),
+    page_size: z.coerce.number().int().min(1).catch(10),
     name: z.string().optional(),
     label: z.string().optional(),
   })

Reply via email to