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 d91213362 fix: zOneOf rejected the valid exactly-one state (inverted 
xor) (#3420)
d91213362 is described below

commit d9121336211aaf6f387f0bc02c50279b6a50916c
Author: Ming Wen <[email protected]>
AuthorDate: Mon Jul 13 10:02:18 2026 +0800

    fix: zOneOf rejected the valid exactly-one state (inverted xor) (#3420)
---
 package.json          |  1 +
 src/utils/zod.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/utils/zod.ts      | 32 +++++++++++++--------
 vite.config.ts        |  6 ++++
 4 files changed, 106 insertions(+), 11 deletions(-)

diff --git a/package.json b/package.json
index 9186ff6fc..29f22f931 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "lint": "eslint . --cache --max-warnings=0 --no-warn-ignored",
     "lint:fix": "pnpm lint --fix",
     "preview": "vite preview",
+    "test": "vitest run src",
     "prepare": "husky",
     "e2e": "playwright test"
   },
diff --git a/src/utils/zod.test.ts b/src/utils/zod.test.ts
new file mode 100644
index 000000000..f50bb623d
--- /dev/null
+++ b/src/utils/zod.test.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.
+ */
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { zOneOf } from './zod';
+
+// Regression for #3296: the original implementation used xor() backwards —
+// it rejected the valid "exactly one filled" state and accepted the two
+// invalid states (both filled / neither filled).
+
+type Arg = { a?: string; b?: string };
+
+const schema = z
+  .object({ a: z.string().optional(), b: z.string().optional() })
+  .superRefine(zOneOf<Arg, 'a', 'b'>('a', 'b'));
+
+describe('zOneOf', () => {
+  it('passes when only the first key is filled', () => {
+    expect(schema.safeParse({ a: 'x' }).success).toBe(true);
+    expect(schema.safeParse({ a: 'x', b: undefined }).success).toBe(true);
+  });
+
+  it('passes when only the second key is filled', () => {
+    expect(schema.safeParse({ b: 'y' }).success).toBe(true);
+    expect(schema.safeParse({ a: undefined, b: 'y' }).success).toBe(true);
+  });
+
+  it('fails when both keys are filled', () => {
+    const res = schema.safeParse({ a: 'x', b: 'y' });
+    expect(res.success).toBe(false);
+    if (!res.success) {
+      // one issue per key so both fields show the error
+      expect(res.error.issues.map((i) => i.path[0]).sort()).toEqual([
+        'a',
+        'b',
+      ]);
+    }
+  });
+
+  it('fails when neither key is filled', () => {
+    expect(schema.safeParse({}).success).toBe(false);
+    expect(schema.safeParse({ a: undefined, b: undefined }).success).toBe(
+      false
+    );
+  });
+
+  it('treats an empty string as not filled', () => {
+    // form inputs produce '' rather than undefined when cleared
+    expect(schema.safeParse({ a: '', b: 'y' }).success).toBe(true);
+    expect(schema.safeParse({ a: '', b: '' }).success).toBe(false);
+  });
+
+  it('treats 0 and false as filled', () => {
+    // 0 is a legitimate port/weight and false a deliberate toggle — a
+    // truthiness-based implementation would wrongly treat them as empty
+    const s = z
+      .object({ n: z.number().optional(), f: z.boolean().optional() })
+      .superRefine(zOneOf<{ n?: number; f?: boolean }, 'n', 'f'>('n', 'f'));
+    expect(s.safeParse({ n: 0 }).success).toBe(true);
+    expect(s.safeParse({ f: false }).success).toBe(true);
+    expect(s.safeParse({ n: 0, f: false }).success).toBe(false);
+  });
+});
diff --git a/src/utils/zod.ts b/src/utils/zod.ts
index 82435f9aa..635fec9f4 100644
--- a/src/utils/zod.ts
+++ b/src/utils/zod.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { xor } from 'rambdax';
 import { type RefinementCtx, z } from 'zod';
 import { init } from 'zod-empty';
 
@@ -25,6 +24,10 @@ export const zOneOf =
     K1 extends Extract<keyof A, string>,
     K2 extends Extract<keyof A, string>,
     R extends A &
+      (
+        | (Required<Pick<A, K1>> & { [P in K2]: undefined })
+        | (Required<Pick<A, K2>> & { [P in K1]: undefined })
+      ) = A &
       (
         | (Required<Pick<A, K1>> & { [P in K2]: undefined })
         | (Required<Pick<A, K2>> & { [P in K1]: undefined })
@@ -34,17 +37,24 @@ export const zOneOf =
     key2: K2
   ): ((arg: A, ctx: RefinementCtx) => arg is R) =>
   (arg, ctx): arg is R => {
-    if (xor(arg[key1] as boolean, arg[key2] as boolean)) {
-      [key1, key2].forEach((key) => {
-        ctx.addIssue({
-          path: [key],
-          code: z.ZodIssueCode.custom,
-          message: `Either '${key1}' or '${key2}' must be filled, but not 
both`,
-        });
-      });
-      return false;
+    // "filled" must treat empty strings like missing values: form inputs
+    // produce '' rather than undefined when cleared.
+    // NOTE: object values (including {} and zod-empty defaults) always
+    // count as filled — for an object/string pair, clear the object side
+    // to undefined before validation.
+    const isFilled = (v: unknown) => v !== undefined && v !== null && v !== '';
+    // valid state: exactly one of the two keys is filled
+    if (isFilled(arg[key1]) !== isFilled(arg[key2])) {
+      return true;
     }
-    return true;
+    [key1, key2].forEach((key) => {
+      ctx.addIssue({
+        path: [key],
+        code: z.ZodIssueCode.custom,
+        message: `Either '${key1}' or '${key2}' must be filled, but not both`,
+      });
+    });
+    return false;
   };
 
 export const zGetDefault = init;
diff --git a/vite.config.ts b/vite.config.ts
index cb00b0226..d4e3c6894 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+/// <reference types="vitest/config" />
 import { readdirSync } from 'node:fs';
 
 import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
@@ -39,6 +40,11 @@ if (inDevContainer) {
 // https://vite.dev/config/
 export default defineConfig({
   base: BASE_PATH,
+  test: {
+    // unit tests live under src/; the Playwright specs in e2e/ must not
+    // be collected even when vitest is invoked without the src filter
+    include: ['src/**/*.test.{ts,tsx}'],
+  },
   server: {
     // as an example, if you want to use the e2e server as the api server,
     proxy: {

Reply via email to