This is an automated email from the ASF dual-hosted git repository.
guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 0fc388aef fix: array<string> fields rendered with wrong widgets; SSL
edit wiped the mTLS client block (#3435)
0fc388aef is described below
commit 0fc388aef1731cbd1b997e7b77e8dc11bf9b290e
Author: Yuhan <[email protected]>
AuthorDate: Thu Jul 23 17:13:29 2026 +0800
fix: array<string> fields rendered with wrong widgets; SSL edit wiped the
mTLS client block (#3435)
---
.../ssls.noop-edit-preserves-client.spec.ts | 87 ++++++++++++++++++
.../ssls.skip-mtls-uri-regex-field.spec.ts | 98 ++++++++++++++++++++
.../upstreams.http-request-headers-field.spec.ts | 100 +++++++++++++++++++++
src/components/form-slice/FormPartSSL/index.tsx | 18 ++--
.../FormPartUpstream/FormSectionChecks.tsx | 9 +-
src/routes/ssls/detail.$id.tsx | 8 ++
6 files changed, 311 insertions(+), 9 deletions(-)
diff --git a/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts
b/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts
new file mode 100644
index 000000000..dac859565
--- /dev/null
+++ b/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 for a silent data-loss bug found while fixing the
+// skip_mtls_uri_regex widget (#3417 follow-up): the SSL detail page
+// created its form WITHOUT `defaultValues` and populated it only through
+// a later `form.reset(...)`. Under `shouldUnregister: true`, a
+// controlled field that mounts AFTER that reset (the whole client
+// section, gated on `__clientEnabled`) has its entry in `_defaultValues`
+// overwritten from `_options.defaultValues` — i.e. with undefined — by
+// react-hook-form's useController mount effect. The unmount/remount
+// around toggling Edit then drops the value with nothing to re-seed
+// from, wiping the whole `client.*` subtree: an edit-save silently
+// DELETED the mTLS client block (PUT succeeded, verification gone).
+// Passing the producer output as `defaultValues` at creation (the same
+// pattern routes/services/stream_routes detail pages already use) keeps
+// the re-seed source populated.
+
+import { sslsPom } from '@e2e/pom/ssls';
+import { genTLS, 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 { deleteAllSSLs } from '@/apis/ssls';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test('no-op edit-save preserves the mTLS client block', async ({ page }) => {
+ const { cert, key } = await genTLS();
+ const sni = `${randomId('reg-client-keep')}.example.com`;
+ const res = await e2eReq.put<{ value: APISIXType['SSL'] }>(
+ `/ssls/${randomId('reg-client-keep')}`,
+ {
+ snis: [sni],
+ cert,
+ key,
+ client: {
+ ca: cert,
+ depth: 2,
+ skip_mtls_uri_regex: ['/health.*', '/metrics'],
+ },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/ssls/detail/$id', { id });
+ await sslsPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ // the API never returns the private key, so a save always needs it
+ // re-entered — everything else must survive untouched
+ await page.getByRole('textbox', { name: 'Key 1' }).fill(key);
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expect(
+ page.getByRole('alert').filter({ hasText: /success/i })
+ ).toBeVisible();
+
+ const after = await e2eReq.get<{ value: APISIXType['SSL'] }>(`/ssls/${id}`);
+ const client = after.data.value.client;
+ expect(client).toBeTruthy();
+ expect(client?.ca).toBe(cert);
+ expect(client?.depth).toBe(2);
+ expect(client?.skip_mtls_uri_regex).toEqual(['/health.*', '/metrics']);
+});
diff --git a/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.spec.ts
b/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.spec.ts
new file mode 100644
index 000000000..d2c587220
--- /dev/null
+++ b/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.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 for a data-integrity item of apache/apisix-dashboard#3417:
+// `ssls.client.skip_mtls_uri_regex` is typed `array<string>` (URI regex
+// list, matching the Admin API) but was rendered as a boolean Switch.
+// Both directions were broken: a stored regex list displayed as a
+// meaningless "on" toggle with the actual values invisible, and toggling
+// wrote a boolean that failed the zod array schema on submit.
+
+import { sslsPom } from '@e2e/pom/ssls';
+import { genTLS, 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 { deleteAllSSLs } from '@/apis/ssls';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const regexes = ['/health.*', '/metrics'];
+
+test.beforeAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllSSLs(e2eReq);
+});
+
+test('client skip_mtls_uri_regex displays and round-trips', async ({
+ page,
+}) => {
+ const { cert, key } = await genTLS();
+ const sni = `${randomId('reg-mtls')}.example.com`;
+ const res = await e2eReq.put<{ value: APISIXType['SSL'] }>(
+ `/ssls/${randomId('reg-mtls')}`,
+ {
+ snis: [sni],
+ cert,
+ key,
+ client: { ca: cert, skip_mtls_uri_regex: regexes },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/ssls/detail/$id', { id });
+ await sslsPom.isDetailPage(page);
+
+ // stored regexes must be visible (unfixed: a bare "on" Switch, values
+ // nowhere on the page)
+ await expect(page.getByText(regexes[0], { exact: true })).toBeVisible();
+ await expect(page.getByText(regexes[1], { exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ const field = page.getByRole('textbox', {
+ name: 'Skip mTLS URI Regex',
+ exact: true,
+ });
+ // a regex legitimately containing a comma (quantifier {1,3}) must stay
+ // ONE tag — the field must not comma-split it (#3435 review)
+ const commaRegex = '^/v[0-9]{1,3}$';
+ // type character by character so the comma keystroke would trigger
+ // Mantine's default comma-split (the actual user path)
+ await field.pressSequentially(commaRegex);
+ await field.press('Enter');
+ await expect(page.getByText(commaRegex, { exact: true })).toBeVisible();
+
+ // the API does not return the private key to the form; re-fill it so
+ // the PUT passes validation (same reason the crud spec cancels edits)
+ await page.getByRole('textbox', { name: 'Key 1' }).fill(key);
+
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expect(
+ page.getByRole('alert').filter({ hasText: /success/i })
+ ).toBeVisible();
+
+ const after = await e2eReq.get<{ value: APISIXType['SSL'] }>(`/ssls/${id}`);
+ expect(after.data.value.client?.skip_mtls_uri_regex).toEqual([
+ ...regexes,
+ commaRegex,
+ ]);
+});
diff --git a/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts
b/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts
new file mode 100644
index 000000000..98242e055
--- /dev/null
+++ b/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * 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 for a data-integrity item of apache/apisix-dashboard#3417:
+// `checks.active.http_request_headers` is typed `array<string>` (matching
+// the Admin API) but was rendered with the Labels widget, which expects
+// an OBJECT value. Both directions were broken: stored header lines
+// displayed as an empty field (the widget's array guard returns []), and
+// anything typed in produced an object that failed the zod array schema
+// on submit — the field was unusable.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+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 { deleteAllUpstreams } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const headers = ['User-Agent: apisix-probe', 'X-Check: 1'];
+
+test.beforeAll(async () => {
+ await deleteAllUpstreams(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllUpstreams(e2eReq);
+});
+
+test('active health-check request headers display and round-trip', async ({
+ page,
+}) => {
+ const name = randomId('reg-hdr-field');
+ const res = await e2eReq.put<{ value: APISIXType['Upstream'] }>(
+ `/upstreams/${name}`,
+ {
+ name,
+ type: 'roundrobin',
+ nodes: { 'hdr-field.local:80': 1 },
+ checks: {
+ active: {
+ http_path: '/health',
+ http_request_headers: headers,
+ unhealthy: { interval: 2, http_failures: 3 },
+ },
+ },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/upstreams/detail/$id', { id });
+ await upstreamsPom.isDetailPage(page);
+
+ // stored header lines must be visible (unfixed: the field renders empty)
+ await expect(page.getByText(headers[0], { exact: true })).toBeVisible();
+ await expect(page.getByText(headers[1], { exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // typed input must survive submit as an array member
+ const field = page.getByRole('textbox', {
+ name: 'HTTP Request Headers',
+ exact: true,
+ });
+ // a header value legitimately containing a comma must stay ONE entry —
+ // the field must not comma-split it (#3435 review)
+ const commaHeader = 'Accept: text/html,application/json';
+ await field.pressSequentially(commaHeader);
+ await field.press('Enter');
+ await expect(page.getByText(commaHeader, { exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expect(
+ page.getByRole('alert').filter({ hasText: /success/i })
+ ).toBeVisible();
+
+ const after = await e2eReq.get<{ value: APISIXType['Upstream'] }>(
+ `/upstreams/${id}`
+ );
+ expect(after.data.value.checks?.active?.http_request_headers).toEqual([
+ ...headers,
+ commaHeader,
+ ]);
+});
diff --git a/src/components/form-slice/FormPartSSL/index.tsx
b/src/components/form-slice/FormPartSSL/index.tsx
index 7d8abbb1a..599d8a51f 100644
--- a/src/components/form-slice/FormPartSSL/index.tsx
+++ b/src/components/form-slice/FormPartSSL/index.tsx
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { InputWrapper, Text } from '@mantine/core';
+import { Text } from '@mantine/core';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -54,12 +54,16 @@ const FormSectionClient = () => {
defaultValue={1}
min={0}
/>
- <InputWrapper label={t('form.ssls.client.skipMtlsUriRegex')}>
- <FormItemSwitch
- control={control}
- name="client.skip_mtls_uri_regex"
- />
- </InputWrapper>
+ {/* array<string> of URI regexes — a TagsInput, not a Switch:
+ the boolean broke this field both ways (#3417). splitChars={[]}
+ because a regex can contain a comma (e.g. the quantifier
+ `{1,3}`) and must stay one entry (#3435 review). */}
+ <FormItemTagsInput
+ control={control}
+ name="client.skip_mtls_uri_regex"
+ label={t('form.ssls.client.skipMtlsUriRegex')}
+ splitChars={[]}
+ />
</>
) : (
<Text c="gray.6" size="sm">
diff --git a/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
b/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
index 4becf788a..ff5f905c3 100644
--- a/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
@@ -18,7 +18,6 @@ import { Text } from '@mantine/core';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
-import { FormItemLabels } from '@/components/form/Labels';
import { FormItemNumberInput } from '@/components/form/NumberInput';
import { FormItemSelect } from '@/components/form/Select';
import { FormItemSwitch } from '@/components/form/Switch';
@@ -76,10 +75,16 @@ const FormSectionChecksActive = () => {
name={np('checks.active.http_path')}
label={t('form.upstreams.checks.active.http_path')}
/>
- <FormItemLabels
+ {/* array<string> of raw header lines — a TagsInput, not the Labels
+ widget (which produces an object and broke this field both ways,
+ #3417). splitChars={[]} because a header value can contain a
+ comma (e.g. `Accept: text/html,application/json`) and must stay
+ one entry (#3435 review). */}
+ <FormItemTagsInput
control={control}
name={np('checks.active.http_request_headers')}
label={t('form.upstreams.checks.active.http_request_headers')}
+ splitChars={[]}
/>
<FormSection legend={t('form.upstreams.checks.active.healthy.title')}>
<FormItemNumberInput
diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx
index 7b028a522..2f4d996aa 100644
--- a/src/routes/ssls/detail.$id.tsx
+++ b/src/routes/ssls/detail.$id.tsx
@@ -65,6 +65,14 @@ const SSLDetailForm = (props: Props & { id: string }) => {
shouldUnregister: true,
mode: 'all',
disabled: readOnly,
+ // Without creation-time defaultValues, any controlled field that
+ // mounts AFTER the reset below (the whole client section, gated on
+ // __clientEnabled) gets its _defaultValues entry overwritten with
+ // undefined by RHF's useController mount effect under
+ // shouldUnregister — the unmount/remount around toggling Edit then
+ // wipes the client.* subtree and an edit-save silently deletes the
+ // mTLS client block (same pattern as routes/services detail, #3414).
+ defaultValues: produceToSSLForm(sslData),
});
const putSSL = useMutation({