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"
   }
 }

Reply via email to