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: {