This is an automated email from the ASF dual-hosted git repository.
LiteSun 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 c5d567058 test: close i18n key-parity, secret-masking, and
upstream-selection coverage gaps (#3447)
c5d567058 is described below
commit c5d5670585902f798f1c1a8a8c1dbc4d2076d8f4
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 14:27:37 2026 +0800
test: close i18n key-parity, secret-masking, and upstream-selection
coverage gaps (#3447)
---
.../secrets.sensitive-field-masked.spec.ts | 41 ++++++
.../services.upstream-or-id-selection.spec.ts | 149 +++++++++++++++++++++
.../validation.zOneOf-single-field.spec.ts | 81 -----------
src/locale-parity.test.ts | 73 ++++++++++
src/locales/de/common.json | 5 +
src/locales/es/common.json | 5 +
src/locales/tr/common.json | 5 +
7 files changed, 278 insertions(+), 81 deletions(-)
diff --git a/e2e/tests/regression/secrets.sensitive-field-masked.spec.ts
b/e2e/tests/regression/secrets.sensitive-field-masked.spec.ts
new file mode 100644
index 000000000..f0bfab63b
--- /dev/null
+++ b/e2e/tests/regression/secrets.sensitive-field-masked.spec.ts
@@ -0,0 +1,41 @@
+/**
+ * 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 the #3417 test-suite gap: masking was only ever asserted
+// inside the Settings modal (the admin key). Sensitive fields OUTSIDE that
+// modal — the secret manager's token / access keys / private key, which use
+// the same PasswordInput — had no test proving they render masked. This pins
+// the Vault-secret Token field on the Add Secret page as masked (rendered
+// through PasswordInput, i.e. type=password) rather than a plain text input —
+// the regression that would surface if the field were swapped to TextInput.
+
+import { secretsPom } from '@e2e/pom/secrets';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+test('the Vault secret Token field is masked', async ({ page }) => {
+ await secretsPom.toAdd(page);
+ await secretsPom.isAddPage(page);
+
+ // Vault is the default manager, so its Token field is rendered.
+ const token = page.getByLabel('Token', { exact: true });
+ await expect(token).toBeVisible();
+
+ // A typed value must be masked (type=password), never shown in clear.
+ await token.fill('super-secret-token');
+ await expect(token).toHaveAttribute('type', 'password');
+});
diff --git a/e2e/tests/regression/services.upstream-or-id-selection.spec.ts
b/e2e/tests/regression/services.upstream-or-id-selection.spec.ts
new file mode 100644
index 000000000..2fd39bedf
--- /dev/null
+++ b/e2e/tests/regression/services.upstream-or-id-selection.spec.ts
@@ -0,0 +1,149 @@
+/**
+ * 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 the #3417 test-suite gap. This spec used to be named after
+// `zOneOf` and only tested the inline-upstream-only case — a case an inverted
+// implementation would also pass. In fact the Service form does NOT apply the
+// `zOneOf` validator at all (that validator is unused by any form and is
+// covered directly by src/utils/zod.test.ts across all four quadrants). The
+// Service form instead resolves the inline-`upstream` vs `upstream_id` choice
+// leniently via the submit pipeline (`produceRmUpstreamWhenHas('upstream_id')`
+// drops the inline upstream when an id is given). This spec now pins that real
+// behaviour across all four quadrants so an inverted or broken resolution
+// cannot slip through.
+//
+// Related issue: apache/apisix-dashboard#3296.
+
+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 { deleteAllUpstreams } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'reg-svc-up.local', port: 80, weight: 100 },
+ { host: 'reg-svc-up-2.local', port: 80, weight: 100 },
+];
+
+// A real upstream to reference by id in the upstream_id quadrants — the
+// gateway rejects an upstream_id that does not resolve to an existing
upstream.
+let seededUpstreamId = '';
+
+test.beforeAll(async () => {
+ await safeClean(() => deleteAllServices(e2eReq));
+ await safeClean(() => deleteAllUpstreams(e2eReq));
+ const upstreamId = randomId('reg-svc-ref-up');
+ const res = await e2eReq.put<{ value: APISIXType['Upstream'] }>(
+ `/upstreams/${upstreamId}`,
+ { type: 'roundrobin', nodes: { 'reg-svc-ref.local:80': 1 } }
+ );
+ seededUpstreamId = res.data.value.id;
+});
+
+test.afterAll(async () => {
+ await safeClean(() => deleteAllServices(e2eReq));
+ await safeClean(() => deleteAllUpstreams(e2eReq));
+});
+
+const submitAndRead = async (
+ page: import('@playwright/test').Page
+): Promise<APISIXType['Service']> => {
+ await servicesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'success' });
+ await servicesPom.isDetailPage(page);
+ const serviceId = page.url().split('/').pop()!;
+ return (await getServiceReq(e2eReq, serviceId)).value;
+};
+
+test('alt 1 — inline upstream only: stored as inline, no upstream_id', async ({
+ page,
+}) => {
+ await servicesPom.toAdd(page);
+ await servicesPom.isAddPage(page);
+ await page.getByLabel('Name', { exact: true
}).first().fill(randomId('reg-alt1'));
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ nodes,
+ name: randomId('reg-up'),
+ });
+
+ const svc = await submitAndRead(page);
+ expect(svc.upstream).toBeDefined();
+ expect(svc.upstream_id).toBeUndefined();
+});
+
+test('alt 2 — upstream_id only: stored as reference, no inline upstream',
async ({
+ page,
+}) => {
+ await servicesPom.toAdd(page);
+ await servicesPom.isAddPage(page);
+ await page.getByLabel('Name', { exact: true
}).first().fill(randomId('reg-alt2'));
+
+ await page.locator('input[name="upstream_id"]').fill(seededUpstreamId);
+
+ const svc = await submitAndRead(page);
+ expect(svc.upstream_id).toBe(seededUpstreamId);
+ expect(svc.upstream).toBeUndefined();
+});
+
+test('both — upstream_id wins, inline upstream is dropped by the pipeline',
async ({
+ page,
+}) => {
+ await servicesPom.toAdd(page);
+ await servicesPom.isAddPage(page);
+ await page.getByLabel('Name', { exact: true
}).first().fill(randomId('reg-both'));
+
+ await page.locator('input[name="upstream_id"]').fill(seededUpstreamId);
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ nodes,
+ name: randomId('reg-up'),
+ });
+
+ const svc = await submitAndRead(page);
+ expect(svc.upstream_id).toBe(seededUpstreamId);
+ expect(svc.upstream).toBeUndefined();
+});
+
+test('neither — a service with no upstream at all still saves', async ({
+ page,
+}) => {
+ await servicesPom.toAdd(page);
+ await servicesPom.isAddPage(page);
+ await page
+ .getByLabel('Name', { exact: true })
+ .first()
+ .fill(randomId('reg-neither'));
+
+ const svc = await submitAndRead(page);
+ expect(svc.upstream).toBeUndefined();
+ expect(svc.upstream_id).toBeUndefined();
+});
diff --git a/e2e/tests/regression/validation.zOneOf-single-field.spec.ts
b/e2e/tests/regression/validation.zOneOf-single-field.spec.ts
deleted file mode 100644
index 94683faa0..000000000
--- a/e2e/tests/regression/validation.zOneOf-single-field.spec.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * 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/src/locale-parity.test.ts b/src/locale-parity.test.ts
new file mode 100644
index 000000000..82796c7bc
--- /dev/null
+++ b/src/locale-parity.test.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 { readdirSync, readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+// Structural key-parity guard for the locale bundles (#3417 test-suite gap).
+// The lang-switch integration spec only exercises runtime behaviour and
+// self-skips if the switcher isn't found; nothing asserted that every locale
+// actually defines the same set of keys as the base (`en`). A missing key
+// falls back to English silently at runtime, so a locale can drift out of
+// parity — e.g. the `a11y.*` keys added for the base bundle were absent from
+// de/es/tr — without any test noticing.
+//
+// This does NOT check that values are translated (a locale may legitimately
+// share a string with English); it checks only that the key SETS match, which
+// is the part a unit test can enforce cheaply and deterministically.
+
+const LOCALES_DIR = fileURLToPath(new URL('./locales/', import.meta.url));
+const BASE_LANG = 'en';
+
+const flattenKeys = (value: unknown, prefix = ''): string[] => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return [prefix];
+ }
+ return Object.entries(value as Record<string, unknown>).flatMap(([k, v]) =>
+ flattenKeys(v, prefix ? `${prefix}.${k}` : k)
+ );
+};
+
+const loadKeys = (lang: string): Set<string> => {
+ const raw = readFileSync(`${LOCALES_DIR}${lang}/common.json`, 'utf8');
+ return new Set(flattenKeys(JSON.parse(raw)));
+};
+
+const langs = readdirSync(LOCALES_DIR, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name);
+
+const baseKeys = loadKeys(BASE_LANG);
+const otherLangs = langs.filter((l) => l !== BASE_LANG);
+
+describe('locale key parity', () => {
+ it('discovers the base locale and at least one other', () => {
+ expect(langs).toContain(BASE_LANG);
+ expect(otherLangs.length).toBeGreaterThan(0);
+ });
+
+ it.each(otherLangs)('%s defines exactly the base locale keys', (lang) => {
+ const keys = loadKeys(lang);
+ const missing = [...baseKeys].filter((k) => !keys.has(k)).sort();
+ const extra = [...keys].filter((k) => !baseKeys.has(k)).sort();
+ expect(
+ { missing, extra },
+ `${lang} is out of key-parity with ${BASE_LANG}`
+ ).toEqual({ missing: [], extra: [] });
+ });
+});
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 0fbf9dfe1..5c8a9ab7a 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -374,5 +374,10 @@
"error": {
"title": "Etwas ist schiefgelaufen",
"retry": "Erneut versuchen"
+ },
+ "a11y": {
+ "openSettings": "Open settings",
+ "selectLanguage": "Select language",
+ "toggleNavigation": "Toggle navigation"
}
}
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index d6188dfcf..7d8b3a8ee 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -374,5 +374,10 @@
"error": {
"title": "Algo salió mal",
"retry": "Reintentar"
+ },
+ "a11y": {
+ "openSettings": "Open settings",
+ "selectLanguage": "Select language",
+ "toggleNavigation": "Toggle navigation"
}
}
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index c20dccc76..e25f0ac9b 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -374,5 +374,10 @@
"error": {
"title": "Bir şeyler ters gitti",
"retry": "Yeniden dene"
+ },
+ "a11y": {
+ "openSettings": "Open settings",
+ "selectLanguage": "Select language",
+ "toggleNavigation": "Toggle navigation"
}
}