From 8a208ce71b7b3daed5d05607091203382d675e07 Mon Sep 17 00:00:00 2001 From: Tomilla <5007859+Tomilla@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:02:25 +0800 Subject: [PATCH] fix(xray): validate balancer regexes before save --- .../xray/balancers/BalancerFormModal.tsx | 81 +++++++++++-- .../src/test/balancer-form-modal.test.tsx | 106 +++++++++++++++++- 2 files changed, 172 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx index 17aa08b54..ff4575906 100644 --- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; +import { GoRegexInput, validateGoRegex } from '@/components/form'; import { InputAddon } from '@/components/ui'; import { BalancerFormSchema, @@ -70,9 +71,17 @@ export default function BalancerFormModal({ const [state, setState] = useState(() => initialState(balancer)); const [touched, setTouched] = useState>>({}); const [submitAttempted, setSubmitAttempted] = useState(false); + const [costErrors, setCostErrors] = useState>({}); + const [validatingCosts, setValidatingCosts] = useState(false); + const submissionSequence = useRef(0); const isEdit = balancer != null; + useEffect(() => () => { + submissionSequence.current += 1; + }, []); + const update = (key: K, value: FormState[K]) => { + if (validatingCosts) return; setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true })); setState((prev) => ({ ...prev, [key]: value })); }; @@ -99,11 +108,30 @@ export default function BalancerFormModal({ const selectorError = showSelectorIssue ? issues.selector : ''; const showDuplicate = showTagIssue && duplicateTag; - function submit() { + async function submit() { if (!parsed.success || duplicateTag) { setSubmitAttempted(true); return; } + const submission = ++submissionSequence.current; + const nextErrors: Record = {}; + if (state.strategy === 'leastLoad') { + setValidatingCosts(true); + const validationResults = await Promise.all(costs.map(async (cost, idx) => ({ + idx, + error: cost.regexp ? await validateGoRegex(cost.match) : '', + }))); + if (submission !== submissionSequence.current) return; + setValidatingCosts(false); + for (const result of validationResults) { + if (result.error) nextErrors[result.idx] = result.error; + } + } + setCostErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + setSubmitAttempted(true); + return; + } const values = { ...parsed.data }; if (values.strategy !== 'leastLoad') delete values.settings; onConfirm(values); @@ -114,6 +142,7 @@ export default function BalancerFormModal({ key: K, value: BalancerStrategySettings[K], ) => { + if (validatingCosts) return; setState((prev) => ({ ...prev, settings: { ...(prev.settings ?? {}), [key]: value }, @@ -141,11 +170,23 @@ export default function BalancerFormModal({ title={title} okText={okText} cancelText={t('close')} + confirmLoading={validatingCosts} + cancelButtonProps={{ disabled: validatingCosts }} + closable={!validatingCosts} + keyboard={!validatingCosts} mask={{ closable: false }} onOk={submit} - onCancel={onClose} + onCancel={() => { + if (!validatingCosts) onClose(); + }} > -
+ updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))} /> - updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))} - /> + {c.regexp ? ( + { + if (costErrors[idx]) { + setCostErrors((prev) => { + if (!(idx in prev)) return prev; + const next = { ...prev }; + delete next[idx]; + return next; + }); + } + updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: value } : x))); + }} + /> + ) : ( + updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))} + /> + )} {}} onConfirm={onConfirm} />, ); - return { onConfirm }; + return { onConfirm, ...result }; } function erroredItemCount(): number { @@ -55,4 +57,98 @@ describe('BalancerFormModal', () => { expect(explainText()).toContain('Pick at least one outbound'); expect(onConfirm).not.toHaveBeenCalled(); }); + + it('blocks submit when a least-load cost regex fails backend validation', async () => { + vi.mocked(HttpUtil.post).mockResolvedValueOnce({ + success: false, + msg: 'error parsing regexp: missing closing ]', + obj: null, + }); + const onConfirm = vi.fn(); + renderModal(onConfirm, { + tag: 'load-balancer', + strategy: 'leastLoad', + selector: ['proxy'], + fallbackTag: '', + settings: { + costs: [{ regexp: true, match: '[', value: 1 }], + }, + }); + + fireEvent.click(createButton()); + + await waitFor(() => expect(screen.getByText(/missing closing/)).toBeTruthy()); + expect(HttpUtil.post).toHaveBeenCalledWith( + '/panel/api/setting/validateRegex', + { regex: '[' }, + { silent: true }, + ); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('does not confirm after a pending regex validation is cancelled by unmounting', async () => { + let resolveValidation!: (value: Msg) => void; + const pendingValidation = new Promise((resolve) => { + resolveValidation = resolve; + }); + vi.mocked(HttpUtil.post).mockReturnValueOnce(pendingValidation); + const onConfirm = vi.fn(); + const { unmount } = renderModal(onConfirm, { + tag: 'load-balancer', + strategy: 'leastLoad', + selector: ['proxy'], + fallbackTag: '', + settings: { + costs: [{ regexp: true, match: '^proxy-', value: 1 }], + }, + }); + + fireEvent.click(createButton()); + + await waitFor(() => { + const cancelButton = document.querySelector('.ant-modal-footer .ant-btn-default') as HTMLButtonElement | null; + expect(cancelButton?.disabled).toBe(true); + expect(document.querySelector('.ant-modal-close')).toBeNull(); + }); + unmount(); + await act(async () => { + resolveValidation(new Msg(true)); + await pendingValidation; + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('prevents edits while regex validation is pending', async () => { + let resolveValidation!: (value: Msg) => void; + const pendingValidation = new Promise((resolve) => { + resolveValidation = resolve; + }); + vi.mocked(HttpUtil.post).mockReturnValueOnce(pendingValidation); + const onConfirm = vi.fn(); + renderModal(onConfirm, { + tag: 'load-balancer', + strategy: 'leastLoad', + selector: ['proxy'], + fallbackTag: '', + settings: { + costs: [{ regexp: true, match: '^proxy-', value: 1 }], + }, + }); + const tagInput = screen.getByDisplayValue('load-balancer') as HTMLInputElement; + + fireEvent.click(createButton()); + + await waitFor(() => expect(tagInput.disabled).toBe(true)); + fireEvent.change(tagInput, { target: { value: 'edited-while-pending' } }); + expect(tagInput.value).toBe('load-balancer'); + + await act(async () => { + resolveValidation(new Msg(true)); + await pendingValidation; + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls[0][0].tag).toBe('load-balancer'); + }); });