fix(xray): validate balancer regexes before save

This commit is contained in:
Tomilla
2026-07-06 10:02:25 +08:00
parent 99072457d1
commit 8a208ce71b
2 changed files with 172 additions and 15 deletions

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { GoRegexInput, validateGoRegex } from '@/components/form';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { import {
BalancerFormSchema, BalancerFormSchema,
@@ -70,9 +71,17 @@ export default function BalancerFormModal({
const [state, setState] = useState<FormState>(() => initialState(balancer)); const [state, setState] = useState<FormState>(() => initialState(balancer));
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({}); const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [costErrors, setCostErrors] = useState<Record<number, string>>({});
const [validatingCosts, setValidatingCosts] = useState(false);
const submissionSequence = useRef(0);
const isEdit = balancer != null; const isEdit = balancer != null;
useEffect(() => () => {
submissionSequence.current += 1;
}, []);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => { const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
if (validatingCosts) return;
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true })); setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
setState((prev) => ({ ...prev, [key]: value })); setState((prev) => ({ ...prev, [key]: value }));
}; };
@@ -99,11 +108,30 @@ export default function BalancerFormModal({
const selectorError = showSelectorIssue ? issues.selector : ''; const selectorError = showSelectorIssue ? issues.selector : '';
const showDuplicate = showTagIssue && duplicateTag; const showDuplicate = showTagIssue && duplicateTag;
function submit() { async function submit() {
if (!parsed.success || duplicateTag) { if (!parsed.success || duplicateTag) {
setSubmitAttempted(true); setSubmitAttempted(true);
return; return;
} }
const submission = ++submissionSequence.current;
const nextErrors: Record<number, string> = {};
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 }; const values = { ...parsed.data };
if (values.strategy !== 'leastLoad') delete values.settings; if (values.strategy !== 'leastLoad') delete values.settings;
onConfirm(values); onConfirm(values);
@@ -114,6 +142,7 @@ export default function BalancerFormModal({
key: K, key: K,
value: BalancerStrategySettings[K], value: BalancerStrategySettings[K],
) => { ) => {
if (validatingCosts) return;
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
settings: { ...(prev.settings ?? {}), [key]: value }, settings: { ...(prev.settings ?? {}), [key]: value },
@@ -141,11 +170,23 @@ export default function BalancerFormModal({
title={title} title={title}
okText={okText} okText={okText}
cancelText={t('close')} cancelText={t('close')}
confirmLoading={validatingCosts}
cancelButtonProps={{ disabled: validatingCosts }}
closable={!validatingCosts}
keyboard={!validatingCosts}
mask={{ closable: false }} mask={{ closable: false }}
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={() => {
if (!validatingCosts) onClose();
}}
> >
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}> <Form
colon={false}
disabled={validatingCosts}
aria-busy={validatingCosts}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
>
<Form.Item <Form.Item
label={t('pages.xray.balancer.tag')} label={t('pages.xray.balancer.tag')}
required required
@@ -258,12 +299,32 @@ export default function BalancerFormModal({
unCheckedChildren="lit" unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))} onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/> />
<Input {c.regexp ? (
value={c.match} <GoRegexInput
aria-label={t('pages.xray.balancer.costMatch')} value={c.match}
placeholder="tag pattern" ariaLabel={t('pages.xray.balancer.costMatch')}
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))} placeholder="tag pattern"
/> externalError={costErrors[idx]}
onChange={(value) => {
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)));
}}
/>
) : (
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
)}
<InputNumber <InputNumber
value={c.value} value={c.value}
aria-label={t('pages.xray.balancer.costValue')} aria-label={t('pages.xray.balancer.costValue')}

View File

@@ -1,21 +1,23 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal'; import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
import { HttpUtil, Msg } from '@/utils';
import type { BalancerFormValue } from '@/pages/xray/balancers/BalancerFormModal';
import { renderWithProviders } from './test-utils'; import { renderWithProviders } from './test-utils';
function renderModal(onConfirm = vi.fn()) { function renderModal(onConfirm = vi.fn(), balancer: BalancerFormValue | null = null) {
renderWithProviders( const result = renderWithProviders(
<BalancerFormModal <BalancerFormModal
open open
balancer={null} balancer={balancer}
outboundTags={['proxy', 'direct']} outboundTags={['proxy', 'direct']}
otherTags={['existing']} otherTags={['existing']}
onClose={() => {}} onClose={() => {}}
onConfirm={onConfirm} onConfirm={onConfirm}
/>, />,
); );
return { onConfirm }; return { onConfirm, ...result };
} }
function erroredItemCount(): number { function erroredItemCount(): number {
@@ -55,4 +57,98 @@ describe('BalancerFormModal', () => {
expect(explainText()).toContain('Pick at least one outbound'); expect(explainText()).toContain('Pick at least one outbound');
expect(onConfirm).not.toHaveBeenCalled(); 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<Msg>((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<Msg>((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');
});
}); });