mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-04 04:05:04 +03:00
fix(inbounds): reject incompatible VLESS fallbacks and prevent Xray crashes (#7003)
Xray refuses a VLESS inbound with server decryption whenever fallbacks is non-nil, even an empty list, so an API or imported inbound with decryption and fallbacks: [] took every inbound down. Empty fallback arrays are now normalized away on add, update, config build and node push; non-empty ones are refused with decryption, enabling decryption over stored fallback rules is refused, and a legacy conflicting row holds back the running core instead of stopping it. The form, API docs and locales follow. Closes #7001.
This commit is contained in:
@@ -67,7 +67,7 @@ export interface Section {
|
||||
|
||||
// /inbounds/update replaces the whole row, so it takes the same payload as /add.
|
||||
const inboundBody =
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
|
||||
const outboundSubscriptionBodyParams: EndpointParam[] = [
|
||||
{
|
||||
@@ -309,7 +309,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary:
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.',
|
||||
body: inboundBody,
|
||||
errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
@@ -332,7 +332,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary:
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.',
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.',
|
||||
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
|
||||
body: inboundBody,
|
||||
},
|
||||
@@ -377,7 +377,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary:
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field. VLESS fallback validation is identical to /add.',
|
||||
params: [
|
||||
{
|
||||
name: 'data',
|
||||
@@ -422,7 +422,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/fallbacks',
|
||||
summary:
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { FallbackRow } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface FallbacksCardProps {
|
||||
canAdd?: boolean;
|
||||
fallbacks: FallbackRow[];
|
||||
fallbackChildOptions: { label: string; value: number }[];
|
||||
addFallback: () => void;
|
||||
@@ -20,6 +21,7 @@ interface FallbacksCardProps {
|
||||
}
|
||||
|
||||
export default function FallbacksCard({
|
||||
canAdd = true,
|
||||
fallbacks,
|
||||
fallbackChildOptions,
|
||||
addFallback,
|
||||
@@ -38,6 +40,7 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
icon={<PlusOutlined aria-hidden />}
|
||||
onClick={addFallback}
|
||||
disabled={!canAdd}
|
||||
>
|
||||
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
@@ -45,7 +48,9 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
onClick={addAllFallbacks}
|
||||
disabled={
|
||||
fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length
|
||||
!canAdd ||
|
||||
fallbackChildOptions.length === 0 ||
|
||||
fallbacks.length >= fallbackChildOptions.length
|
||||
}
|
||||
title={t('pages.inbounds.form.addAllFallbackTooltip')}
|
||||
>
|
||||
|
||||
@@ -226,11 +226,14 @@ export default function InboundFormModal({
|
||||
const getV = methods.getValues as unknown as (name?: string) => unknown;
|
||||
const control = methods.control;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savedInboundId = useRef<number | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
@@ -250,6 +253,7 @@ export default function InboundFormModal({
|
||||
* always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||
*/
|
||||
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||
const vlessDecryption = useWatch({ control, name: 'settings.decryption' }) ?? '';
|
||||
const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
|
||||
const ssMethod = useWatch({ control, name: 'settings.method' });
|
||||
const isSSWith2022 = isSS2022({
|
||||
@@ -295,7 +299,10 @@ export default function InboundFormModal({
|
||||
streamSettings: { network },
|
||||
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
|
||||
});
|
||||
const hasVlessDecryption =
|
||||
protocol === Protocols.VLESS && vlessDecryption !== '' && vlessDecryption !== 'none';
|
||||
const isFallbackHost =
|
||||
!hasVlessDecryption &&
|
||||
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
(security === 'tls' || security === 'reality');
|
||||
@@ -437,6 +444,7 @@ export default function InboundFormModal({
|
||||
? withoutClients(rawInboundToFormValues(dbInbound))
|
||||
: buildAddModeValues();
|
||||
methods.reset(initial);
|
||||
savedInboundId.current = null;
|
||||
setScanResult(null);
|
||||
setActiveTab('basic');
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
@@ -573,19 +581,46 @@ export default function InboundFormModal({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
if (!fallbacksLoaded) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
if (!isFallbackHost && fallbacks.length > 0) {
|
||||
setActiveTab('protocol');
|
||||
messageApi.error(
|
||||
t(
|
||||
hasVlessDecryption
|
||||
? 'pages.inbounds.fallbacks.decryptionConflict'
|
||||
: 'pages.inbounds.fallbacks.needsTls',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
|
||||
const url =
|
||||
mode === 'edit' && dbInbound
|
||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
||||
: '/panel/api/inbounds/add';
|
||||
// Persist an explicit removal before the backend checks the new decryption setting.
|
||||
if (!isFallbackHost && hasStoredFallbacks && dbInbound) {
|
||||
if (!(await saveFallbacks(dbInbound.id))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existingId = mode === 'edit' ? dbInbound?.id : savedInboundId.current;
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: !!existingId });
|
||||
const url = existingId
|
||||
? `/panel/api/inbounds/update/${existingId}`
|
||||
: '/panel/api/inbounds/add';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
if (isFallbackHost) {
|
||||
const obj = msg.obj as { id?: number; Id?: number } | null;
|
||||
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
|
||||
if (masterId) await saveFallbacks(masterId);
|
||||
const masterId = existingId ?? obj?.id ?? obj?.Id ?? 0;
|
||||
if (masterId) savedInboundId.current = masterId;
|
||||
if (masterId && !(await saveFallbacks(masterId))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -783,6 +818,7 @@ export default function InboundFormModal({
|
||||
|
||||
const fallbacksCard = (
|
||||
<FallbacksCard
|
||||
canAdd={isFallbackHost && fallbacksLoaded}
|
||||
fallbacks={fallbacks}
|
||||
fallbackChildOptions={fallbackChildOptions}
|
||||
addFallback={addFallback}
|
||||
@@ -834,9 +870,18 @@ export default function InboundFormModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFallbackHost && fallbacksCard}
|
||||
{(isFallbackHost || fallbacks.length > 0) && fallbacksCard}
|
||||
{hasVlessDecryption && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.inbounds.fallbacks.decryptionConflict')}
|
||||
/>
|
||||
)}
|
||||
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
!hasVlessDecryption &&
|
||||
!isFallbackHost && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
@@ -1175,7 +1220,9 @@ export default function InboundFormModal({
|
||||
Protocols.TUIC,
|
||||
Protocols.MASQUE,
|
||||
] as string[]
|
||||
).includes(protocol) || isFallbackHost
|
||||
).includes(protocol) ||
|
||||
isFallbackHost ||
|
||||
fallbacks.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'protocol',
|
||||
|
||||
@@ -10,6 +10,8 @@ import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
|
||||
const fallbackKeyRef = useRef(0);
|
||||
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
|
||||
const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false);
|
||||
const [fallbacksLoaded, setFallbacksLoaded] = useState(false);
|
||||
|
||||
const fallbackChildOptions = (dbInbounds || [])
|
||||
.filter((ib) => ib.id !== dbInbound?.id)
|
||||
@@ -19,7 +21,10 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
}));
|
||||
|
||||
const loadFallbacks = async (masterId: number | null) => {
|
||||
setFallbacksLoaded(false);
|
||||
setHasStoredFallbacks(false);
|
||||
if (!masterId) {
|
||||
setFallbacksLoaded(true);
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
@@ -28,6 +33,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
setFallbacksLoaded(true);
|
||||
setHasStoredFallbacks(msg.obj.length > 0);
|
||||
setFallbacks(
|
||||
(
|
||||
msg.obj as {
|
||||
@@ -68,6 +75,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0);
|
||||
return !!msg?.success;
|
||||
};
|
||||
|
||||
@@ -181,6 +189,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
|
||||
return {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
|
||||
@@ -99,7 +99,7 @@ function cloneLikeVlessInbound(target: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderCloneLikeEdit(dbInbound: DBInbound) {
|
||||
async function renderCloneLikeEdit(dbInbound: DBInbound) {
|
||||
renderWithProviders(
|
||||
<InboundFormModal
|
||||
open
|
||||
@@ -111,6 +111,7 @@ function renderCloneLikeEdit(dbInbound: DBInbound) {
|
||||
onSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
describe('InboundFormModal', () => {
|
||||
@@ -278,7 +279,7 @@ describe('InboundFormModal', () => {
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
messageError.mockClear();
|
||||
renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
|
||||
|
||||
fireEvent.click(primaryButton());
|
||||
|
||||
@@ -323,7 +324,7 @@ describe('InboundFormModal', () => {
|
||||
it('submits a valid clone-like Reality inbound', async () => {
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
|
||||
fireEvent.click(primaryButton());
|
||||
|
||||
@@ -340,7 +341,7 @@ describe('InboundFormModal', () => {
|
||||
it('switches a REALITY inbound to an XDRIVE stream with security none', async () => {
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
|
||||
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
|
||||
@@ -448,7 +449,7 @@ describe('InboundFormModal', () => {
|
||||
shareAddrStrategy: 'listen',
|
||||
shareAddr: '',
|
||||
});
|
||||
renderCloneLikeEdit(hysteria);
|
||||
await renderCloneLikeEdit(hysteria);
|
||||
// Form watches flush in a macrotask; save only once the hop fields have emitted.
|
||||
await screen.findByDisplayValue('20000-30000');
|
||||
await act(async () => {
|
||||
@@ -501,7 +502,7 @@ describe('InboundFormModal', () => {
|
||||
shareAddrStrategy: 'listen',
|
||||
shareAddr: '',
|
||||
});
|
||||
renderCloneLikeEdit(hysteria);
|
||||
await renderCloneLikeEdit(hysteria);
|
||||
await screen.findByText('geoip:cn');
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -541,7 +542,7 @@ describe('InboundFormModal', () => {
|
||||
shareAddrStrategy: 'listen',
|
||||
shareAddr: '',
|
||||
});
|
||||
renderCloneLikeEdit(tun);
|
||||
await renderCloneLikeEdit(tun);
|
||||
|
||||
fireEvent.change(document.getElementById('tun-desc')!, { target: { value: 'MyTun' } });
|
||||
fireEvent.click(document.getElementById('tun-auto-dns-gateway')!);
|
||||
@@ -571,7 +572,7 @@ describe('InboundFormModal', () => {
|
||||
clients: [{ email: 'legacy', id: '' }],
|
||||
},
|
||||
});
|
||||
renderCloneLikeEdit(legacy);
|
||||
await renderCloneLikeEdit(legacy);
|
||||
|
||||
fireEvent.click(primaryButton());
|
||||
|
||||
@@ -580,11 +581,132 @@ describe('InboundFormModal', () => {
|
||||
expect(JSON.parse(payload.settings)).not.toHaveProperty('clients');
|
||||
});
|
||||
|
||||
it('offers the enable switch when adding an inbound but not when editing one', () => {
|
||||
it('offers the enable switch when adding an inbound but not when editing one', async () => {
|
||||
renderModal();
|
||||
expect(document.getElementById('inbound-enable')).not.toBeNull();
|
||||
cleanup();
|
||||
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
expect(document.getElementById('inbound-enable')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VLESS fallback safety', () => {
|
||||
it('disables adding fallbacks with server decryption and keeps removal available', async () => {
|
||||
vi.mocked(HttpUtil.get).mockResolvedValueOnce({
|
||||
success: true,
|
||||
msg: '',
|
||||
obj: [{ dest: '8080' }],
|
||||
});
|
||||
const inbound = cloneLikeVlessInbound('example.com:443');
|
||||
inbound.settings = {
|
||||
clients: [],
|
||||
decryption: 'mlkem768x25519plus.native.600s.key',
|
||||
encryption: 'none',
|
||||
};
|
||||
await renderCloneLikeEdit(inbound);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('8080')).toBeTruthy());
|
||||
expect(
|
||||
(screen.getByRole('button', { name: /Add fallback$/i }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Remove and save fallback rules before enabling VLESS decryption/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
it('uses server decryption rather than outbound encryption for eligibility', async () => {
|
||||
const inbound = cloneLikeVlessInbound('example.com:443');
|
||||
inbound.settings = { clients: [], decryption: 'none', encryption: 'link-only-key' };
|
||||
await renderCloneLikeEdit(inbound);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole('button', { name: /Add fallback$/i }) as HTMLButtonElement).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it('retains unsaved fallback rows when decryption becomes incompatible', async () => {
|
||||
messageError.mockClear();
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add fallback$/i }));
|
||||
fireEvent.change(screen.getByLabelText('Dest'), { target: { value: '8080' } });
|
||||
fireEvent.change(screen.getByLabelText('Decryption'), {
|
||||
target: { value: 'mlkem768x25519plus.native.600s.key' },
|
||||
});
|
||||
fireEvent.click(primaryButton());
|
||||
await waitFor(() =>
|
||||
expect(messageError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Remove and save fallback rules'),
|
||||
),
|
||||
);
|
||||
expect(screen.getByDisplayValue('8080')).toBeTruthy();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes legacy managed rules before saving active decryption', async () => {
|
||||
vi.mocked(HttpUtil.get).mockResolvedValueOnce({
|
||||
success: true,
|
||||
msg: '',
|
||||
obj: [{ dest: '8080' }],
|
||||
});
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
const inbound = cloneLikeVlessInbound('example.com:443');
|
||||
inbound.settings = {
|
||||
clients: [],
|
||||
decryption: 'mlkem768x25519plus.native.600s.key',
|
||||
encryption: 'none',
|
||||
};
|
||||
await renderCloneLikeEdit(inbound);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('8080')).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
fireEvent.click(primaryButton());
|
||||
await waitFor(() =>
|
||||
expect(post.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/panel/api/inbounds/42/fallbacks',
|
||||
'/panel/api/inbounds/update/42',
|
||||
]),
|
||||
);
|
||||
expect(post.mock.calls[0][1]).toEqual({ fallbacks: [] });
|
||||
});
|
||||
|
||||
it('keeps the draft open when saving fallback rules fails', async () => {
|
||||
messageError.mockClear();
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post
|
||||
.mockResolvedValueOnce({ success: true, msg: '', obj: { id: 42 } })
|
||||
.mockResolvedValueOnce({ success: false, msg: 'save failed', obj: null });
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const inbound = cloneLikeVlessInbound('example.com:443');
|
||||
renderWithProviders(
|
||||
<InboundFormModal
|
||||
open
|
||||
mode="edit"
|
||||
dbInbound={inbound}
|
||||
dbInbounds={[inbound]}
|
||||
availableNodes={[]}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add fallback$/i }));
|
||||
fireEvent.change(screen.getByLabelText('Dest'), { target: { value: '8080' } });
|
||||
fireEvent.click(primaryButton());
|
||||
await waitFor(() =>
|
||||
expect(messageError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not load or save fallback rules'),
|
||||
),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue('8080')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,9 @@ import { HttpUtil, Msg } from '@/utils';
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
||||
// Array-returning endpoints must resolve to [] so Zod list schemas validate.
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||
const isList = url.includes('/panel/api/inbounds/options') || url.includes('/panel/api/hosts/');
|
||||
const isList =
|
||||
url.includes('/panel/api/inbounds/options') ||
|
||||
url.includes('/panel/api/hosts/') ||
|
||||
url.endsWith('/fallbacks');
|
||||
return new Msg(true, '', isList ? [] : {});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user