diff --git a/e2e/server/apisix_conf.yml b/e2e/server/apisix_conf.yml index 7d75f94368..f0109e4ebf 100644 --- a/e2e/server/apisix_conf.yml +++ b/e2e/server/apisix_conf.yml @@ -16,7 +16,7 @@ # apisix: - node_listen: 9080 # APISIX listening port + node_listen: 9080 enable_ipv6: false proxy_mode: http&stream stream_proxy: @@ -24,24 +24,20 @@ apisix: - 9100 udp: - 9200 - deployment: admin: - allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow - - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test. - + allow_admin: + - 0.0.0.0/0 admin_key: - - name: "admin" + - name: admin key: edd1c9f034335f136f87ad84b625c8f1 - role: admin # admin: manage all configuration data - + role: admin etcd: - host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster. - - "http://etcd:2379" # multiple etcd address - prefix: "/apisix" # apisix configurations prefix - timeout: 30 # 30 seconds - + host: + - http://etcd:2379 + prefix: /apisix + timeout: 30 discovery: nacos: host: - - "http://nacos:8848" + - http://nacos:8848 diff --git a/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts b/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts new file mode 100644 index 0000000000..117b2fa616 --- /dev/null +++ b/e2e/tests/bulk/routes.bulk-100.list-render.spec.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +// Bulk D-01: routes list page must render correctly with 100 rows. +// Data is seeded directly via the Admin API to keep the bulk-render +// assertion decoupled from the add-form behavior. + +import { routesPom } from '@e2e/pom/routes'; +import { + bulkCreateRoutes, + bulkDeleteRoutesByPrefix, +} from '@e2e/utils/bulk'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +const PREFIX = 'bulk100'; +const COUNT = 100; + +test.describe.configure({ mode: 'serial', timeout: 120_000 }); + +test.beforeAll(async () => { + await bulkDeleteRoutesByPrefix(PREFIX); + await bulkCreateRoutes({ count: COUNT, prefix: PREFIX }); +}); + +test.afterAll(async () => { + await bulkDeleteRoutesByPrefix(PREFIX); +}); + +test('routes list shows correct total and renders default page', async ({ + page, +}) => { + const t0 = Date.now(); + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + // Routes are listed in server-defined order (not insertion order), so + // bulk100-0 isn't guaranteed to be on page 1. Just require *some* + // seeded row to render. + const someSeededRow = page + .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) }) + .first(); + await expect(someSeededRow).toBeVisible({ timeout: 5000 }); + const renderMs = Date.now() - t0; + test + .info() + .annotations.push({ type: 'perf', description: `LCP-ish: ${renderMs}ms` }); + expect(renderMs, 'list page should render in < 5s with 100 rows').toBeLessThan( + 5000 + ); +}); + +test('pagination shows ≥ 10 pages at default page_size=10', async ({ + page, +}) => { + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + // page=10 must be reachable (100 rows / 10 per page = 10 pages). + const page10 = page.getByRole('listitem', { name: '10' }); + await expect(page10).toBeVisible(); +}); diff --git a/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts b/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts new file mode 100644 index 0000000000..8018961b9b --- /dev/null +++ b/e2e/tests/bulk/routes.bulk-1000.list-search.spec.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Bulk D-03: with 1000 routes the list page must: +// - render the first page within a reasonable budget +// - allow URL-driven jump to a far page (e.g. ?page=50) +// - not flood the Admin API with per-row requests +// +// Marked as `bulk` — exclude from default CI runs. + +import { routesPom } from '@e2e/pom/routes'; +import { + bulkCreateRoutes, + bulkDeleteRoutesByPrefix, +} from '@e2e/utils/bulk'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +const PREFIX = 'bulk1k'; +const COUNT = 1000; + +test.describe.configure({ mode: 'serial', timeout: 300_000 }); + +test.beforeAll(async () => { + await bulkDeleteRoutesByPrefix(PREFIX); + await bulkCreateRoutes({ count: COUNT, prefix: PREFIX }); +}); + +test.afterAll(async () => { + await bulkDeleteRoutesByPrefix(PREFIX); +}); + +test('first page renders within 5s with 1000 rows in etcd', async ({ + page, +}) => { + const t0 = Date.now(); + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + await expect( + page + .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) }) + .first() + ).toBeVisible({ timeout: 8000 }); + const ms = Date.now() - t0; + test + .info() + .annotations.push({ type: 'perf', description: `first-page render: ${ms}ms` }); + expect(ms).toBeLessThan(8000); +}); + +test('admin API call volume on list page load is bounded (≤ 5 GETs)', async ({ + page, +}) => { + const adminCalls: string[] = []; + page.on('request', (req) => { + if (req.method() === 'GET' && req.url().includes('/apisix/admin/routes')) { + adminCalls.push(req.url()); + } + }); + + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + await expect( + page + .getByRole('row', { name: new RegExp(`${PREFIX}-\\d+`) }) + .first() + ).toBeVisible({ timeout: 8000 }); + // Settle for any deferred queries. + await page.waitForTimeout(800); + + test.info().annotations.push({ + type: 'perf', + description: `admin /routes GETs: ${adminCalls.length}`, + }); + expect( + adminCalls.length, + 'list page should not issue per-row GETs (≤ 5 total)' + ).toBeLessThanOrEqual(5); +}); diff --git a/e2e/tests/consumer_groups.crud-required-fields.spec.ts b/e2e/tests/consumer_groups.crud-required-fields.spec.ts index ade5f7d4a7..5f03309095 100644 --- a/e2e/tests/consumer_groups.crud-required-fields.spec.ts +++ b/e2e/tests/consumer_groups.crud-required-fields.spec.ts @@ -117,8 +117,13 @@ test('should CRUD Consumer Group with required fields', async ({ page }) => { const idField = page.getByRole('textbox', { name: 'ID', exact: true }); await expect(idField).toBeDisabled(); - // Cancel without making changes + // Cancel without making changes. The Edit→Cancel guard now always + // confirms before discarding (see src/hooks/useEditCancelGuard.tsx). await page.getByRole('button', { name: 'Cancel' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Discard Changes' }) + .click(); // Verify we're back in detail view await consumerGroupsPom.isDetailPage(page); diff --git a/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts b/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts new file mode 100644 index 0000000000..6ccf7472cc --- /dev/null +++ b/e2e/tests/edge/plugins.invalid-json-monaco.spec.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Edge: typing syntactically-invalid JSON in the plugin config editor must +// either prevent submission or surface a visible error — never round-trip a +// broken config to the Admin API. + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { + uiFillMonacoEditor, + uiGetMonacoEditor, +} from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; + +const pluginName = 'cors'; +const invalidJson = '{ "allow_origins": "*", '; // missing closing brace + trailing comma + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('invalid JSON in plugin editor cannot be saved', async ({ page }) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByRole('button', { name: 'Select Plugins' }).click(); + const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' }); + await selectDialog.getByPlaceholder('Search').fill(pluginName); + await selectDialog + .getByTestId(`plugin-${pluginName}`) + .getByRole('button', { name: 'Add' }) + .click(); + + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + const editor = await uiGetMonacoEditor(page, addPluginDialog, false); + await uiFillMonacoEditor(page, editor, invalidJson); + + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + + // The dialog must remain open OR an error must surface — what we forbid + // is silently accepting invalid JSON and closing the dialog as if it + // were valid. + await page.waitForTimeout(1000); + const dialogStillOpen = await addPluginDialog + .isVisible() + .catch(() => false); + const errorVisible = await page + .getByText(/(invalid|json|parse|syntax)/i) + .first() + .isVisible() + .catch(() => false); + + expect( + dialogStillOpen || errorVisible, + 'invalid JSON must NOT silently close the editor as if accepted' + ).toBe(true); +}); diff --git a/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts b/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts new file mode 100644 index 0000000000..71cd0647c9 --- /dev/null +++ b/e2e/tests/edge/plugins.large-config-roundtrip.spec.ts @@ -0,0 +1,106 @@ +/** + * 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. + */ + +// Edge: a multi-KB plugin config must round-trip through Monaco + Admin API +// without truncation. We use serverless-pre-function which accepts an +// arbitrary phase / functions structure, allowing us to compose a payload +// that is provably ~10KB on the wire. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { + uiFillMonacoEditor, + uiGetMonacoEditor, + uiHasToastMsg, +} from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'large-cfg-a.local', port: 80, weight: 100 }, + { host: 'large-cfg-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('10KB plugin config survives a UI round trip', async ({ page }) => { + // Build a CORS allow_origins value padded to ~10KB. CORS plugin tolerates + // any string for allow_origins, so the test is purely about payload size, + // not semantics. + const padding = 'x'.repeat(10240); + const pluginConfig = { allow_origins: `https://${padding}.local` }; + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page + .getByLabel('Name', { exact: true }) + .first() + .fill(randomId('edge-large-cfg')); + await page.getByLabel('URI', { exact: true }).fill('/edge/large-cfg'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('edge-up'), desc: 'edge' } + ); + + await page.getByRole('button', { name: 'Select Plugins' }).click(); + const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' }); + await selectDialog.getByPlaceholder('Search').fill('cors'); + await selectDialog + .getByTestId('plugin-cors') + .getByRole('button', { name: 'Add' }) + .click(); + + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + const editor = await uiGetMonacoEditor(page, addPluginDialog, false); + await uiFillMonacoEditor(page, editor, JSON.stringify(pluginConfig)); + await addPluginDialog + .getByRole('button', { name: 'Add' }) + .click(); + await expect(addPluginDialog).toBeHidden({ timeout: 10000 }); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.plugins).toBeDefined(); + expect(route.plugins!.cors).toBeDefined(); + expect( + (route.plugins!.cors as { allow_origins: string }).allow_origins + ).toBe(pluginConfig.allow_origins); +}); diff --git a/e2e/tests/edge/routes.long-name.spec.ts b/e2e/tests/edge/routes.long-name.spec.ts new file mode 100644 index 0000000000..a865ee7d06 --- /dev/null +++ b/e2e/tests/edge/routes.long-name.spec.ts @@ -0,0 +1,122 @@ +/** + * 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. + */ + +// Edge: long route names. The Admin API has its own length limits — the +// dashboard must surface those clearly. Two boundary cases: +// 1. 100 chars (typical APISIX max) → must accept +// 2. 1000 chars (way over) → must reject with a visible error + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'long-a.local', port: 80, weight: 100 }, + { host: 'long-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('100-char route name is accepted', async ({ page }) => { + const longName = 'a'.repeat(100); + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(longName); + await page.getByLabel('URI', { exact: true }).fill('/edge/long/100'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: 'edge-long-up-100', desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.name).toBe(longName); +}); + +test('1000-char route name is rejected with visible feedback', async ({ + page, +}) => { + const tooLong = 'b'.repeat(1000); + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(tooLong); + await page.getByLabel('URI', { exact: true }).fill('/edge/long/1000'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: 'edge-long-up-1000', desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + + // Either the form's inline validation prevents submit OR the API rejects + // and a toast surfaces the error. We require at least one of: + // - we never reach the detail page + // - an alert toast appears + const successToast = page + .getByRole('alert') + .filter({ hasText: 'Add Route Successfully' }); + const errorToast = page + .getByRole('alert') + .filter({ hasText: /(fail|error|invalid|too long|exceed|length)/i }); + + const result = await Promise.race([ + successToast.first().waitFor({ state: 'visible', timeout: 10000 }) + .then(() => 'success' as const) + .catch(() => 'no-success' as const), + errorToast.first().waitFor({ state: 'visible', timeout: 10000 }) + .then(() => 'error' as const) + .catch(() => 'no-error' as const), + ]); + + expect(result, 'a 1000-char name must NOT silently succeed').not.toBe( + 'success' + ); +}); diff --git a/e2e/tests/edge/routes.nonexistent-service-id.spec.ts b/e2e/tests/edge/routes.nonexistent-service-id.spec.ts new file mode 100644 index 0000000000..e037816a9a --- /dev/null +++ b/e2e/tests/edge/routes.nonexistent-service-id.spec.ts @@ -0,0 +1,88 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */ + +// Edge: creating a route that references a non-existent service_id must +// surface an error — never silently appear to succeed. + +import { routesPom } from '@e2e/pom/routes'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; + +const FAKE_SERVICE_ID = 'nonexistent-service-id-edge-001'; + +test.beforeAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq) + ); +}); + +test.afterAll(async () => { + await safeClean(() => deleteAllRoutes(e2eReq)); +}); + +test('route with non-existent service_id is rejected with a visible error', async ({ + page, +}) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page + .getByLabel('Name', { exact: true }) + .first() + .fill(randomId('edge-bad-sid')); + await page.getByLabel('URI', { exact: true }).fill('/edge/bad-service-id'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const serviceSection = page.getByRole('group', { name: 'Service' }); + await serviceSection.locator('input[name="service_id"]').fill(FAKE_SERVICE_ID); + + await routesPom.getAddBtn(page).click(); + + // Must NOT show success + const successToast = page + .getByRole('alert') + .filter({ hasText: 'Add Route Successfully' }); + const accepted = await successToast + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + expect( + accepted, + 'non-existent service_id must NOT silently produce a working route' + ).toBe(false); + + // Either an error toast surfaces or the form stays on add page with + // inline validation. Both are acceptable; silent success is not. + const stillOnAdd = page.url().includes('/routes/add'); + if (!stillOnAdd) { + const errorToast = page + .getByRole('alert') + .filter({ hasText: /(fail|error|invalid|not found|404)/i }); + await expect(errorToast.first()).toBeVisible({ timeout: 5000 }); + } +}); diff --git a/e2e/tests/edge/routes.same-uri-different-method.spec.ts b/e2e/tests/edge/routes.same-uri-different-method.spec.ts new file mode 100644 index 0000000000..d724ca224e --- /dev/null +++ b/e2e/tests/edge/routes.same-uri-different-method.spec.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +// Edge: APISIX legitimately allows two routes that share a URI as long as +// their methods differ. The dashboard must not pre-block this. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteListReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'same-uri-a.local', port: 80, weight: 100 }, + { host: 'same-uri-b.local', port: 80, weight: 100 }, +]; +const sharedUri = '/edge/same-uri'; + +test.describe('routes sharing URI with different methods', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + }); + + test.afterAll(async () => { + await deleteAllRoutes(e2eReq); + }); + + const addRoute = async ( + page: import('@playwright/test').Page, + name: string, + method: string + ) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + await page.getByLabel('Name', { exact: true }).first().fill(name); + await page.getByLabel('URI', { exact: true }).fill(sharedUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: method }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('edge-up'), desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + }; + + test('two routes with same URI but different methods both save', async ({ + page, + }) => { + await addRoute(page, randomId('edge-same-uri-get'), 'GET'); + await addRoute(page, randomId('edge-same-uri-post'), 'POST'); + + const list = await getRouteListReq(e2eReq, { + page: 1, + page_size: 50, + }); + const sameUriRoutes = list.list.filter( + (r) => (r.value as APISIXType['Route']).uri === sharedUri + ); + expect(sameUriRoutes).toHaveLength(2); + }); +}); diff --git a/e2e/tests/edge/routes.special-uri-chars.spec.ts b/e2e/tests/edge/routes.special-uri-chars.spec.ts new file mode 100644 index 0000000000..b02a9e7761 --- /dev/null +++ b/e2e/tests/edge/routes.special-uri-chars.spec.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +// Edge: URI patterns containing characters with special meaning in URI +// space. APISIX accepts trailing-wildcard `/foo/*` and most printable +// chars except whitespace. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'special-a.local', port: 80, weight: 100 }, + { host: 'special-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('route URI with trailing wildcard /api/* is accepted', async ({ + page, +}) => { + const routeName = randomId('edge-uri-wildcard'); + const routeUri = '/edge/api/*'; + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('edge-up'), desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.uri).toBe(routeUri); +}); + +test('route URI with embedded path-parameter pattern /:id is accepted', async ({ + page, +}) => { + const routeName = randomId('edge-uri-param'); + const routeUri = '/edge/users/:id'; + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('edge-up'), desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + // APISIX may or may not honour `:param` syntax; behavior must be either + // success or a clear error toast, never a silent submit. + const toast = page + .getByRole('alert') + .filter({ hasText: /(success|fail|error|invalid)/i }); + await expect(toast.first()).toBeVisible({ timeout: 10000 }); +}); diff --git a/e2e/tests/edge/routes.unicode-emoji-name.spec.ts b/e2e/tests/edge/routes.unicode-emoji-name.spec.ts new file mode 100644 index 0000000000..a5bf5f185c --- /dev/null +++ b/e2e/tests/edge/routes.unicode-emoji-name.spec.ts @@ -0,0 +1,78 @@ +/** + * 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. + */ + +// Edge: route name composed of CJK, emoji and RTL text must round-trip +// cleanly through the Dashboard UI and Admin API. + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +// nanoid for ASCII fallback portion; the bulk of the name is unicode. +const suffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 6)(); +const routeName = `测试-🔥-اختبار-${suffix}`; +const routeUri = `/edge/unicode/${suffix}`; +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'unicode-a.local', port: 80, weight: 100 }, + { host: 'unicode-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('route name with CJK + emoji + RTL persists exactly', async ({ page }) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: `edge-unicode-up-${suffix}`, desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.name).toBe(routeName); + + await routesPom.toIndex(page); + await expect(page.getByRole('cell', { name: routeName })).toBeVisible(); +}); diff --git a/e2e/tests/edge/routes.whitespace-only-name.spec.ts b/e2e/tests/edge/routes.whitespace-only-name.spec.ts new file mode 100644 index 0000000000..e486fe4d9d --- /dev/null +++ b/e2e/tests/edge/routes.whitespace-only-name.spec.ts @@ -0,0 +1,93 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */ + +// Edge: a whitespace-only `name` value must NOT be silently accepted as a +// valid name. Either the field validation rejects it, or the API rejects +// and a toast surfaces the error. + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'ws-a.local', port: 80, weight: 100 }, + { host: 'ws-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('whitespace-only route name is rejected', async ({ page }) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(' '); + await page.getByLabel('URI', { exact: true }).fill('/edge/whitespace'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: 'edge-ws-up', desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + + const successToast = page + .getByRole('alert') + .filter({ hasText: 'Add Route Successfully' }); + const stayOnAddPage = page + .waitForURL((u) => u.pathname.endsWith('/routes/add'), { timeout: 8000 }) + .then(() => true) + .catch(() => false); + + const accepted = await successToast + .first() + .waitFor({ state: 'visible', timeout: 8000 }) + .then(() => true) + .catch(() => false); + + expect(accepted, 'whitespace-only name must NOT silently succeed').toBe( + false + ); + + // Either we stay on Add page (frontend validation) or we have an error + // toast (backend rejection). At least one must be true. + const stillOnAdd = await stayOnAddPage; + if (!stillOnAdd) { + const errorToast = page + .getByRole('alert') + .filter({ hasText: /(fail|error|invalid)/i }); + await expect(errorToast.first()).toBeVisible({ timeout: 5000 }); + } +}); diff --git a/e2e/tests/edge/routes.xss-name-payload.spec.ts b/e2e/tests/edge/routes.xss-name-payload.spec.ts new file mode 100644 index 0000000000..a3a1a48da4 --- /dev/null +++ b/e2e/tests/edge/routes.xss-name-payload.spec.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */ + +// Edge: XSS payloads in user-controlled fields must never execute. The +// route name is a representative free-text field that flows through list, +// detail, and Match Rules rendering. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const xssPayload = ''; +const routeName = `xss-${xssPayload}-${randomId('edge-xss')}`; +const routeUri = '/edge/xss-payload'; +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'xss-a.local', port: 80, weight: 100 }, + { host: 'xss-b.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('XSS payload in route name is rendered as text, not executed', async ({ + page, +}) => { + // Also assert: a real browser `alert()` would surface as a `dialog` + // event. We deliberately do NOT auto-accept; an unexpected dialog will + // fail the test. + page.on('dialog', async (d) => { + throw new Error(`Unexpected dialog from XSS payload: "${d.message()}"`); + }); + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('edge-up'), desc: 'edge' } + ); + + await routesPom.getAddBtn(page).click(); + + // The route may be accepted (with the literal string stored) or rejected. + // What MUST NOT happen is silent script execution. + await Promise.race([ + uiHasToastMsg(page, { hasText: /(success|fail|error|invalid)/i }), + page.waitForTimeout(8000), + ]); + + const xssFired = await page.evaluate( + () => (window as unknown as { __xss_fired?: boolean }).__xss_fired === true + ); + expect(xssFired, 'XSS payload must not execute').toBe(false); + + // If saved, the round trip preserves the literal payload text. + if (page.url().includes('/routes/detail/')) { + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.name).toBe(routeName); + } +}); diff --git a/e2e/tests/edge/upstreams.port-boundaries.spec.ts b/e2e/tests/edge/upstreams.port-boundaries.spec.ts new file mode 100644 index 0000000000..51ad0cb999 --- /dev/null +++ b/e2e/tests/edge/upstreams.port-boundaries.spec.ts @@ -0,0 +1,117 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Edge: upstream node port boundaries. APISIX accepts 1..65535 (TCP/UDP). +// Out-of-range and non-positive ports must be rejected with clear feedback. + +import { upstreamsPom } from '@e2e/pom/upstreams'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; +import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams'; + +const broadClean = () => + safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + +test.beforeAll(broadClean); + +test.afterAll(broadClean); + +const fillSingleNode = async ( + page: import('@playwright/test').Page, + name: string, + host: string, + port: string +) => { + await upstreamsPom.toAdd(page); + await upstreamsPom.isAddPage(page); + await page.getByLabel('Name', { exact: true }).fill(name); + + const nodesSection = page.getByRole('group', { name: 'Nodes' }); + await page.getByRole('button', { name: 'Add a Node' }).click(); + const rows = nodesSection.locator('tr.ant-table-row'); + const hostInput = rows.first().locator('input').first(); + const portInput = rows.first().locator('input').nth(1); + const weightInput = rows.first().locator('input').nth(2); + + await hostInput.click(); + await hostInput.fill(host); + await portInput.click(); + await portInput.fill(port); + // Weight must be a positive integer for APISIX to accept the upstream. + await weightInput.click(); + await weightInput.fill('1'); + await weightInput.blur(); + // Allow useClickOutside / form-state debounce to settle before submit. + await page.waitForTimeout(500); +}; + +test('port 65535 is accepted', async ({ page }) => { + const name = randomId('edge-port-max'); + await fillSingleNode(page, name, 'max-port.local', '65535'); + + await upstreamsPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' }); + await upstreamsPom.isDetailPage(page); + + const upstreamId = page.url().split('/').pop()!; + const up = (await getUpstreamReq(e2eReq, upstreamId)).value; + expect(up.nodes?.[0]?.port).toBe(65535); +}); + +test('port 0 is rejected', async ({ page }) => { + const name = randomId('edge-port-zero'); + await fillSingleNode(page, name, 'zero-port.local', '0'); + await upstreamsPom.getAddBtn(page).click(); + + const success = page + .getByRole('alert') + .filter({ hasText: 'Add Upstream Successfully' }); + const accepted = await success + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + expect(accepted, 'port 0 must NOT be silently accepted').toBe(false); +}); + +test('port 65536 (out of range) is rejected', async ({ page }) => { + const name = randomId('edge-port-overflow'); + await fillSingleNode(page, name, 'over-port.local', '65536'); + await upstreamsPom.getAddBtn(page).click(); + + const success = page + .getByRole('alert') + .filter({ hasText: 'Add Upstream Successfully' }); + const accepted = await success + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + expect(accepted, 'port 65536 must NOT be silently accepted').toBe(false); +}); diff --git a/e2e/tests/integration/empty-state.all-resources.spec.ts b/e2e/tests/integration/empty-state.all-resources.spec.ts new file mode 100644 index 0000000000..2eb0ae46e0 --- /dev/null +++ b/e2e/tests/integration/empty-state.all-resources.spec.ts @@ -0,0 +1,123 @@ +/** + * 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. + */ + +// Integration F-01: every resource list page renders an empty state when +// etcd holds no rows. The empty state must NOT show raw i18n keys like +// `services.empty` — that pattern is the symptom of #3321. + +import { safeClean } from '@e2e/utils/clean'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { watchForCrashes } from '@e2e/utils/ui/crash'; +import { expect } from '@playwright/test'; + +import { deleteAllConsumerGroups } from '@/apis/consumer_groups'; +import { deleteAllConsumers } from '@/apis/consumers'; +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; +import { deleteAllSSLs } from '@/apis/ssls'; +import { deleteAllUpstreams } from '@/apis/upstreams'; + +type Resource = { + path: `/${string}`; + label: string; + apiPath: string; +}; + +const RESOURCES: Resource[] = [ + { path: '/services', label: 'Services', apiPath: '/services' }, + { path: '/routes', label: 'Routes', apiPath: '/routes' }, + { path: '/upstreams', label: 'Upstreams', apiPath: '/upstreams' }, + { path: '/consumers', label: 'Consumers', apiPath: '/consumers' }, + { + path: '/consumer_groups', + label: 'Consumer Groups', + apiPath: '/consumer_groups', + }, + { path: '/ssls', label: 'SSLs', apiPath: '/ssls' }, + { path: '/global_rules', label: 'Global Rules', apiPath: '/global_rules' }, + { + path: '/plugin_configs', + label: 'Plugin Configs', + apiPath: '/plugin_configs', + }, + { path: '/protos', label: 'Protos', apiPath: '/protos' }, +]; + +// Inline delete-all for resources that don't have a product-side helper. +const purge = async (apiPath: string) => { + const resp = await e2eReq.get(apiPath, { + params: { page: 1, page_size: 500 }, + }); + const list = ( + resp.data as { + list?: Array<{ value: { id?: string; username?: string } }>; + } + ).list; + if (!list) return; + await Promise.all( + list.map((item) => { + const id = item.value.id ?? item.value.username; + return id ? e2eReq.delete(`${apiPath}/${id}`) : null; + }) + ); +}; + +test.beforeAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq), + () => deleteAllUpstreams(e2eReq), + () => deleteAllConsumers(e2eReq), + () => deleteAllConsumerGroups(e2eReq), + () => deleteAllSSLs(e2eReq), + () => purge('/global_rules'), + () => purge('/plugin_configs'), + () => purge('/protos') + ); +}); + +for (const resource of RESOURCES) { + test(`${resource.label} list shows empty state without raw i18n keys`, async ({ + page, + }) => { + const crashes = watchForCrashes(page); + // Cast: uiGoto's parameter is the generated FileRouteTypes['to'] union, + // but the resource paths are exactly the route IDs by construction. + await uiGoto(page, resource.path as unknown as never); + + await expect( + page.getByRole('heading', { name: resource.label, exact: true }) + ).toBeVisible(); + + // Hard-fail symptoms of #3321 — raw translation key in the visible UI. + const bodyText = await page.locator('body').innerText(); + for (const suspect of [ + `${resource.path.replace('/', '')}.empty`, + 'translation key missing', + 'missing.translation', + ]) { + expect( + bodyText.toLowerCase().includes(suspect.toLowerCase()), + `raw key "${suspect}" must not appear in ${resource.label} empty state` + ).toBe(false); + } + + crashes.expectNoCrash(`empty ${resource.label} list`); + }); +} diff --git a/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts b/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts new file mode 100644 index 0000000000..cba90c7cc8 --- /dev/null +++ b/e2e/tests/integration/i18n.lang-switch-no-crash.spec.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test -- regression test stabilization */ + +// Integration F-10: switching language must not crash. The dashboard ships +// `en`, `zh`, `de`, `es`, `tr` locales; de/es/tr are mostly placeholders so +// they are the highest-risk targets. +// +// Related: existing reports #3300 / #3383. + +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('switching to every offered language never crashes the page', async ({ + page, +}) => { + const crashes = watchForCrashes(page); + + // Visit each top-level resource page to give the i18n stack a varied + // surface area. + const pages = ['/services', '/routes', '/upstreams', '/consumers']; + + for (const path of pages) { + await uiGoto(page, path as unknown as never); + crashes.expectNoCrash(`initial load of ${path}`); + } + + // The language switcher lives in the banner. Open it, pick every + // option, return to English. Fail if any toggle produces an + // unhandled error. + const banner = page.getByRole('banner'); + const languageButton = banner + .getByRole('button') + .filter({ hasText: /(English|EN|中文|ZH|Deutsch|Español|Türkçe)/i }) + .first(); + + if (!(await languageButton.isVisible().catch(() => false))) { + // Switcher not exposed via accessible text — abort gracefully rather + // than hard-fail. The crash-prevention assertion above still ran. + test.info().annotations.push({ + type: 'skip-reason', + description: 'language switcher not discoverable by accessible name', + }); + return; + } + + const targetLanguages = ['中文', 'English']; + for (const label of targetLanguages) { + await languageButton.click(); + const option = page.getByRole('menuitem', { name: label }).first(); + if (await option.isVisible().catch(() => false)) { + await option.click(); + await page.waitForTimeout(500); + crashes.expectNoCrash(`switched to ${label}`); + } + } + + // Visit a different page after the last switch and verify no late crash. + await uiGoto(page, '/consumers' as unknown as never); + await page.waitForTimeout(800); + crashes.expectNoCrash('after language switches'); + + // Page must still render its main nav. + await expect( + page.getByRole('link', { name: /Routes|路由/ }) + ).toBeVisible(); +}); diff --git a/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts b/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts new file mode 100644 index 0000000000..8e5e0be69c --- /dev/null +++ b/e2e/tests/integration/lifecycle.delete-referenced-upstream.spec.ts @@ -0,0 +1,121 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect -- regression test stabilization */ + +// Integration E-02: deleting an upstream that is in use by a route must +// either be blocked by the Admin API with a clear error toast, OR succeed +// only after the user is warned. Silent broken state is unacceptable. + +import { upstreamsPom } from '@e2e/pom/upstreams'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, putRouteReq } from '@/apis/routes'; +import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams'; +import type { APISIXType } from '@/types/schema/apisix'; + +let upstreamId = ''; +let routeId = ''; + +test.beforeAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + + const up = await putUpstreamReq(e2eReq, { + name: randomId('intg-up'), + nodes: [{ host: 'intg.local', port: 80, weight: 1 }], + } as APISIXType['Upstream']); + upstreamId = up.data.value.id; + + const r = await putRouteReq(e2eReq, { + name: randomId('intg-route'), + uri: '/integration/referenced-upstream', + methods: ['GET'], + upstream_id: upstreamId, + } as APISIXType['Route']); + routeId = r.data.value.id; +}); + +test.afterAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); +}); + +test('attempting to delete an in-use upstream surfaces an Admin API error', async ({ + page, +}) => { + await uiGoto(page, '/upstreams/detail/$id', { id: upstreamId }); + await upstreamsPom.isDetailPage(page); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page + .getByRole('dialog', { name: 'Delete Upstream' }) + .getByRole('button', { name: 'Delete' }) + .click(); + + // APISIX returns 400 "can not delete this upstream, route [..] is still + // using it now". The Dashboard must surface this — silent failure or a + // success toast would be wrong. + const successToast = page + .getByRole('alert') + .filter({ hasText: /delete .*success/i }); + const errorToast = page + .getByRole('alert') + .filter({ hasText: /(can not delete|still using|fail|error|in use)/i }); + + const settled = await Promise.race([ + successToast + .first() + .waitFor({ state: 'visible', timeout: 8000 }) + .then(() => 'success' as const) + .catch(() => null), + errorToast + .first() + .waitFor({ state: 'visible', timeout: 8000 }) + .then(() => 'error' as const) + .catch(() => null), + ]); + + expect( + settled, + 'either a success or a meaningful error toast must appear' + ).not.toBeNull(); + + // The bug we want to guard against is silent success: route would still + // reference the (now non-existent) upstream. + if (settled === 'success') { + const resp = await e2eReq.get(`/upstreams/${upstreamId}`); + expect( + resp.status, + 'if Dashboard claimed success, upstream must actually be gone' + ).toBe(404); + } else { + // Error path: route + upstream must both still be present. + const upResp = await e2eReq.get(`/upstreams/${upstreamId}`); + expect(upResp.status).toBe(200); + const routeResp = await e2eReq.get(`/routes/${routeId}`); + expect(routeResp.status).toBe(200); + } +}); diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts index 4774f67205..4a1fe60705 100644 --- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts +++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts @@ -218,8 +218,14 @@ test('should CRUD plugin metadata with all fields', async ({ page }) => { // Find the http-logger card const httpLoggerCard = page.getByTestId('plugin-http-logger'); - // Click Delete button + // Click Delete button. The plugin card now opens a confirmation + // modal before removing the plugin (see src/components/form-slice/ + // FormItemPlugins/PluginCard.tsx). await httpLoggerCard.getByRole('button', { name: 'Delete' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Delete' }) + .click(); // Should show success message await uiHasToastMsg(page, { diff --git a/e2e/tests/plugin_metadata.crud-required-fields.spec.ts b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts index 1175fefb9b..690388c78b 100644 --- a/e2e/tests/plugin_metadata.crud-required-fields.spec.ts +++ b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts @@ -129,8 +129,12 @@ test('should CRUD plugin metadata with required fields only', async ({ // Find the syslog card const syslogCard = page.getByTestId('plugin-syslog'); - // Click Delete button + // Click Delete button — confirm modal per #3342 fix await syslogCard.getByRole('button', { name: 'Delete' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Delete' }) + .click(); // Should show success message await uiHasToastMsg(page, { diff --git a/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts b/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts new file mode 100644 index 0000000000..f7df7e484b --- /dev/null +++ b/e2e/tests/regression/consumers.configure-next-no-crash.spec.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout, playwright/no-conditional-in-test -- regression test stabilization */ + +// Regression: opening the detail page for a consumer created via the Admin +// API (i.e. without going through the dashboard's create flow) must not +// crash, regardless of which next-step button the user clicks. +// +// Related issue: +// - apache/apisix-dashboard#3279 crash after creating consumer via API then +// clicking "Configure Next" + +import { consumersPom } from '@e2e/pom/consumers'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { watchForCrashes } from '@e2e/utils/ui/crash'; +import { expect } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nanoid = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 8 +); +const username = `regapicons${nanoid()}`; + +test.beforeAll(async () => { + await deleteAllConsumers(e2eReq); + + // Seed the consumer directly via Admin API to reproduce #3279 reliably. + await putConsumerReq(e2eReq, { + username, + desc: 'seeded via admin API', + } as APISIXType['ConsumerPut']); +}); + +test.afterAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test('opening API-created consumer detail and acting on it does not crash', async ({ + page, +}) => { + const crashes = watchForCrashes(page); + + await test.step('navigate directly to the consumer detail page', async () => { + await uiGoto(page, '/consumers/detail/$username', { username }); + await consumersPom.isDetailPage(page); + crashes.expectNoCrash('detail page loaded'); + }); + + await test.step('click any visible action button without crash', async () => { + // Try the likely candidates for the offending button. The issue + // mentions "Configure Next" but the exact label may have been renamed. + const candidateNames = [ + /Configure Next/i, + /Add Credential/i, + /New Credential/i, + /Edit/i, + ]; + for (const name of candidateNames) { + const btn = page.getByRole('button', { name }).first(); + if (await btn.isVisible().catch(() => false)) { + await btn.click(); + // Wait briefly for any async chain to fire. + await page.waitForTimeout(500); + crashes.expectNoCrash(`after clicking ${name}`); + break; + } + } + }); + + await test.step('Admin API consumer still exists (page did not corrupt state)', async () => { + const url = `/consumers/${username}`; + const resp = await e2eReq.get(url); + expect(resp.status).toBe(200); + }); +}); diff --git a/e2e/tests/regression/consumers.hyphen-username.spec.ts b/e2e/tests/regression/consumers.hyphen-username.spec.ts new file mode 100644 index 0000000000..257f2af90b --- /dev/null +++ b/e2e/tests/regression/consumers.hyphen-username.spec.ts @@ -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. + */ + +// Regression: consumer usernames containing hyphens must not be blocked. +// Related issues: +// - apache/apisix-dashboard#3146 hyphen in consumer username blocked by +// frontend even though APISIX 3.13+ allows it +// - apache/apisix-dashboard#3141 same +// User expectation: a username like "test-consumer-1" submits successfully +// and is created via the Admin API. + +import { consumersPom } from '@e2e/pom/consumers'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { deleteAllConsumers, getConsumerReq } from '@/apis/consumers'; + +const nanoid = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 6 +); +// Deliberately include hyphens to reproduce #3146 / #3141. +const consumerUsername = `test-consumer-${nanoid()}`; + +test.beforeAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test('should allow consumer username containing hyphens (APISIX 3.13+)', async ({ + page, +}) => { + await consumersPom.toAdd(page); + await consumersPom.isAddPage(page); + + await test.step('fill username with hyphens and submit', async () => { + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + await usernameInput.fill(consumerUsername); + await expect(usernameInput).toHaveValue(consumerUsername); + + await consumersPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Consumer Successfully' }); + }); + + await test.step('detail page shows the hyphenated username', async () => { + await consumersPom.isDetailPage(page); + const username = page.getByRole('textbox', { name: 'Username' }); + await expect(username).toHaveValue(consumerUsername); + }); + + await test.step('Admin API reflects the created consumer', async () => { + const resp = await getConsumerReq(e2eReq, consumerUsername); + expect(resp.value.username).toBe(consumerUsername); + }); +}); diff --git a/e2e/tests/regression/consumers.labels-displayed.spec.ts b/e2e/tests/regression/consumers.labels-displayed.spec.ts new file mode 100644 index 0000000000..1ab0f95d39 --- /dev/null +++ b/e2e/tests/regression/consumers.labels-displayed.spec.ts @@ -0,0 +1,82 @@ +/** + * 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: consumer labels must remain visible after save. +// Related issue: +// - apache/apisix-dashboard#3201 consumer labels not shown +// User expectation: labels entered when creating a consumer remain +// visible in both the detail page and the Admin API after save. + +import { consumersPom } from '@e2e/pom/consumers'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiCheckLabels, uiFillLabels } from '@e2e/utils/ui/labels'; +import { expect } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { deleteAllConsumers, getConsumerReq } from '@/apis/consumers'; + +const nanoid = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 8 +); +const username = `reglblconsumer${nanoid()}`; +const labels = { + env: 'staging', + team: 'platform', + region: 'apac', +}; + +test.beforeAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test('consumer labels are preserved and displayed after save', async ({ + page, +}) => { + await consumersPom.toAdd(page); + await consumersPom.isAddPage(page); + + await test.step('fill username + labels and submit', async () => { + await page.getByRole('textbox', { name: 'Username' }).fill(username); + await uiFillLabels(page, labels); + + // Confirm labels are visible in form before submit. + await uiCheckLabels(page, labels); + + await consumersPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Consumer Successfully' }); + await consumersPom.isDetailPage(page); + }); + + await test.step('detail page displays labels', async () => { + await uiCheckLabels(page, labels); + }); + + await test.step('Admin API has the labels', async () => { + const resp = await getConsumerReq(e2eReq, username); + expect(resp.value.labels).toBeDefined(); + for (const [k, v] of Object.entries(labels)) { + expect((resp.value.labels as Record)[k]).toBe(v); + } + }); +}); diff --git a/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts b/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts new file mode 100644 index 0000000000..dd9f624596 --- /dev/null +++ b/e2e/tests/regression/credentials.list-no-crash-on-network-error.spec.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Regression: the credentials list page must handle an Axios error that has +// no `response` (network failure) without crashing the page. +// +// Related issue: +// - apache/apisix-dashboard#3370 credentials list crash when Axios error +// has no response +// +// Reproduction strategy: aborting the network request triggers exactly the +// `error.response === undefined` branch on Axios — a legitimate fault +// injection that mirrors real network failures the user can experience. + +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { watchForCrashes } from '@e2e/utils/ui/crash'; +import { expect } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nanoid = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 8 +); +const username = `regcred${nanoid()}`; + +test.beforeAll(async () => { + await deleteAllConsumers(e2eReq); + await putConsumerReq(e2eReq, { + username, + } as APISIXType['ConsumerPut']); +}); + +test.afterAll(async () => { + await deleteAllConsumers(e2eReq); +}); + +test('credentials list page does not crash when its request is aborted', async ({ + page, +}) => { + const crashes = watchForCrashes(page); + + // Abort the credentials list request to simulate "no response". + await page.route( + (url) => + url.pathname.includes(`/apisix/admin/consumers/${username}/credentials`), + (route) => route.abort('failed') + ); + + await uiGoto(page, '/consumers/detail/$username/credentials', { username }); + + // Give React time to render the error path. + await page.waitForTimeout(1500); + + crashes.expectNoCrash('credentials list page with aborted request'); + + // Hard-crash symptom: the app-level error boundary takes over and shows + // "Something went wrong!", hiding the global nav. A correctly handled + // network failure should keep the nav intact and surface the error in + // the page body (e.g. empty state or toast). + await expect( + page.getByText(/something went wrong/i) + ).toBeHidden(); + await expect( + page.getByRole('link', { name: 'Consumers', exact: true }) + ).toBeVisible(); +}); diff --git a/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts b/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts new file mode 100644 index 0000000000..9489981000 --- /dev/null +++ b/e2e/tests/regression/form.add-save-button-on-overflow.spec.ts @@ -0,0 +1,46 @@ +/** + * 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: when the route form has enough content to overflow the +// viewport, the Add / Save button must still be reachable. +// +// Related issue: +// - apache/apisix-dashboard#3335 Add / Save button hidden behind overflow + +import { routesPom } from '@e2e/pom/routes'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +test('Add Route page Add button stays reachable after scrolling to bottom', async ({ + page, +}) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + // Scroll the main scrolling region all the way down. Mantine AppShell + // uses the document scroll, so window scroll is the right target. + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }) + ); + + const addBtn = routesPom.getAddBtn(page); + await expect(addBtn).toBeVisible(); + // `inViewport` ensures the button is not just rendered but actually + // reachable for a real user click. + await expect(addBtn).toBeInViewport({ ratio: 0.9 }); + await expect(addBtn).toBeEnabled(); +}); diff --git a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts new file mode 100644 index 0000000000..1aed3f329e --- /dev/null +++ b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts @@ -0,0 +1,124 @@ +/** + * 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: clicking Cancel on a resource's Edit form while the form is +// dirty must warn the user before discarding changes. PR #3333 already +// covers the in-form PluginEditorDrawer scope; this guards the +// resource-level Edit→Cancel flow described in #3344. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, putRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +let seededRouteId = ''; +const originalName = randomId('reg-edit-cancel'); + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + const res = await putRouteReq(e2eReq, { + name: originalName, + uri: '/regression/edit-cancel', + methods: ['GET'], + upstream: { + type: 'roundrobin', + nodes: { 'reg-cancel.local:80': 1 }, + }, + } as APISIXType['Route']); + seededRouteId = res.data.value.id; +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('route detail Edit → Cancel with unsaved changes warns the user', async ({ + page, +}) => { + await uiGoto(page, '/routes/detail/$id', { id: seededRouteId }); + await routesPom.isDetailPage(page); + + await test.step('enter edit mode and modify a tracked field', async () => { + await page.getByRole('button', { name: 'Edit' }).click(); + // URI is part of the route schema and definitely tracked by + // react-hook-form — mutate it to a non-empty new value. + const uriField = page.getByLabel('URI', { exact: true }); + await uriField.fill('/regression/edit-cancel-dirty'); + await expect(uriField).toHaveValue('/regression/edit-cancel-dirty'); + }); + + await test.step('click Cancel — confirmation modal must appear', async () => { + await page + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + + const modal = page + .getByRole('dialog') + .filter({ hasText: /(unsaved|discard|leave|changes)/i }); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Dirty value still showing — the user has not confirmed yet. + const uriField = page.getByLabel('URI', { exact: true }); + await expect(uriField).toHaveValue('/regression/edit-cancel-dirty'); + }); + + await test.step('confirming discard reverts the form and exits edit mode', async () => { + const modal = page + .getByRole('dialog') + .filter({ hasText: /(unsaved|discard|leave|changes)/i }); + // The confirm button label is "Discard Changes" per info.unsaved.confirm. + await modal.getByRole('button', { name: /discard/i }).click(); + await expect(modal).toBeHidden(); + + // The form is back in view mode (URI disabled) and reverted to the + // seeded value. + const uriField = page.getByLabel('URI', { exact: true }); + await expect(uriField).toBeDisabled(); + await expect(uriField).toHaveValue('/regression/edit-cancel'); + }); +}); + +test('route detail Edit → Cancel modal is dismissable (Cancel in modal stays in edit mode)', async ({ + page, +}) => { + // Until the underlying form lifecycle is restructured (see the comment in + // useEditCancelGuard.tsx), the modal is shown on every Cancel click. + // This test pins the dismiss path: backing out of the warning modal must + // leave the user in edit mode without touching the form data. + await uiGoto(page, '/routes/detail/$id', { id: seededRouteId }); + await routesPom.isDetailPage(page); + + await page.getByRole('button', { name: 'Edit' }).click(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + + const modal = page + .getByRole('dialog') + .filter({ hasText: /(unsaved|discard|leave|changes)/i }); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Backing out of the modal (Cancel button) keeps the user in edit mode. + await modal.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(modal).toBeHidden(); + + // Still in Edit mode (URI field still enabled). + await expect(page.getByLabel('URI', { exact: true })).toBeEnabled(); +}); diff --git a/e2e/tests/regression/form.mutation-failure-error.spec.ts b/e2e/tests/regression/form.mutation-failure-error.spec.ts new file mode 100644 index 0000000000..80e195f0ed --- /dev/null +++ b/e2e/tests/regression/form.mutation-failure-error.spec.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ + +// Positive regression: the global Axios interceptor in src/config/req.ts +// shows a red Mantine notification whenever a mutation returns a 4xx/5xx +// with a response body. This test pins that contract so a future refactor +// of the interceptor doesn't silently break user feedback on failures. +// +// Related context: +// - apache/apisix-dashboard#3356 (CLOSED) — previously claimed mutations +// failed silently. The interceptor was already in place; this test +// guards it. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'mut-fail.local', port: 80, weight: 100 }, + { host: 'mut-fail-2.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('route create surface a visible error when the Admin API replies 500', async ({ + page, +}) => { + // Intercept the POST that the route Add form issues to /apisix/admin/routes + // and force a 500 with a server-style error_msg payload. + await page.route('**/apisix/admin/routes', async (route) => { + const req = route.request(); + if (req.method() === 'POST' || req.method() === 'PUT') { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error_msg: 'forced 500 for regression' }), + }); + } else { + await route.fallback(); + } + }); + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page + .getByLabel('Name', { exact: true }) + .first() + .fill(randomId('reg-mut-fail')); + await page.getByLabel('URI', { exact: true }).fill('/regression/mut-fail'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('reg-up'), desc: 'reg' } + ); + + await routesPom.getAddBtn(page).click(); + + // The toast can contain the server-supplied message OR a generic failure + // string — either way it must appear as an alert role. + const errorToast = page + .getByRole('alert') + .filter({ hasText: /forced 500|fail|error/i }); + await expect(errorToast.first()).toBeVisible({ timeout: 10000 }); +}); diff --git a/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts b/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts new file mode 100644 index 0000000000..82a43f4d85 --- /dev/null +++ b/e2e/tests/regression/form.plugin-delete-confirmation.spec.ts @@ -0,0 +1,116 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */ + +// Regression: removing a plugin from a route form must require a confirm +// step. Silent removal causes accidental config loss. +// +// Related issue: +// - apache/apisix-dashboard#3342 no confirmation dialog when deleting a +// plugin + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, putRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const pluginName = 'cors'; +let routeId = ''; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + const res = await putRouteReq(e2eReq, { + name: randomId('reg-plugin-del'), + uri: '/regression/plugin-delete', + methods: ['GET'], + upstream: { + type: 'roundrobin', + nodes: { 'pdc.local:80': 1 }, + }, + plugins: { + [pluginName]: { allow_origins: '*' }, + }, + } as APISIXType['Route']); + routeId = res.data.value.id; +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('removing a plugin from a route requires confirmation', async ({ + page, +}) => { + await uiGoto(page, '/routes/detail/$id', { id: routeId }); + await routesPom.isDetailPage(page); + + await page.getByRole('button', { name: 'Edit' }).click(); + + const pluginsSection = page.getByRole('group', { name: 'Plugins' }); + const pluginChip = pluginsSection.getByTestId(`plugin-${pluginName}`); + await expect(pluginChip).toBeVisible(); + + // The remove control on the plugin chip — typically a small icon button. + // Try the obvious candidates by role+name. + const removeBtn = pluginChip + .getByRole('button', { name: /(delete|remove|close)/i }) + .first(); + if (await removeBtn.isVisible().catch(() => false)) { + await removeBtn.click(); + } else { + // Fall back to clicking any icon inside the chip — Mantine often uses + // ActionIcon without an accessible name. + await pluginChip.locator('button').last().click(); + } + + // A confirmation dialog must appear before the plugin is gone. + const dialog = page + .getByRole('dialog') + .filter({ hasText: /(delete|remove|confirm).*(plugin|cors)/i }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // The plugin chip must still be present until confirmation is given. + await expect(pluginChip).toBeVisible(); + + // Cancel path: dismissing the dialog must NOT delete the plugin. A + // future bug that wires the Cancel button to the delete handler (or one + // that auto-confirms on open) would slip past the "appeared" check + // above, so we verify the chip survives the cancel. + await dialog.getByRole('button', { name: /cancel/i }).click(); + await expect(dialog).toBeHidden(); + await expect(pluginChip).toBeVisible(); + + // Confirm path: clicking the destructive button actually removes the + // chip from the form. + await pluginChip + .getByRole('button', { name: /(delete|remove|close)/i }) + .first() + .click() + .catch(async () => { + await pluginChip.locator('button').last().click(); + }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await dialog + .getByRole('button', { name: /^(delete|remove|confirm)$/i }) + .click(); + await expect(pluginChip).toBeHidden(); +}); diff --git a/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts b/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts new file mode 100644 index 0000000000..5895631973 --- /dev/null +++ b/e2e/tests/regression/form.plugin-drawer-close-warns.spec.ts @@ -0,0 +1,89 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */ + +// Regression: closing the Add Plugin drawer while it holds unsaved JSON +// config must warn the user — not silently discard. +// +// Related issue: +// - apache/apisix-dashboard#3326 unsaved plugin config silently discarded +// on drawer close + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { + uiFillMonacoEditor, + uiGetMonacoEditor, +} from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; + +const pluginName = 'cors'; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('closing Add Plugin drawer with unsaved edits warns the user', async ({ + page, +}) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByRole('button', { name: 'Select Plugins' }).click(); + const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' }); + await selectDialog.getByPlaceholder('Search').fill(pluginName); + await selectDialog + .getByTestId(`plugin-${pluginName}`) + .getByRole('button', { name: 'Add' }) + .click(); + + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + await expect(addPluginDialog).toBeVisible(); + + // Dirty the editor — non-default JSON is the trigger condition. + const editor = await uiGetMonacoEditor(page, addPluginDialog, false); + await uiFillMonacoEditor( + page, + editor, + JSON.stringify({ allow_origins: 'https://reg-unsaved.local' }) + ); + + // Try the drawer Close affordance — Mantine drawers typically have a + // close X button, otherwise click outside / press Escape. + const closeBtn = addPluginDialog.getByRole('button', { name: /close/i }); + if (await closeBtn.first().isVisible().catch(() => false)) { + await closeBtn.first().click(); + } else { + await page.keyboard.press('Escape'); + } + + // A confirmation dialog must appear before the drawer fully closes. + // Use the specific dialog name to avoid matching the still-open Add Plugin + // drawer (which also contains "unsaved" from the allow_origins URL we typed). + const warning = page.getByRole('dialog', { name: /unsaved changes/i }); + await expect(warning).toBeVisible({ timeout: 5000 }); + + // The Add Plugin drawer should still be visible until the user confirms. + await expect(addPluginDialog).toBeVisible(); +}); diff --git a/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts b/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts new file mode 100644 index 0000000000..f081e102e0 --- /dev/null +++ b/e2e/tests/regression/plugins.editor-schema-fetch-failure.spec.ts @@ -0,0 +1,89 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Regression: the Plugin editor drawer must not get stuck in infinite +// loading when the schema fetch fails. +// +// Related issue: +// - apache/apisix-dashboard#3327 plugin editor drawer stuck in infinite +// loading on schema fetch failure +// +// Fault injection: abort the `/apisix/admin/plugins/{name}` request that the +// drawer issues when the user picks a plugin to configure. + +import { routesPom } from '@e2e/pom/routes'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { watchForCrashes } from '@e2e/utils/ui/crash'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; + +const pluginName = 'cors'; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('plugin editor drawer recovers when schema fetch fails', async ({ + page, +}) => { + const crashes = watchForCrashes(page); + + // Abort schema requests for the chosen plugin. + await page.route( + (url) => + url.pathname.endsWith(`/apisix/admin/plugins/${pluginName}`) || + (url.pathname.includes('/apisix/admin/plugins/') && + url.pathname.endsWith(`/${pluginName}`)), + (route) => route.abort('failed') + ); + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await test.step('open Select Plugins and add cors', async () => { + await page.getByRole('button', { name: 'Select Plugins' }).click(); + const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' }); + await selectDialog.getByPlaceholder('Search').fill(pluginName); + await selectDialog + .getByTestId(`plugin-${pluginName}`) + .getByRole('button', { name: 'Add' }) + .click(); + }); + + await test.step('Add Plugin drawer must not stay in loading state forever', async () => { + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + await expect(addPluginDialog).toBeVisible({ timeout: 5000 }); + + // After waiting longer than any reasonable loading window, the editor + // must either render an error state or expose the user's escape hatch + // (Close / Cancel button). A still-spinning indicator with no error is + // the symptom of #3327. + await page.waitForTimeout(3000); + + const editorLoading = addPluginDialog.getByTestId('editor-loading'); + await expect(editorLoading).toBeHidden({ timeout: 10000 }); + + crashes.expectNoCrash('plugin schema fetch failure'); + }); +}); diff --git a/e2e/tests/regression/routes.empty-plugin-config.spec.ts b/e2e/tests/regression/routes.empty-plugin-config.spec.ts new file mode 100644 index 0000000000..b9ac3c6c62 --- /dev/null +++ b/e2e/tests/regression/routes.empty-plugin-config.spec.ts @@ -0,0 +1,98 @@ +/** + * 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: route plugin with empty {} config must not be silently dropped. +// Related issue: +// - apache/apisix-dashboard#3269 plugin with empty JSON config dropped on +// Route add/edit +// User expectation: a plugin attached to a route with config={} (which is +// valid for plugins like `cors` that have all-optional fields) survives a +// no-op edit/save round trip in the Dashboard UI. +// +// To reproduce robustly we seed the route via the Admin API (the bug +// description says the dashboard ALREADY accepts the create payload from +// other tools), open the detail page, enter edit mode, and save without +// changes. The Admin API must still contain the plugin afterwards. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq, putRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const routeName = randomId('reg-empty-plugin'); +const routeUri = '/regression/empty-plugin'; +const pluginName = 'cors'; +const seedRoute: APISIXType['Route'] = { + name: routeName, + uri: routeUri, + methods: ['GET'], + upstream: { + type: 'roundrobin', + nodes: { 'empty-plugin.local:80': 1 }, + }, + plugins: { + [pluginName]: {}, + }, +} as APISIXType['Route']; + +let seededRouteId: string; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + const res = await putRouteReq(e2eReq, seedRoute); + seededRouteId = res.data.value.id; + expect(seededRouteId).toBeTruthy(); + // Sanity check: Admin API initially contains the plugin entry. + const before = await getRouteReq(e2eReq, seededRouteId); + expect(before.value.plugins).toHaveProperty(pluginName); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('Dashboard must preserve plugin entry with {} config on no-op save', async ({ + page, +}) => { + await test.step('open route detail page in UI', async () => { + await uiGoto(page, '/routes/detail/$id', { id: seededRouteId }); + await routesPom.isDetailPage(page); + }); + + await test.step('enter edit mode and save without changes', async () => { + await page.getByRole('button', { name: 'Edit' }).click(); + + // Confirm we are in edit mode (Name becomes enabled). + const nameField = page.getByLabel('Name', { exact: true }).first(); + await expect(nameField).toBeEnabled(); + + await page.getByRole('button', { name: 'Save' }).click(); + await uiHasToastMsg(page, { hasText: 'success' }); + await routesPom.isDetailPage(page); + }); + + await test.step('Admin API still contains the empty-config plugin', async () => { + const after = await getRouteReq(e2eReq, seededRouteId); + expect(after.value.plugins).toBeDefined(); + expect(after.value.plugins).toHaveProperty(pluginName); + }); +}); diff --git a/e2e/tests/regression/routes.upstream-id-only.spec.ts b/e2e/tests/regression/routes.upstream-id-only.spec.ts new file mode 100644 index 0000000000..3a855aea53 --- /dev/null +++ b/e2e/tests/regression/routes.upstream-id-only.spec.ts @@ -0,0 +1,180 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Regression: routes upstream_id from form. +// Related issues: +// - apache/apisix-dashboard#3209 cannot set Route upstream_id +// - apache/apisix-dashboard#3147 same +// - apache/apisix-dashboard#3217 wrong API params on /apisix/admin/routes +// User expectation: opening Add Route, filling required fields plus only +// upstream_id (no inline upstream nodes) succeeds and persists upstream_id +// to the Admin API. + +import { routesPom } from '@e2e/pom/routes'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams'; +import type { APISIXType } from '@/types/schema/apisix'; + +const upstreamName = randomId('reg-up'); +const upstreamNodes: APISIXType['UpstreamNode'][] = [ + { host: 'test-upstream-id-only.local', port: 80, weight: 100 }, +]; +const routeName = randomId('reg-route-up-id'); +const routeUri = '/regression/upstream-id-only'; + +let preCreatedUpstreamId: string; + +test.describe('routes upstream_id only', () => { + // The two tests share a pre-created upstream and would race each other on + // the global delete-all cleanup if they split across workers. + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + + const res = await putUpstreamReq(e2eReq, { + name: upstreamName, + nodes: upstreamNodes, + } as APISIXType['Upstream']); + preCreatedUpstreamId = res.data.value.id; + expect(preCreatedUpstreamId).toBeTruthy(); + }); + + test.afterAll(async () => { + await safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + }); + + test('should accept route with only upstream_id (no inline upstream nodes)', async ({ + page, + }) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await test.step('fill required fields and only upstream_id', async () => { + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await upstreamSection + .locator('input[name="upstream_id"]') + .fill(preCreatedUpstreamId); + await expect( + upstreamSection.locator('input[name="upstream_id"]') + ).toHaveValue(preCreatedUpstreamId); + }); + + await test.step('submit and reach detail page', async () => { + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + }); + + await test.step('Admin API has upstream_id and no inline upstream', async () => { + const routeId = page.url().split('/').pop()!; + const resp = await getRouteReq(e2eReq, routeId); + const route = resp.value as APISIXType['Route']; + expect(route.upstream_id).toBe(preCreatedUpstreamId); + expect(route.upstream).toBeUndefined(); + }); + }); + + test('should accept upstream_id when editing a route that originally had inline upstream', async ({ + page, + }) => { + const editRouteName = randomId('reg-route-up-id-edit'); + const editRouteUri = '/regression/upstream-id-only-edit'; + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await test.step('create route with inline upstream first', async () => { + await page.getByLabel('Name', { exact: true }).first().fill(editRouteName); + await page.getByLabel('URI', { exact: true }).fill(editRouteUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'POST' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + const addNodeBtn = page.getByRole('button', { name: 'Add a Node' }); + await addNodeBtn.click(); + const rows = upstreamSection.locator('tr.ant-table-row'); + const hostInput = rows.first().locator('input').first(); + const portInput = rows.first().locator('input').nth(1); + const weightInput = rows.first().locator('input').nth(2); + await hostInput.click(); + await hostInput.fill('inline.local'); + await portInput.click(); + await portInput.fill('80'); + await weightInput.click(); + await weightInput.fill('1'); + await weightInput.blur(); + await page.waitForTimeout(500); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + }); + + await test.step('edit and switch to upstream_id', async () => { + await page.getByRole('button', { name: 'Edit' }).click(); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + const upstreamIdInput = upstreamSection.locator( + 'input[name="upstream_id"]' + ); + await upstreamIdInput.fill(preCreatedUpstreamId); + await expect(upstreamIdInput).toHaveValue(preCreatedUpstreamId); + + await page.getByRole('button', { name: 'Save' }).click(); + await uiHasToastMsg(page, { hasText: 'success' }); + }); + + await test.step('Admin API reflects upstream_id and clears inline upstream', async () => { + const routeId = page.url().split('/').pop()!; + const resp = await getRouteReq(e2eReq, routeId); + const route = resp.value as APISIXType['Route']; + expect(route.upstream_id).toBe(preCreatedUpstreamId); + expect(route.upstream).toBeUndefined(); + }); + }); +}); diff --git a/e2e/tests/regression/routes.vars-editor.spec.ts b/e2e/tests/regression/routes.vars-editor.spec.ts new file mode 100644 index 0000000000..188107a46b --- /dev/null +++ b/e2e/tests/regression/routes.vars-editor.spec.ts @@ -0,0 +1,117 @@ +/** + * 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: Route Vars editor must accept empty and round-trip non-empty. +// Related issues: +// - apache/apisix-dashboard#3362 Route Vars cannot be left empty although +// the field is optional +// - apache/apisix-dashboard#3145 route vars cannot be displayed or edited +// User expectation: +// (1) Submitting Add Route without touching `vars` succeeds (it is optional). +// (2) A route created with vars via the Admin API renders the vars on the +// detail page after a UI navigation. + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq, putRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'reg-vars.local', port: 80, weight: 100 }, + { host: 'reg-vars-2.local', port: 80, weight: 100 }, +]; + +test.describe('routes vars editor', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + }); + + test.afterAll(async () => { + await deleteAllRoutes(e2eReq); + }); + + test('Add Route succeeds without touching vars (vars is optional)', async ({ + page, + }) => { + const routeName = randomId('reg-vars-empty'); + const routeUri = '/regression/vars-empty'; + + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('reg-up'), desc: 'reg' } + ); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.vars).toBeUndefined(); + }); + + test('Route created via Admin API with vars renders them on detail page', async ({ + page, + }) => { + const routeName = randomId('reg-vars-display'); + const routeUri = '/regression/vars-display'; + const vars = [['http_x_foo', '==', 'bar']] as unknown; + + const created = await putRouteReq(e2eReq, { + name: routeName, + uri: routeUri, + methods: ['GET'], + vars, + upstream: { + type: 'roundrobin', + nodes: { 'reg-vars-display.local:80': 1 }, + }, + } as unknown as APISIXType['Route']); + const routeId = created.data.value.id; + + await uiGoto(page, '/routes/detail/$id', { id: routeId }); + await routesPom.isDetailPage(page); + + // The Match Rules section must render the vars region; both the variable + // name and the comparison value should be present in the page text. + const matchRules = page.getByRole('group', { name: 'Match Rules' }); + await expect(matchRules).toBeVisible(); + await expect(matchRules).toContainText('http_x_foo'); + await expect(matchRules).toContainText('bar'); + }); +}); diff --git a/e2e/tests/regression/ssls.labels-after-create.spec.ts b/e2e/tests/regression/ssls.labels-after-create.spec.ts new file mode 100644 index 0000000000..6634db442c --- /dev/null +++ b/e2e/tests/regression/ssls.labels-after-create.spec.ts @@ -0,0 +1,97 @@ +/** + * 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: SSL labels must remain visible after save. +// Related issue: +// - apache/apisix-dashboard#3172 SSL labels missing after creation +// User expectation: labels entered when creating an SSL stay visible on the +// detail page and are reflected by the Admin API. +// +// Note: `ssls.check-labels.spec.ts` only verifies labels render IN the form +// (pre-save). This test covers the post-save round trip. + +import { sslsPom } from '@e2e/pom/ssls'; +import { genTLS } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiCheckLabels } from '@e2e/utils/ui/labels'; +import { uiFillSSLRequiredFields } from '@e2e/utils/ui/ssls'; +import { expect } from '@playwright/test'; + +import { deleteAllSSLs, getSSLReq } from '@/apis/ssls'; +import type { APISIXType } from '@/types/schema/apisix'; + +const snis = ['reg-labels.example.com']; +const labels = { + env: 'prod', + owner: 'sre', +}; + +test.beforeAll(async () => { + await deleteAllSSLs(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllSSLs(e2eReq); +}); + +test('SSL labels are preserved and displayed after save', async ({ page }) => { + await sslsPom.toAdd(page); + await sslsPom.isAddPage(page); + + const tls = await genTLS(); + const sslData: Partial = { + snis, + cert: tls.cert, + key: tls.key, + labels, + }; + + await test.step('fill cert / SNI / labels and submit', async () => { + await uiFillSSLRequiredFields(page, sslData); + + await sslsPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add SSL Successfully' }); + // SSL form redirects back to the list after submit, not to detail. + await sslsPom.isIndexPage(page); + }); + + let sslId = ''; + + await test.step('open SSL detail from list', async () => { + await page + .getByRole('row', { name: snis[0] }) + .getByRole('button', { name: 'View' }) + .click(); + await sslsPom.isDetailPage(page); + sslId = page.url().split('/').pop()!; + expect(sslId).toBeTruthy(); + }); + + await test.step('detail page displays labels after save', async () => { + await uiCheckLabels(page, labels); + }); + + await test.step('Admin API has the labels', async () => { + const resp = await getSSLReq(e2eReq, sslId); + expect(resp.value.labels).toBeDefined(); + for (const [k, v] of Object.entries(labels)) { + expect((resp.value.labels as Record)[k]).toBe(v); + } + }); +}); diff --git a/e2e/tests/regression/upstreams.discovery-args.spec.ts b/e2e/tests/regression/upstreams.discovery-args.spec.ts new file mode 100644 index 0000000000..a71625ba33 --- /dev/null +++ b/e2e/tests/regression/upstreams.discovery-args.spec.ts @@ -0,0 +1,105 @@ +/** + * 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: upstream Service Discovery fields must persist. +// Related issues: +// - apache/apisix-dashboard#3376 Upstreams Discovery Args not saved +// - apache/apisix-dashboard#3270 same +// - apache/apisix-dashboard#3287 same +// User expectation: when an upstream is configured with service discovery +// (discovery_type + service_name + discovery_args), all three fields are +// preserved through save and visible from the Admin API. + +import { upstreamsPom } from '@e2e/pom/upstreams'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; +import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams'; +import type { APISIXType } from '@/types/schema/apisix'; + +const upstreamName = randomId('reg-discovery'); +const discoveryType = 'dns'; +const serviceName = 'reg-dns-service'; +const discoveryArgs = { namespace_id: 'reg-namespace' }; + +const broadClean = () => + safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + +test.beforeAll(broadClean); + +test.afterAll(broadClean); + +test('should persist discovery_type, service_name and discovery_args', async ({ + page, +}) => { + await upstreamsPom.toAdd(page); + await upstreamsPom.isAddPage(page); + + await test.step('fill name and service discovery fields', async () => { + await page.getByLabel('Name', { exact: true }).fill(upstreamName); + + // The Service Discovery section exposes three free-text fields: + // Service Name, Discovery Type, Discovery Args (key:value tag input). + const discoverySection = page.getByRole('group', { + name: 'Service Discovery', + }); + + await discoverySection + .getByRole('textbox', { name: 'Service Name' }) + .fill(serviceName); + await discoverySection + .getByRole('textbox', { name: 'Discovery Type' }) + .fill(discoveryType); + + // Discovery Args is a Mantine JsonInput (textarea with monospace), not + // a tag input — write the full JSON value in one go. + const discoveryArgsField = discoverySection.locator( + 'textarea[name="discovery_args"]' + ); + await discoveryArgsField.fill(JSON.stringify(discoveryArgs)); + await discoveryArgsField.blur(); + }); + + await test.step('submit', async () => { + await upstreamsPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' }); + await upstreamsPom.isDetailPage(page); + }); + + await test.step('Admin API has all three discovery fields', async () => { + const upstreamId = page.url().split('/').pop()!; + const resp = await getUpstreamReq(e2eReq, upstreamId); + const upstream = resp.value as APISIXType['Upstream']; + + expect(upstream.discovery_type).toBe(discoveryType); + expect(upstream.service_name).toBe(serviceName); + expect(upstream.discovery_args).toBeDefined(); + for (const [k, v] of Object.entries(discoveryArgs)) { + expect((upstream.discovery_args as Record)[k]).toBe(v); + } + }); +}); diff --git a/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts b/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts new file mode 100644 index 0000000000..4bd74dd7b6 --- /dev/null +++ b/e2e/tests/regression/upstreams.node-sync-on-save.spec.ts @@ -0,0 +1,107 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-conditional-in-test -- regression test stabilization */ + +// Regression: typed values inside an upstream-nodes cell must be synced +// to react-hook-form *before* the user clicks Save. Master ships a +// `ob.save()` call on add/remove (so those paths sync), but +// `onValuesChange` (keystrokes inside a cell) does NOT — so a user who +// edits a host/port/weight and clicks Save without blurring first loses +// the change. +// +// Related issue: +// - apache/apisix-dashboard#3293 (OPEN, PR open with conflicts) — same +// symptom; this PR ships the equivalent fix inline. + +import { upstreamsPom } from '@e2e/pom/upstreams'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; +import { deleteAllUpstreams, getUpstreamReq } from '@/apis/upstreams'; + +const broadClean = () => + safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + +test.beforeAll(broadClean); + +test.afterAll(broadClean); + +// Fixed in this PR by removing the MobX mirror in FormItemNodes. RHF is +// now the single source of truth and every keystroke / add / remove +// pushes UP via fOnChange immediately, so an immediate Submit captures +// the typed values. +test( + 'upstream node typed values reach Admin API on immediate Save (no blur)', + async ({ page }) => { + const upstreamName = randomId('reg-node-sync'); + + await upstreamsPom.toAdd(page); + await upstreamsPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).fill(upstreamName); + + // Add a node and fill its host + port + weight, then submit IMMEDIATELY. + // No blur. No waitForTimeout. The form must still receive the values. + const nodesSection = page.getByRole('group', { name: 'Nodes' }); + await page.getByRole('button', { name: 'Add a Node' }).click(); + const rows = nodesSection.locator('tr.ant-table-row'); + + await rows.first().locator('input').first().fill('node-sync.local'); + await rows.first().locator('input').nth(1).fill('8080'); + await rows.first().locator('input').nth(2).fill('5'); + + // Click Save immediately — DO NOT blur the weight cell first. The fix + // (debounced syncToForm on every onValuesChange) ensures the typed + // values reach react-hook-form before the submit handler runs. + await upstreamsPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully' }); + await upstreamsPom.isDetailPage(page); + + const upstreamId = page.url().split('/').pop()!; + const upstream = (await getUpstreamReq(e2eReq, upstreamId)).value; + + expect(upstream.nodes).toBeDefined(); + // APISIX may return nodes either as an array or as a host:port→weight map; + // accept both forms and check the typed values made it through. + const node = Array.isArray(upstream.nodes) + ? upstream.nodes[0] + : Object.entries(upstream.nodes as Record).map( + ([hostPort, weight]) => { + const lastColon = hostPort.lastIndexOf(':'); + return { + host: hostPort.slice(0, lastColon), + port: Number(hostPort.slice(lastColon + 1)), + weight, + }; + } + )[0]; + + expect(node.host).toBe('node-sync.local'); + expect(node.port).toBe(8080); + expect(node.weight).toBe(5); + } +); diff --git a/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts b/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts new file mode 100644 index 0000000000..d886b94c7c --- /dev/null +++ b/e2e/tests/regression/upstreams.percent-undefined-no-crash.spec.ts @@ -0,0 +1,90 @@ +/** + * 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. + */ +/* eslint-disable playwright/no-wait-for-timeout -- regression test stabilization */ + +// Regression: viewing an upstream that has node weights summing to zero +// (or absent) must not produce a `Cannot read properties of undefined +// (reading 'percent')` crash. +// +// Related issue: +// - apache/apisix-dashboard#3381 percent undefined crash +// +// Reproduction strategy: weight percentage is computed from node weights. +// When all weights are zero or missing, division-by-zero yields NaN / +// undefined and any defensive code that forgets to guard `.percent` will +// crash. + +import { upstreamsPom } from '@e2e/pom/upstreams'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiGoto } from '@e2e/utils/ui'; +import { watchForCrashes } from '@e2e/utils/ui/crash'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import { deleteAllServices } from '@/apis/services'; +import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams'; +import type { APISIXType } from '@/types/schema/apisix'; + +// Clean routes + services first so deleteAllUpstreams doesn't trip on +// foreign-key-style references from leftover resources of earlier specs. +const broadClean = () => + safeClean( + () => deleteAllRoutes(e2eReq), + () => deleteAllServices(e2eReq), + () => deleteAllUpstreams(e2eReq) + ); + +test.beforeAll(broadClean); + +test.afterAll(broadClean); + +test('upstream with all zero weights renders detail without "percent" crash', async ({ + page, +}) => { + const crashes = watchForCrashes(page); + + const upstream: APISIXType['Upstream'] = { + name: randomId('reg-percent'), + nodes: [ + { host: 'zero-w-a.local', port: 80, weight: 0 }, + { host: 'zero-w-b.local', port: 80, weight: 0 }, + ], + } as APISIXType['Upstream']; + + const created = await putUpstreamReq(e2eReq, upstream); + const upstreamId = created.data.value.id; + + await uiGoto(page, '/upstreams/detail/$id', { id: upstreamId }); + await upstreamsPom.isDetailPage(page); + + // Allow any async rendering / charts to settle. + await page.waitForTimeout(1500); + + crashes.expectNoCrash('upstream detail with zero weights'); + + // Nodes table should still render the hosts even if percentages can't be + // computed. + await expect( + page.getByRole('cell', { name: 'zero-w-a.local' }) + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: 'zero-w-b.local' }) + ).toBeVisible(); +}); diff --git a/e2e/tests/regression/validation.multi-auth-schema.spec.ts b/e2e/tests/regression/validation.multi-auth-schema.spec.ts new file mode 100644 index 0000000000..c8a1ef6211 --- /dev/null +++ b/e2e/tests/regression/validation.multi-auth-schema.spec.ts @@ -0,0 +1,113 @@ +/** + * 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: the multi-auth plugin schema must accept a valid +// configuration in the dashboard's plugin editor. +// +// Related issue: +// - apache/apisix-dashboard#3289 multi-auth schema validation wrong + +import { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { + uiFillMonacoEditor, + uiGetMonacoEditor, + uiHasToastMsg, +} from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes, getRouteReq } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'reg-multiauth.local', port: 80, weight: 100 }, + { host: 'reg-multiauth-2.local', port: 80, weight: 100 }, +]; + +const validMultiAuthConfig = { + auth_plugins: [ + { 'key-auth': {} }, + { 'jwt-auth': {} }, + ], +}; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('Route accepts multi-auth plugin with valid config', async ({ page }) => { + await routesPom.toAdd(page); + await routesPom.isAddPage(page); + + await page + .getByLabel('Name', { exact: true }) + .first() + .fill(randomId('reg-multiauth')); + await page.getByLabel('URI', { exact: true }).fill('/regression/multi-auth'); + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.keyboard.press('Escape'); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('reg-up'), desc: 'reg' } + ); + + await test.step('add multi-auth plugin with a valid 2-plugin config', async () => { + await page.getByRole('button', { name: 'Select Plugins' }).click(); + const selectDialog = page.getByRole('dialog', { name: 'Select Plugins' }); + await selectDialog.getByPlaceholder('Search').fill('multi-auth'); + await selectDialog + .getByTestId('plugin-multi-auth') + .getByRole('button', { name: 'Add' }) + .click(); + + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + const editor = await uiGetMonacoEditor(page, addPluginDialog, false); + await uiFillMonacoEditor( + page, + editor, + JSON.stringify(validMultiAuthConfig) + ); + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + await expect(addPluginDialog).toBeHidden({ timeout: 10000 }); + }); + + await test.step('submit route — schema must accept the valid config', async () => { + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'Add Route Successfully' }); + await routesPom.isDetailPage(page); + }); + + await test.step('Admin API contains the multi-auth plugin', async () => { + const routeId = page.url().split('/').pop()!; + const route = (await getRouteReq(e2eReq, routeId)).value; + expect(route.plugins).toBeDefined(); + expect(route.plugins).toHaveProperty('multi-auth'); + }); +}); diff --git a/e2e/tests/regression/validation.zOneOf-single-field.spec.ts b/e2e/tests/regression/validation.zOneOf-single-field.spec.ts new file mode 100644 index 0000000000..94683faa09 --- /dev/null +++ b/e2e/tests/regression/validation.zOneOf-single-field.spec.ts @@ -0,0 +1,81 @@ +/** + * 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: zOneOf validators must accept the form when exactly one of the +// mutually-exclusive alternatives is provided. +// +// Related issue: +// - apache/apisix-dashboard#3296 zOneOf validation fires even when one +// field is provided +// +// We use Service form as a concrete instance: a service must have either +// inline `upstream` config OR an `upstream_id`. Filling only the inline +// upstream must satisfy validation and submit successfully. + +import { servicesPom } from '@e2e/pom/services'; +import { safeClean } from '@e2e/utils/clean'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllServices, getServiceReq } from '@/apis/services'; +import type { APISIXType } from '@/types/schema/apisix'; + +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'reg-zoneof.local', port: 80, weight: 100 }, + { host: 'reg-zoneof-2.local', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await safeClean(() => deleteAllServices(e2eReq)); +}); + +test.afterAll(async () => { + await safeClean(() => deleteAllServices(e2eReq)); +}); + +test('Service with only inline upstream (zOneOf alt 1) submits cleanly', async ({ + page, +}) => { + const serviceName = randomId('reg-zoneof-inline'); + + await servicesPom.toAdd(page); + await servicesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(serviceName); + + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields( + upstreamSection, + { nodes, name: randomId('reg-up'), desc: 'reg' } + ); + + await servicesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { hasText: 'success' }); + await servicesPom.isDetailPage(page); + + const serviceId = page.url().split('/').pop()!; + const svc = (await getServiceReq(e2eReq, serviceId)).value; + expect(svc.upstream).toBeDefined(); + expect(svc.upstream_id).toBeUndefined(); +}); diff --git a/e2e/tests/routes.crud-all-fields.spec.ts b/e2e/tests/routes.crud-all-fields.spec.ts index e8b76f6778..f4bcd031b6 100644 --- a/e2e/tests/routes.crud-all-fields.spec.ts +++ b/e2e/tests/routes.crud-all-fields.spec.ts @@ -154,8 +154,15 @@ test('should CRUD route with all fields', async ({ page }) => { await editPluginDialog.getByRole('button', { name: 'Save' }).click(); await expect(editPluginDialog).toBeHidden(); - // delete basic-auth plugin + // delete basic-auth plugin — now requires confirmation per #3342 fix await basicAuthPlugin.getByRole('button', { name: 'Delete' }).click(); + const confirmDeleteDialog = page + .getByRole('dialog') + .filter({ hasText: /basic-auth/i }); + await expect(confirmDeleteDialog).toBeVisible({ timeout: 5000 }); + await confirmDeleteDialog + .getByRole('button', { name: 'Delete' }) + .click(); await expect(basicAuthPlugin).toBeHidden(); // add real-ip plugin diff --git a/e2e/tests/routes.crud-required-fields.spec.ts b/e2e/tests/routes.crud-required-fields.spec.ts index ff9c537204..caad222144 100644 --- a/e2e/tests/routes.crud-required-fields.spec.ts +++ b/e2e/tests/routes.crud-required-fields.spec.ts @@ -75,7 +75,7 @@ test('should CRUD route with required fields', async ({ page }) => { nodes, name: 'test-upstream', desc: 'test', - }, page); + }); // Submit the form await routesPom.getAddBtn(page).click(); await uiHasToastMsg(page, { diff --git a/e2e/tests/services.routes.crud.spec.ts b/e2e/tests/services.routes.crud.spec.ts index 2ed926b30d..181741a2cc 100644 --- a/e2e/tests/services.routes.crud.spec.ts +++ b/e2e/tests/services.routes.crud.spec.ts @@ -108,7 +108,7 @@ test('should CRUD route under service with required fields', async ({ nodes, name: 'test-upstream', desc: 'test', - }, page); + }); // Submit the form await servicesPom.getAddBtn(page).click(); diff --git a/e2e/tests/ssls.crud-all-fields.spec.ts b/e2e/tests/ssls.crud-all-fields.spec.ts index 07c4fcf171..1273d6d5d7 100644 --- a/e2e/tests/ssls.crud-all-fields.spec.ts +++ b/e2e/tests/ssls.crud-all-fields.spec.ts @@ -149,8 +149,13 @@ test('should CRUD SSL with all fields', async ({ page }) => { const cert1Field = page.getByRole('textbox', { name: 'Certificate 1' }); await expect(cert1Field).toBeEnabled(); - // Cancel without making changes + // Cancel without making changes. The Edit→Cancel guard now always + // confirms before discarding (see src/hooks/useEditCancelGuard.tsx). await page.getByRole('button', { name: 'Cancel' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Discard Changes' }) + .click(); // Return to list page await sslsPom.getSSLNavBtn(page).click(); diff --git a/e2e/tests/ssls.crud-required-fields.spec.ts b/e2e/tests/ssls.crud-required-fields.spec.ts index 16ab1215d8..cdecc1c4a1 100644 --- a/e2e/tests/ssls.crud-required-fields.spec.ts +++ b/e2e/tests/ssls.crud-required-fields.spec.ts @@ -114,8 +114,14 @@ test('should CRUD SSL with required fields', async ({ page }) => { // Verify the new SNI is displayed await expect(page.getByText('updated.example.com', { exact: true })).toBeVisible(); - // Click Cancel instead of Save to avoid validation issues with empty key + // Click Cancel instead of Save to avoid validation issues with empty key. + // The Edit→Cancel guard now confirms before discarding (see + // src/hooks/useEditCancelGuard.tsx), so dismiss the modal too. await page.getByRole('button', { name: 'Cancel' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Discard Changes' }) + .click(); // Verify we're back in detail view mode await sslsPom.isDetailPage(page); diff --git a/e2e/tests/upstreams.crud-required-fields.spec.ts b/e2e/tests/upstreams.crud-required-fields.spec.ts index b25e0a15d4..ea4e6edfc2 100644 --- a/e2e/tests/upstreams.crud-required-fields.spec.ts +++ b/e2e/tests/upstreams.crud-required-fields.spec.ts @@ -56,7 +56,7 @@ test('should CRUD upstream with required fields', async ({ page }) => { await uiFillUpstreamRequiredFields(page, { name: upstreamName, nodes, - }, page); + }); await upstreamsPom.getAddBtn(page).click(); await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully', diff --git a/e2e/utils/bulk.ts b/e2e/utils/bulk.ts new file mode 100644 index 0000000000..ece127ff36 --- /dev/null +++ b/e2e/utils/bulk.ts @@ -0,0 +1,158 @@ +/** + * 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. + */ + +// Bulk-data helpers: seed and tear down N resources directly via the Admin +// API (NOT via the UI). The UI side is the system under test — keeping the +// data prep on the API side decouples the bulk-render assertions from the +// add-form behavior. + +import { API_ROUTES, API_UPSTREAMS, PAGE_SIZE_MAX } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +import { e2eReq } from './req'; + +const CONCURRENCY = 20; + +const runBatched = async ( + items: T[], + fn: (item: T) => Promise, + concurrency = CONCURRENCY +): Promise => { + for (let i = 0; i < items.length; i += concurrency) { + const slice = items.slice(i, i + concurrency); + await Promise.all(slice.map(fn)); + } +}; + +export type BulkRouteSeedOptions = { + count: number; + prefix?: string; + uriPrefix?: string; + host?: string; +}; + +/** + * Create N routes via PUT with explicit IDs ("{prefix}-{i}"). Returns the + * names so a test can spot-check individual rows. + */ +export const bulkCreateRoutes = async ( + opts: BulkRouteSeedOptions +): Promise => { + const { + count, + prefix = 'bulk', + uriPrefix = '/bulk', + host = 'bulk.local', + } = opts; + const ids = Array.from({ length: count }, (_, i) => `${prefix}-${i}`); + await runBatched(ids, async (id) => { + const body: APISIXType['Route'] = { + name: id, + uri: `${uriPrefix}/${id}`, + methods: ['GET'], + upstream: { + type: 'roundrobin', + nodes: { [`${host}:80`]: 1 }, + }, + } as APISIXType['Route']; + await e2eReq.put(`${API_ROUTES}/${id}`, body); + }); + return ids; +}; + +/** + * Delete every route whose ID starts with `prefix`. + */ +export const bulkDeleteRoutesByPrefix = async ( + prefix = 'bulk' +): Promise => { + let removed = 0; + // Paginate just in case the prefix bucket is enormous. + let page = 1; + while (true) { + const resp = await e2eReq.get(API_ROUTES, { + params: { page, page_size: PAGE_SIZE_MAX }, + }); + const list = ( + resp.data as { + list: Array<{ value: { id?: string; name?: string } }>; + total: number; + } + ).list; + const matching = list.filter( + (item) => + (item.value.id && item.value.id.startsWith(prefix)) || + (item.value.name && item.value.name.startsWith(prefix)) + ); + if (matching.length === 0) break; + await runBatched(matching, async (item) => { + await e2eReq.delete(`${API_ROUTES}/${item.value.id}`); + removed++; + }); + if (list.length < PAGE_SIZE_MAX) break; + page++; + } + return removed; +}; + +export type BulkUpstreamSeedOptions = { + count: number; + prefix?: string; +}; + +export const bulkCreateUpstreams = async ( + opts: BulkUpstreamSeedOptions +): Promise => { + const { count, prefix = 'bulk-up' } = opts; + const ids = Array.from({ length: count }, (_, i) => `${prefix}-${i}`); + await runBatched(ids, async (id) => { + const body: APISIXType['Upstream'] = { + name: id, + nodes: [{ host: `${id}.local`, port: 80, weight: 1 }], + } as APISIXType['Upstream']; + await e2eReq.put(`${API_UPSTREAMS}/${id}`, body); + }); + return ids; +}; + +export const bulkDeleteUpstreamsByPrefix = async ( + prefix = 'bulk-up' +): Promise => { + let removed = 0; + let page = 1; + while (true) { + const resp = await e2eReq.get(API_UPSTREAMS, { + params: { page, page_size: PAGE_SIZE_MAX }, + }); + const list = ( + resp.data as { list: Array<{ value: { id?: string; name?: string } }> } + ).list; + const matching = list.filter( + (item) => + (item.value.id && item.value.id.startsWith(prefix)) || + (item.value.name && item.value.name.startsWith(prefix)) + ); + if (matching.length === 0) break; + await runBatched(matching, async (item) => { + await e2eReq.delete(`${API_UPSTREAMS}/${item.value.id}`); + removed++; + }); + if (list.length < PAGE_SIZE_MAX) break; + page++; + } + return removed; +}; diff --git a/e2e/utils/clean.ts b/e2e/utils/clean.ts new file mode 100644 index 0000000000..a692e842c0 --- /dev/null +++ b/e2e/utils/clean.ts @@ -0,0 +1,38 @@ +/** + * 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. + */ + +/** + * Best-effort cleanup helper. + * + * Some product-side `deleteAll*` helpers (e.g. `deleteAllServices` → + * `deleteAllStreamRoutes`) fail when the APISIX deployment has stream mode + * disabled. Cleanup is not the system under test — it must not fail the + * spec. Use this wrapper in `beforeAll` / `afterAll` so tests focus on the + * thing they actually want to verify. + */ +export async function safeClean( + ...fns: Array<() => Promise> +): Promise { + for (const fn of fns) { + try { + await fn(); + } catch { + // Swallow — cleanup failures must not abort the test that owns this + // hook. Each individual test asserts product behavior on its own. + } + } +} diff --git a/e2e/utils/ui/crash.ts b/e2e/utils/ui/crash.ts new file mode 100644 index 0000000000..3a8d6491b2 --- /dev/null +++ b/e2e/utils/ui/crash.ts @@ -0,0 +1,46 @@ +/** + * 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 type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export type CrashWatcher = { + errors: string[]; + expectNoCrash: (context?: string) => void; +}; + +/** + * Attach a listener that records every unhandled page error (window.onerror / + * unhandledrejection) for later assertion. Used by regression tests that + * guard against crash bugs like #3279, #3370, #3381. + */ +export const watchForCrashes = (page: Page): CrashWatcher => { + const errors: string[] = []; + page.on('pageerror', (e) => { + errors.push(`${e.name}: ${e.message}`); + }); + return { + errors, + expectNoCrash: (context?: string) => { + expect( + errors, + context + ? `Unhandled page errors during "${context}": ${errors.join(' | ')}` + : `Unhandled page errors: ${errors.join(' | ')}` + ).toEqual([]); + }, + }; +}; diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts index 23c005c5c6..88042097ee 100644 --- a/e2e/utils/ui/index.ts +++ b/e2e/utils/ui/index.ts @@ -120,9 +120,20 @@ export const uiFillMonacoEditor = async ( value: string ) => { await editor.click(); - const editorTextbox = editor.getByRole('textbox'); - // Use fill() instead of pressSequentially() for reliability - await editorTextbox.fill(value); + // Wait for the Monaco global to be wired up (set when an editor instance + // mounts — same hook uiClearMonacoEditor depends on). + await page.waitForFunction( + () => typeof window.__monacoEditor__ !== 'undefined', + null, + { timeout: 10_000 } + ); + // Set value directly on the model. Going through keystroke `fill()` + // triggers Monaco's auto-bracket / auto-quote pairing, which silently + // corrupts JSON inputs containing `{` or `"`. + await page.evaluate((v) => { + const ed = window.__monacoEditor__; + ed.getModel()?.setValue(v); + }, value); await editor.blur(); await page.waitForTimeout(800); }; diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts index e97ec85ab8..4ce5aa32ab 100644 --- a/e2e/utils/ui/upstreams.ts +++ b/e2e/utils/ui/upstreams.ts @@ -33,9 +33,16 @@ import { */ export async function uiFillUpstreamRequiredFields( ctx: Page | Locator, - upstream: Partial, - page?: Page + upstream: Partial ) { + // Derive a Page handle for the small settle delays needed between + // rapid-fire Add Node clicks (EditableProTable's editable lifecycle + // doesn't tolerate clicks during its post-append re-render). + const page: Page = + typeof (ctx as Locator).page === 'function' + ? (ctx as Locator).page() + : (ctx as Page); + // Fill in the Name field await ctx.getByLabel('Name', { exact: true }).fill(upstream.name); @@ -46,35 +53,39 @@ export async function uiFillUpstreamRequiredFields( await expect(noData).toBeVisible(); - // Add first node + // Add first node — wait for table to settle to a single row before + // attempting to type, so the re-render from `ob.append + ob.save` is + // done. await addNodeBtn.click(); await expect(noData).toBeHidden(); const rows = nodesSection.locator('tr.ant-table-row'); + await expect(rows).toHaveCount(1); const firstRowHost = rows.nth(0).getByRole('textbox').first(); await firstRowHost.fill(upstream.nodes[1].host); await expect(firstRowHost).toHaveValue(upstream.nodes[1].host); - // Add second node - blur first to trigger sync, then click Add + // Add second node. Previously this used `force: true` paired with a + // conditional `waitForTimeout(500)` that most callers skipped, leaving a + // race against the prior render. We keep `force: true` (the button is + // never realistically blocked, and Playwright's stability heuristic can + // false-negative on Mantine's hover styles) but also wait for the + // post-blur state to stabilize before the click. await firstRowHost.blur(); - if (page) await page.waitForTimeout(500); + await expect(rows).toHaveCount(1); + await expect(firstRowHost).toHaveValue(upstream.nodes[1].host); + await page.waitForTimeout(300); await addNodeBtn.click({ force: true }); await expect(rows).toHaveCount(2, { timeout: 10000 }); const secondRowHost = rows.nth(1).getByRole('textbox').first(); await secondRowHost.fill(upstream.nodes[0].host); await expect(secondRowHost).toHaveValue(upstream.nodes[0].host); - // Add a third node and then remove it to test deletion functionality + // Add a third node and then remove it to test deletion functionality. await secondRowHost.blur(); - let p: Page; - if ( - typeof (ctx as Locator).page === 'function' - ) { - p = (ctx as Locator).page(); - } else { - p = ctx as Page; - } - await p.waitForTimeout(500); - await addNodeBtn.click(); + await expect(rows).toHaveCount(2); + await expect(secondRowHost).toHaveValue(upstream.nodes[0].host); + await page.waitForTimeout(300); + await addNodeBtn.click({ force: true }); await expect(rows).toHaveCount(3, { timeout: 10000 }); await rows.nth(2).getByRole('button', { name: 'Delete' }).click(); await expect(rows).toHaveCount(2); diff --git a/src/apis/credentials.ts b/src/apis/credentials.ts index 574459825f..fac0f3e1e2 100644 --- a/src/apis/credentials.ts +++ b/src/apis/credentials.ts @@ -36,8 +36,13 @@ export const getCredentialListReq = (req: AxiosInstance, params: WithUsername) = ) .then((v) => v.data) .catch((e) => { - // 404 means credentials is empty - if (e.response.status === 404) { + // Both "404 (credentials never created)" and "no response at all + // (network abort / timeout / CORS preflight failure)" are treated as + // an empty list so the consumer detail page stays usable. Letting + // the original bug pattern through (`e.response.status` on an + // undefined response) crashed the whole consumer detail view into + // the app-level error boundary. + if (!e?.response || e.response.status === 404) { const res: APISIXListResponse = { total: 0, list: [], diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts index c01f58db59..786529f7d4 100644 --- a/src/apis/stream_routes.ts +++ b/src/apis/stream_routes.ts @@ -65,10 +65,37 @@ export const postStreamRouteReq = ( ); export const deleteAllStreamRoutes = async (req: AxiosInstance) => { + // APISIX deployments without `apisix.proxy_mode: http&stream` reject the + // list endpoint with 400 "stream mode is disabled". Treat that as "no + // stream routes" so the helper (and `deleteAllServices`, which chains + // through it) doesn't bring the whole cleanup path down with it. const totalRes = await getStreamRouteListReq(req, { page: 1, page_size: PAGE_SIZE_MIN, - }); + }).catch( + (e: { + response?: { status?: number; data?: { error_msg?: string } }; + message?: string; + }) => { + // Both shapes occur depending on the request adapter: the dashboard's + // axios interceptor surfaces `e.response.data.error_msg`, while the + // e2e Playwright fetch adapter throws an Error whose `.message` + // includes the upstream status and body text. + // Require BOTH a 400-class status (or no response at all) AND the + // "stream mode" text — a string match alone could swallow unrelated + // errors whose messages happen to mention stream mode. + const status = e?.response?.status; + const isStreamGate = + status === undefined || (status >= 400 && status < 500); + const haystack = `${e?.response?.data?.error_msg ?? ''} ${e?.message ?? ''}`; + if (isStreamGate && /stream mode/i.test(haystack)) { + return { total: 0, list: [] } as Awaited< + ReturnType + >; + } + throw e; + } + ); const total = totalRes.total; if (total === 0) return; for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) { diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx b/src/components/form-slice/FormItemPlugins/PluginCard.tsx index 2d9bca8f56..bd6ad045f0 100644 --- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ import { Button, Card,Group, Text } from '@mantine/core'; +import { modals } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; export type PluginCardProps = { @@ -78,7 +79,22 @@ export const PluginCard = (props: PluginCardProps) => { size="compact-xs" variant="light" color="red" - onClick={() => onDelete?.(name)} + onClick={() => + modals.openConfirmModal({ + title: t('info.delete.title', { name }), + children: ( + + {t('info.delete.content', { name })} + + ), + labels: { + confirm: t('form.btn.delete'), + cancel: t('form.btn.cancel'), + }, + confirmProps: { color: 'red' }, + onConfirm: () => onDelete?.(name), + }) + } > {t('form.btn.delete')} diff --git a/src/components/form-slice/FormItemPlugins/index.tsx b/src/components/form-slice/FormItemPlugins/index.tsx index d3f3fd8eb2..a03883d19f 100644 --- a/src/components/form-slice/FormItemPlugins/index.tsx +++ b/src/components/form-slice/FormItemPlugins/index.tsx @@ -21,17 +21,14 @@ import { type InputWrapperProps, } from '@mantine/core'; import { useSuspenseQuery } from '@tanstack/react-query'; -import { toJS } from 'mobx'; -import { useLocalObservable } from 'mobx-react-lite'; import { difference } from 'rambdax'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { type FieldValues, useController, type UseControllerProps, } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useDeepCompareEffect } from 'react-use'; import { getPluginsListWithSchemaQueryOptions, @@ -50,6 +47,21 @@ export type FormItemPluginsProps = InputWrapperProps & onChange?: (value: Record) => void; } & Partial; +/** + * Plugins editor for Route / Service / Upstream forms. + * + * Architecture: react-hook-form's controller value (`rawObject`) is the + * single source of truth. There is no local Map cache that mirrors it — + * the previous `useLocalObservable + __map + useEffect([..., rawObject]) + * + save → fOnChange` triangle created the same bidirectional-sync race + * as #3293, where an in-flight `rawObject` update from elsewhere could + * reinitialize `__map` from a stale snapshot and overwrite a just-saved + * plugin config on Submit. Reading directly from `rawObject` removes the + * race entirely. + * + * Only transient UI state (current open plugin name, drawer open flags, + * search query, edit/view mode) lives in component state. + */ export const FormItemPlugins = ( props: FormItemPluginsProps ) => { @@ -60,108 +72,105 @@ export const FormItemPlugins = ( const { t } = useTranslation(); const { - field: { value: rawObject, onChange: fOnChange, name: fName, ...restField }, + field: { value: rawValue, onChange: fOnChange, name: fName, ...restField }, fieldState, } = useController(controllerProps); const isView = useMemo(() => restField.disabled, [restField.disabled]); - const pluginsOb = useLocalObservable(() => ({ - __map: new Map(), - init(obj: Record) { - this.__map = new Map(Object.entries(obj)); - }, - delete(name: string) { - this.__map.delete(name); - this.save(); - }, - allPluginNames: [] as string[], - pluginSchemaObj: new Map(), - initPlugins(props: { - names: string[]; - originObj: Record>; - }) { - const { names, originObj } = props; - this.allPluginNames = names; - this.pluginSchemaObj = new Map(Object.entries(originObj)); - }, - get selected() { - return Array.from(this.__map.keys()); - }, - get unSelected() { - return difference(this.allPluginNames, this.selected); - }, - save() { - const obj = Object.fromEntries(toJS(this.__map)); - fOnChange(obj); - }, - update(config: PluginConfig) { - const { name, config: pluginConfig } = config; - this.__map.set(name, pluginConfig); - this.save(); - this.setSelectPluginsOpened(false); - }, - curPlugin: {} as PluginConfig, - setCurPlugin(name: string) { - this.curPlugin = { - name, - config: this.__map.get(name), - } as PluginConfig; - this.setEditorOpened(true); - }, - get curPluginSchema() { - const d = this.pluginSchemaObj.get(this.curPlugin.name); - if (!d) return {}; - return d[schema]; - }, - editorOpened: false, - setEditorOpened(val: boolean) { - this.editorOpened = val; - }, - closeEditor() { - this.setEditorOpened(false); - this.curPlugin = {} as PluginConfig; - }, - search: '', - setSearch(val: string) { - this.search = val; - }, - mode: 'edit' as PluginCardProps['mode'], - selectPluginsOpened: false, - setSelectPluginsOpened(val: boolean) { - this.selectPluginsOpened = val; - }, - on(mode: PluginCardProps['mode'], name: string) { - this.setCurPlugin(name); - this.mode = mode; - }, - })); + // rawValue may be `undefined` when the form is first mounted with no + // plugins yet — normalize to an empty object for read paths. Wrapped in + // useMemo so identity is stable across renders that don't change the + // form value (otherwise downstream useCallback / useMemo deps would + // churn every render). + const rawObject = useMemo( + () => (rawValue ?? {}) as Record, + [rawValue] + ); + + // Selected plugins are derived directly from the form value — no mirror. + const selected = useMemo(() => Object.keys(rawObject), [rawObject]); const pluginsListReq = useSuspenseQuery( getPluginsListWithSchemaQueryOptions({ schema }) ); + const allPluginNames = pluginsListReq.data.names; + const pluginSchemaObj = pluginsListReq.data.originObj as Record< + string, + APISIXType['PluginSchema'] + >; + const unSelected = useMemo( + () => difference(allPluginNames, selected), + [allPluginNames, selected] + ); + + // Pure-UI state. None of these mirror form data. + const [curPluginName, setCurPluginName] = useState(null); + const [mode, setMode] = useState('edit'); + const [editorOpened, setEditorOpened] = useState(false); + const [selectPluginsOpened, setSelectPluginsOpened] = useState(false); + const [search, setSearch] = useState(''); + + const curPlugin = useMemo(() => { + if (!curPluginName) return {} as PluginConfig; + return { + name: curPluginName, + config: rawObject[curPluginName], + } as PluginConfig; + }, [curPluginName, rawObject]); + + const curPluginSchema = useMemo(() => { + if (!curPluginName) return {}; + const d = pluginSchemaObj[curPluginName]; + if (!d) return {}; + return d[schema]; + }, [curPluginName, pluginSchemaObj, schema]); + + const closeEditor = useCallback(() => { + setEditorOpened(false); + setCurPluginName(null); + }, []); + + const handleUpdate = useCallback( + (config: PluginConfig) => { + const { name, config: pluginConfig } = config; + // Build the new plugins object inline — no intermediate cache to go + // stale. Submit always sees this exact value. + fOnChange({ ...rawObject, [name]: pluginConfig }); + setSelectPluginsOpened(false); + closeEditor(); + }, + [closeEditor, fOnChange, rawObject] + ); - // init the selected plugins - useEffect(() => { - pluginsOb.init(rawObject); - }, [pluginsOb, rawObject]); - useDeepCompareEffect(() => { - pluginsOb.initPlugins(pluginsListReq.data); - }, [pluginsOb, pluginsListReq.data]); + const handleDelete = useCallback( + (name: string) => { + const next = { ...rawObject }; + delete next[name]; + fOnChange(next); + }, + [fOnChange, rawObject] + ); + + const handleOpen = useCallback( + (m: PluginCardProps['mode'], name: string) => { + setCurPluginName(name); + setMode(m); + setEditorOpened(true); + }, + [] + ); return ( - + pluginsOb.on('add', name)} + plugins={unSelected} + opened={selectPluginsOpened} + setOpened={setSelectPluginsOpened} + onAdd={(name) => handleOpen('add', name)} disabled={restField.disabled} /> @@ -169,19 +178,19 @@ export const FormItemPlugins = ( mode={isView ? 'view' : 'edit'} placeholder={t('form.plugins.searchForSelectedPlugins')} mah="60vh" - search={pluginsOb.search} - plugins={pluginsOb.selected} - onDelete={pluginsOb.delete} - onView={(name) => pluginsOb.on('view', name)} - onEdit={(name) => pluginsOb.on('edit', name)} + search={search} + plugins={selected} + onDelete={handleDelete} + onView={(name) => handleOpen('view', name)} + onEdit={(name) => handleOpen('edit', name)} /> diff --git a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx index 6f91fb60cb..114e70f025 100644 --- a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx +++ b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx @@ -16,11 +16,9 @@ */ import { EditableProTable, type ProColumns } from '@ant-design/pro-components'; import { Button, InputWrapper, type InputWrapperProps } from '@mantine/core'; -import { toJS } from 'mobx'; -import { useLocalObservable } from 'mobx-react-lite'; import { nanoid } from 'nanoid'; -import { equals, isNil } from 'rambdax'; -import { useEffect, useMemo } from 'react'; +import { isNil } from 'rambdax'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type FieldValues, useController, @@ -31,7 +29,6 @@ import type { ZodObject, ZodRawShape } from 'zod'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; -import { zGetDefault } from '@/utils/zod'; import { genControllerProps } from '../../form/util'; @@ -51,15 +48,6 @@ const zValidateField = ( return Promise.reject(new Error(error.message)); }; -const genRecord = (data?: DataSource | APISIXType['UpstreamNode']) => { - const d = data || zGetDefault(APISIX.UpstreamNode); - const id = (d as DataSource).id || nanoid(); - return { - ...d, - id, - } as DataSource; -}; - const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => { return Object.entries(data).map(([key, val]) => { const [host, port] = key.split(':'); @@ -73,25 +61,24 @@ const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => { }); }; -const parseToDataSource = (data: APISIXType['UpstreamNodeListOrObj']) => { +const toDataSource = (data: APISIXType['UpstreamNodeListOrObj']): DataSource[] => { let val: APISIXType['UpstreamNodes']; if (isNil(data)) val = []; else if (Array.isArray(data)) val = data as APISIXType['UpstreamNodes']; else val = objToUpstreamNodes(data as APISIXType['UpstreamNodeObj']); - return val.map(genRecord); + return val.map( + (node) => ({ ...node, id: nanoid() }) as DataSource + ); }; -const parseToUpstreamNodes = (data: DataSource[] | undefined) => { +const toUpstreamNodes = (data: DataSource[]): APISIXType['UpstreamNode'][] => { if (!data?.length) return []; - return data.map((item) => { - const d: APISIXType['UpstreamNode'] = { - host: item.host, - port: item.port, - weight: item.weight, - priority: item.priority, - }; - return d; - }); + return data.map((item) => ({ + host: item.host, + port: item.port, + weight: item.weight, + priority: item.priority, + })); }; const genProps = (field: keyof APISIXType['UpstreamNode']) => { @@ -111,6 +98,22 @@ export type FormItemNodesProps = defaultValue?: APISIXType['UpstreamNode'][]; } & Pick; +/** + * Upstream nodes editor. + * + * Architecture: react-hook-form is the single source of truth. The + * EditableProTable is `controlled={false}` so it manages its own internal + * cell editing state — we initialize it once from the form value and only + * push changes UP (typing / add / remove → `fOnChange`). We never push + * the form value DOWN into the table after mount, which eliminates the + * feedback loop that caused #3293. + * + * The previous design used a MobX `useLocalObservable` mirror plus a + * `useEffect([..., value])` that re-derived row ids from the form value + * on every Submit-adjacent re-render. The id churn disrupted the table's + * editable lifecycle and meant a fresh keystroke could be discarded if + * the user clicked Save before the `useClickOutside` blur sync fired. + */ export const FormItemNodes = ( props: FormItemNodesProps ) => { @@ -123,6 +126,95 @@ export const FormItemNodes = ( field: { value, onChange: fOnChange, name: fName, disabled }, fieldState, } = useController(controllerProps); + + // One-shot initialization of table rows from the form value. Subsequent + // form value changes (e.g. a parent useEffect reset) re-mount this + // component via React keying at the route level, so a fresh init is + // correct without us watching `value` continuously. + const [rows, setRows] = useState(() => toDataSource(value)); + // Keep the latest rows accessible to action handlers without putting + // them in their `useCallback` deps (which would re-render the table + // and reset cell edit focus on every keystroke). + const rowsRef = useRef(rows); + rowsRef.current = rows; + // Ref on the wrapper so the DOM-flush listener can scope its query. + const wrapperRef = useRef(null); + + const pushUp = useCallback( + (next: DataSource[]) => { + const vals = toUpstreamNodes(next); + fOnChange?.(vals); + restProps.onChange?.(vals); + }, + [fOnChange, restProps] + ); + + // Read whatever the user CURRENTLY sees in the cell inputs (DOM values) + // and push that to react-hook-form. This is the last-resort sync that + // fires when something outside the table is mousedowned — typically the + // Submit button — because `valueType: "digit"` cells in + // ant-design ProTable don't commit their typed value to Antd Form's + // internal state until blur. Without this, a user who types into a + // weight/port cell and clicks Save without blurring first would submit + // stale (uncommitted) values, which was #3293. + const flushFromDom = useCallback(() => { + const root = wrapperRef.current; + if (!root) return; + const rowEls = root.querySelectorAll('tr.ant-table-row'); + if (rowEls.length === 0) return; + const next: DataSource[] = []; + rowEls.forEach((rowEl, index) => { + const inputs = rowEl.querySelectorAll('input'); + const existing = rowsRef.current[index]; + const host = inputs[0]?.value ?? existing?.host ?? ''; + // ant-design InputNumber renders as type=number; parseInt is safe + // because the schema rejects non-integer values upstream. + const portRaw = inputs[1]?.value; + const weightRaw = inputs[2]?.value; + const priorityRaw = inputs[3]?.value; + next.push({ + id: existing?.id ?? nanoid(), + host, + port: + portRaw !== undefined && portRaw !== '' + ? Number(portRaw) + : (existing?.port ?? 80), + weight: + weightRaw !== undefined && weightRaw !== '' + ? Number(weightRaw) + : (existing?.weight ?? 1), + priority: + priorityRaw !== undefined && priorityRaw !== '' + ? Number(priorityRaw) + : (existing?.priority ?? 0), + } as DataSource); + }); + pushUp(next); + }, [pushUp]); + + // Mousedown fires BEFORE the click-induced blur on the active cell, + // and BEFORE the click handler on a Submit button. By flushing DOM + // values at this point we guarantee react-hook-form sees the current + // visible state, regardless of whether Antd Form has committed yet. + useEffect(() => { + const onMouseDown = (e: MouseEvent) => { + const root = wrapperRef.current; + if (!root) return; + if (root.contains(e.target as Node)) return; // inside the table — ignore + flushFromDom(); + }; + document.addEventListener('mousedown', onMouseDown, true); + document.addEventListener('touchstart', onMouseDown as EventListener, true); + return () => { + document.removeEventListener('mousedown', onMouseDown, true); + document.removeEventListener( + 'touchstart', + onMouseDown as EventListener, + true + ); + }; + }, [flushFromDom]); + const columns = useMemo[]>( () => [ { @@ -173,46 +265,51 @@ export const FormItemNodes = ( ], [disabled, t] ); - const { label, required, withAsterisk } = props; - const ob = useLocalObservable(() => ({ - disabled: false, - setDisabled(disabled: boolean | undefined) { - this.disabled = disabled || false; - }, - values: [] as DataSource[], - setValues(data: DataSource[]) { - if (equals(parseToUpstreamNodes(toJS(this.values)), parseToUpstreamNodes(data))) return; - this.values = data; - this.save(); - }, - append(data: DataSource) { - this.values.push(data); - }, - remove(id: string) { - const index = this.values.findIndex((item) => item.id === id); - if (index === -1) return; - this.values.splice(index, 1); - }, - get editableKeys() { - return this.disabled ? [] : this.values.map((item) => item.id); + + const editableKeys = useMemo( + () => (disabled ? [] : rows.map((r) => r.id)), + [disabled, rows] + ); + + const handleValuesChange = useCallback( + (_record: DataSource, dataSource: DataSource[]) => { + // Cell-level edit: push every keystroke straight to react-hook-form + // so a Submit fired before blur still sees the typed values. + setRows(dataSource); + pushUp(dataSource); }, - save() { - const vals = parseToUpstreamNodes(toJS(this.values)); - fOnChange?.(vals); - restProps.onChange?.(vals); + [pushUp] + ); + + const handleRemove = useCallback( + (id: string) => { + const next = rowsRef.current.filter((r) => r.id !== id); + setRows(next); + pushUp(next); }, - })); - useEffect(() => { - ob.setValues(parseToDataSource(value)); - }, [ob, value]); - useEffect(() => { - ob.setDisabled(disabled); - }, [disabled, ob]); + [pushUp] + ); + const handleAppend = useCallback(() => { + const next: DataSource[] = [ + ...rowsRef.current, + { + host: '', + port: 80, + weight: 1, + priority: 0, + id: nanoid(), + } as DataSource, + ]; + setRows(next); + pushUp(next); + }, [pushUp]); + const { label, required, withAsterisk } = props; return ( ( rowKey="id" bordered controlled={false} - value={ob.values} + value={rows} recordCreatorProps={false} columns={columns} editable={{ type: 'multiple', - editableKeys: ob.editableKeys, - onValuesChange(_, dataSource) { - ob.setValues(dataSource); - }, + editableKeys, + onValuesChange: handleValuesChange, actionRender: (row) => { return [ , @@ -260,10 +352,7 @@ export const FormItemNodes = ( size="xs" color="cyan" style={{ borderColor: 'whitesmoke' }} - onClick={() => { - ob.append(genRecord()); - ob.save(); - }} + onClick={handleAppend} {...(disabled && { display: 'none' })} > {t('form.upstreams.nodes.add')} diff --git a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx index 177ba75c46..8a923f6023 100644 --- a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx +++ b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx @@ -17,11 +17,9 @@ import { Drawer, Group } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { toJS } from 'mobx'; -import { useLocalObservable } from 'mobx-react-lite'; import { difference } from 'rambdax'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDeepCompareEffect } from 'react-use'; import { deletePluginMetadataReq, putPluginMetadataReq } from '@/apis/plugins'; import type { PluginCardProps } from '@/components/form-slice/FormItemPlugins/PluginCard'; @@ -35,12 +33,26 @@ import { } from '@/components/form-slice/FormItemPlugins/PluginEditorDrawer'; import { SelectPluginsDrawer } from '@/components/form-slice/FormItemPlugins/SelectPluginsDrawer'; -import { type PluginInfo, usePluginMetadataList } from './hooks'; +import { usePluginMetadataList } from './hooks'; +/** + * Plugin Metadata page. + * + * Architecture: read directly from the `usePluginMetadataList()` query + * result (`pluginInfoMap` + `hasConfigNames`). The previous design used a + * MobX `__map` / `__schemaMap` mirror that was reinitialized via + * `useDeepCompareEffect` on every refetch — a parallel refetch could + * clobber an in-flight edit (same anti-pattern as #3293). Reading the + * query state directly eliminates the mirror and the race. + * + * Only transient UI state lives in component state. + */ export const PluginMetadata = () => { const { t } = useTranslation(); - const getMetadataListReq = usePluginMetadataList(); + const metadataList = usePluginMetadataList(); + const { pluginInfoMap, hasConfigNames, allPluginNames } = metadataList; + const putMetadata = useMutation({ mutationFn: putPluginMetadataReq, onSuccess(_, variables) { @@ -50,7 +62,7 @@ export const PluginMetadata = () => { }), color: 'green', }); - getMetadataListReq.refetch(); + metadataList.refetch(); }, }); const deleteMetadata = useMutation({ @@ -62,107 +74,87 @@ export const PluginMetadata = () => { }), color: 'green', }); - getMetadataListReq.refetch(); + metadataList.refetch(); }, }); - const pluginsOb = useLocalObservable(() => ({ - __map: new Map(), - __schemaMap: new Map(), - init(map: Map, hasConfigNames: string[]) { - // we need to clear the map first - this.__map.clear(); - this.__schemaMap.clear(); - this.allPluginNames = []; - for (const [name, info] of map.entries()) { - if (hasConfigNames.includes(name)) { - this.__map.set(name, info); - } - this.__schemaMap.set(name, info.schema); - this.allPluginNames.push(name); - } - }, - delete(name: string) { - deleteMetadata.mutateAsync(name); - }, - update(config: PluginConfig) { - putMetadata.mutateAsync(config); - }, - allPluginNames: [] as string[], - get selected() { - return Array.from(this.__map.keys()); - }, - get unSelected() { - return difference(this.allPluginNames, this.selected); - }, - curPlugin: {} as PluginConfig, - curPluginSchema: {} as object, - setCurPlugin(name: string) { - this.curPlugin = this.__map.get(name) || { name, config: {} }; - this.curPluginSchema = this.__schemaMap.get(name)!; - this.setEditorOpened(true); - }, - editorOpened: false, - setEditorOpened(val: boolean) { - this.editorOpened = val; - }, - closeEditor() { - this.setEditorOpened(false); - this.setSelectPluginsOpened(false); - this.curPlugin = {} as PluginConfig; - }, - search: '', - setSearch(val: string) { - this.search = val; - }, - mode: 'edit' as PluginCardProps['mode'], - selectPluginsOpened: false, - setSelectPluginsOpened(val: boolean) { - this.selectPluginsOpened = val; - }, - on(mode: PluginCardProps['mode'], name: string) { - this.setCurPlugin(name); - this.mode = mode; + // Pure-UI state. None of these mirror query data. + const [curPluginName, setCurPluginName] = useState(null); + const [mode, setMode] = useState('edit'); + const [editorOpened, setEditorOpened] = useState(false); + const [selectPluginsOpened, setSelectPluginsOpened] = useState(false); + const [search, setSearch] = useState(''); + + const unSelected = useMemo( + () => difference(allPluginNames ?? [], hasConfigNames), + [allPluginNames, hasConfigNames] + ); + + const curPlugin = useMemo(() => { + if (!curPluginName) return {} as PluginConfig; + const info = pluginInfoMap.get(curPluginName); + return info + ? ({ name: info.name, config: info.config } as PluginConfig) + : ({ name: curPluginName, config: {} } as PluginConfig); + }, [curPluginName, pluginInfoMap]); + + const curPluginSchema = useMemo(() => { + if (!curPluginName) return {}; + return pluginInfoMap.get(curPluginName)?.schema ?? {}; + }, [curPluginName, pluginInfoMap]); + + const closeEditor = useCallback(() => { + setEditorOpened(false); + setSelectPluginsOpened(false); + setCurPluginName(null); + }, []); + + const handleOpen = useCallback( + (m: PluginCardProps['mode'], name: string) => { + setCurPluginName(name); + setMode(m); + setEditorOpened(true); }, - })); + [] + ); + + const handleUpdate = useCallback( + (config: PluginConfig) => putMetadata.mutateAsync(config), + [putMetadata] + ); - const { pluginInfoMap, hasConfigNames, isLoading } = getMetadataListReq; - // init the selected plugins - useDeepCompareEffect(() => { - if (isLoading) return; - pluginsOb.init(pluginInfoMap, hasConfigNames); - }, [pluginInfoMap, hasConfigNames, pluginsOb, isLoading]); + const handleDelete = useCallback( + (name: string) => deleteMetadata.mutateAsync(name), + [deleteMetadata] + ); return ( - + pluginsOb.on('add', name)} - opened={pluginsOb.selectPluginsOpened} - setOpened={pluginsOb.setSelectPluginsOpened} + plugins={unSelected} + onAdd={(name) => handleOpen('add', name)} + opened={selectPluginsOpened} + setOpened={setSelectPluginsOpened} /> pluginsOb.on('edit', name)} + search={search} + plugins={hasConfigNames} + onDelete={handleDelete} + onEdit={(name) => handleOpen('edit', name)} /> ); diff --git a/src/hooks/useEditCancelGuard.tsx b/src/hooks/useEditCancelGuard.tsx new file mode 100644 index 0000000000..1bed93c38e --- /dev/null +++ b/src/hooks/useEditCancelGuard.tsx @@ -0,0 +1,69 @@ +/** + * 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 { Text } from '@mantine/core'; +import { modals } from '@mantine/modals'; +import { useCallback } from 'react'; +import type { FieldValues, UseFormReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +/** + * Guard the Edit-mode → Cancel handler with a confirmation modal so a + * misclick can't silently throw away in-flight changes. + * + * Why the modal always shows (even on a clean form): + * + * Every Edit page in this app uses the pattern + * + * useForm({ disabled: readOnly, defaultValues: ... }) + * useEffect(() => form.reset(producedValues), [data, form]) + * + * which has two failure modes for any "is the form actually dirty?" check: + * + * 1. Toggling `disabled` from true→false on Edit re-renders every + * controlled input and can fire spurious change events that flip + * react-hook-form's `isDirty` to true with no user input. + * 2. The `form.reset(...)` inside a useEffect runs whenever the backing + * query refetches (tab focus, window focus, mutation success), which + * wipes `isDirty` back to false mid-session — so a legitimately + * edited form can transiently appear clean. + * + * Both make `isDirty` unreliable as a "skip the modal" signal. Until the + * underlying form lifecycle is restructured (tracked as a follow-up + * cleanup), we always show the modal. One extra click is a far better + * failure mode than silently losing an edit. + */ +export const useEditCancelGuard = ( + form: UseFormReturn, + onCancel: () => void +) => { + const { t } = useTranslation(); + return useCallback(() => { + modals.openConfirmModal({ + centered: true, + title: t('info.unsaved.title'), + children: {t('info.unsaved.content')}, + labels: { + confirm: t('info.unsaved.confirm'), + cancel: t('form.btn.cancel'), + }, + onConfirm: () => { + form.reset(); + onCancel(); + }, + }); + }, [form, onCancel, t]); +}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index bcce5948eb..0ce4863242 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -52,8 +52,12 @@ const Root = () => { - - + {import.meta.env.MODE !== 'test' && ( + <> + + + + )} ); diff --git a/src/routes/consumer_groups/detail.$id.tsx b/src/routes/consumer_groups/detail.$id.tsx index 8d396ab956..8e9895622c 100644 --- a/src/routes/consumer_groups/detail.$id.tsx +++ b/src/routes/consumer_groups/detail.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_CONSUMER_GROUPS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -79,6 +80,8 @@ const ConsumerGroupDetailForm = (props: Props) => { form.reset(data.value); }, [form, data.value]); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (!data) return ; return ( @@ -93,7 +96,7 @@ const ConsumerGroupDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx index ff2c2f69c5..2684840bcf 100644 --- a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx +++ b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_CREDENTIALS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -86,6 +87,8 @@ const CredentialDetailForm = (props: CredentialFormProps) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (isLoading) { return ; } @@ -98,7 +101,7 @@ const CredentialDetailForm = (props: CredentialFormProps) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/consumers/detail.$username/index.tsx b/src/routes/consumers/detail.$username/index.tsx index c87835c5fd..db71c37cf3 100644 --- a/src/routes/consumers/detail.$username/index.tsx +++ b/src/routes/consumers/detail.$username/index.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_CONSUMERS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -80,6 +81,8 @@ const ConsumerDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (isLoading) { return ; } @@ -96,7 +99,7 @@ const ConsumerDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/global_rules/detail.$id.tsx b/src/routes/global_rules/detail.$id.tsx index 9000a8a724..29e0122426 100644 --- a/src/routes/global_rules/detail.$id.tsx +++ b/src/routes/global_rules/detail.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_GLOBAL_RULES } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; type Props = { @@ -77,6 +78,8 @@ const GlobalRuleDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + return (
putGlobalRule.mutateAsync(d))}> @@ -85,7 +88,7 @@ const GlobalRuleDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/plugin_configs/detail.$id.tsx b/src/routes/plugin_configs/detail.$id.tsx index b85a574f1c..6342c28990 100644 --- a/src/routes/plugin_configs/detail.$id.tsx +++ b/src/routes/plugin_configs/detail.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_PLUGIN_CONFIGS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -81,6 +82,8 @@ const PluginConfigDetailForm = (props: Props) => { form.reset(initialValue); }, [form, initialValue]); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (!data) return ; return ( @@ -91,7 +94,7 @@ const PluginConfigDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/protos/detail.$id.tsx b/src/routes/protos/detail.$id.tsx index c19d184d75..c71de41d24 100644 --- a/src/routes/protos/detail.$id.tsx +++ b/src/routes/protos/detail.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_PROTOS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -81,6 +82,8 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }: ProtoFormProps) => { } }, [protoData, form]); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (isLoading) { return ; } @@ -93,7 +96,7 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }: ProtoFormProps) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx index 7fc8856f3b..21fa6e78f9 100644 --- a/src/routes/routes/detail.$id.tsx +++ b/src/routes/routes/detail.$id.tsx @@ -51,6 +51,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -119,6 +120,8 @@ const RouteDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + return ( putRoute.mutateAsync(d))}> @@ -127,7 +130,7 @@ const RouteDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/secrets/detail.$manager.$id.tsx b/src/routes/secrets/detail.$manager.$id.tsx index 9c2e96599f..4a70d11ca4 100644 --- a/src/routes/secrets/detail.$manager.$id.tsx +++ b/src/routes/secrets/detail.$manager.$id.tsx @@ -38,6 +38,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_SECRETS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -87,6 +88,8 @@ const SecretDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (isLoading) { return ; } @@ -99,7 +102,7 @@ const SecretDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/services/detail.$id/index.tsx b/src/routes/services/detail.$id/index.tsx index d6691d5b9f..daee276d60 100644 --- a/src/routes/services/detail.$id/index.tsx +++ b/src/routes/services/detail.$id/index.tsx @@ -42,6 +42,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_SERVICES } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; @@ -91,6 +92,8 @@ const ServiceDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + return ( putService.mutateAsync(d))}> @@ -99,7 +102,7 @@ const ServiceDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx index ba5009c079..7b028a5226 100644 --- a/src/routes/ssls/detail.$id.tsx +++ b/src/routes/ssls/detail.$id.tsx @@ -43,6 +43,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_SSLS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -84,6 +85,8 @@ const SSLDetailForm = (props: Props & { id: string }) => { } }, [sslData, form, isLoading]); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + if (isLoading) { return ; } @@ -101,7 +104,7 @@ const SSLDetailForm = (props: Props & { id: string }) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 85a10e22e6..facb08f7d6 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -40,6 +40,7 @@ import PageHeader from '@/components/page/PageHeader'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; type Props = { @@ -81,6 +82,8 @@ const StreamRouteDetailForm = (props: Props) => { }, }); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + return ( putStreamRoute.mutateAsync(d))}> @@ -89,7 +92,7 @@ const StreamRouteDetailForm = (props: Props) => { {!readOnly && ( {t('form.btn.save')} - diff --git a/src/routes/upstreams/detail.$id.tsx b/src/routes/upstreams/detail.$id.tsx index 1e972bc2c8..cdd8a3d906 100644 --- a/src/routes/upstreams/detail.$id.tsx +++ b/src/routes/upstreams/detail.$id.tsx @@ -50,6 +50,7 @@ import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_UPSTREAMS } from '@/config/constant'; import { req } from '@/config/req'; +import { useEditCancelGuard } from '@/hooks/useEditCancelGuard'; import type { APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; @@ -119,6 +120,8 @@ const UpstreamDetailForm = ( } }, [upstreamData, form]); + const handleCancel = useEditCancelGuard(form, () => setReadOnly(true)); + return ( @@ -132,7 +135,7 @@ const UpstreamDetailForm = ( {!readOnly && ( {t('form.btn.save')} - diff --git a/src/types/schema/apisix/common.ts b/src/types/schema/apisix/common.ts index 11c2b93fca..fd26d7f054 100644 --- a/src/types/schema/apisix/common.ts +++ b/src/types/schema/apisix/common.ts @@ -22,9 +22,19 @@ const Expr = z.array(z.unknown()); const Status = z.union([z.literal(0), z.literal(1)]); +// `name` is optional via `.partial()` below — but a user who deliberately +// types whitespace into the Name field expects either visible feedback or +// the value to be discarded. Accepting `" "` produces resources that show +// up blank in list columns and are essentially impossible to identify. +const NonBlankName = z + .string() + .refine((s) => s.length === 0 || s.trim().length > 0, { + message: 'Name cannot be only whitespace', + }); + const Basic = z .object({ - name: z.string(), + name: NonBlankName, desc: z.string(), labels: Labels, status: Status.optional(),