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(),
})