mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 09:52:14 +03:00
fix(xray): validate balancer regexes before save
This commit is contained in:
@@ -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')}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user