Files
3x-ui/frontend/src/test/inbound-form-modal.test.tsx
T
MHSanaei 823db05966 fix(inbounds): keep the stored client list and enable on inbound save
Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.

For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.

The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.

Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
2026-09-28 13:23:48 +02:00

351 lines
11 KiB
TypeScript

import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { HttpUtil } from '@/utils';
import {
renderWithProviders,
fieldLabels,
listSelectOptions,
chooseSelectOption,
} from './test-utils';
const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
vi.mock('antd', async (importOriginal) => {
const actual = await importOriginal<typeof import('antd')>();
return {
...actual,
message: {
...actual.message,
useMessage: () => [{ error: messageError }, null],
},
};
});
function renderModal() {
return renderWithProviders(
<InboundFormModal
open
mode="add"
dbInbound={null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
}
function primaryButton(): HTMLElement {
const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!button) throw new Error('Primary modal button not found');
return button as HTMLElement;
}
function cloneLikeVlessInbound(target: string) {
return new DBInbound({
id: 42,
port: 41234,
listen: '',
protocol: 'vless',
remark: 'source clone',
enable: false,
settings: {
clients: [],
decryption: 'none',
encryption: 'none',
fallbacks: [],
},
streamSettings: {
network: 'tcp',
security: 'reality',
tcpSettings: { header: { type: 'none' } },
realitySettings: {
target,
serverNames: ['example.com'],
privateKey: 'test-private-key',
shortIds: ['abcd'],
settings: {
publicKey: 'test-public-key',
fingerprint: 'chrome',
spiderX: '/',
},
},
},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'listen',
shareAddr: '',
});
}
function renderCloneLikeEdit(dbInbound: DBInbound) {
renderWithProviders(
<InboundFormModal
open
mode="edit"
dbInbound={dbInbound}
dbInbounds={[dbInbound]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
}
describe('InboundFormModal', () => {
it('renders add mode without crashing', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal();
const protocols = listSelectOptions('protocol');
expect(protocols.length).toBeGreaterThan(3);
const labelsByProto: Record<string, string[]> = {};
for (const proto of protocols) {
chooseSelectOption('protocol', proto);
// Flush antd Form.useWatch('protocol') before reading — without it every iteration
// sees the same pre-update DOM and the loop asserts nothing (the original bug here).
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
labelsByProto[proto] = fieldLabels();
}
// The loop must actually exercise protocol-specific rendering: distinct protocols
// must yield distinct field sets (a vacuous loop makes them all identical).
const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch.
if (labelsByProto.shadowsocks) {
expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
it('preserves custom share address strategy when editing a local inbound', async () => {
renderWithProviders(
<InboundFormModal
open
mode="edit"
dbInbound={
new DBInbound({
id: 1,
port: 12345,
listen: '',
protocol: 'shadowsocks',
remark: 'edge',
enable: true,
settings: {
method: '2022-blake3-aes-128-gcm',
password: 'server-password',
network: 'tcp,udp',
clients: [],
},
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'custom',
shareAddr: 'edge.example.test',
})
}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
});
it('uses Hosts instead of showing the custom share address fields for MTProto', async () => {
renderWithProviders(
<InboundFormModal
open
mode="edit"
dbInbound={
new DBInbound({
id: 2,
port: 4060,
listen: '',
protocol: 'mtproto',
remark: 'proxy',
enable: true,
settings: { clients: [] },
streamSettings: {},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'custom',
shareAddr: 'proxy.example.test',
})
}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(fieldLabels()).not.toContain('Share address strategy');
expect(screen.queryByDisplayValue('proxy.example.test')).toBeNull();
});
it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
const buildInbound = () =>
new DBInbound({
id: 1,
port: 23456,
listen: '',
protocol: 'vless',
remark: 'noded',
enable: true,
settings: { clients: [] },
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
sniffing: { enabled: false },
nodeId: 1,
shareAddrStrategy: 'node',
});
const flush = async () => {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
};
const strategyItem = (title: string) =>
document.querySelector(`.ant-select-content[title="${title}"]`);
const modal = (nodes: never[], fetched: boolean) => (
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
);
// Baseline: nodes already loaded, so the node option is offered and selected.
render(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
cleanup();
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
// then nodes arrive. The persisted 'node' strategy must survive the gap and
// stay selected once the option reappears — not silently revert to listen.
const { rerender } = render(modal([], false));
await flush();
rerender(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
expect(strategyItem('Inbound listen')).toBeFalsy();
});
it('surfaces a Reality validation error and switches to its tab', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
messageError.mockClear();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
fireEvent.click(primaryButton());
await waitFor(() => {
const securityTab = screen.getByRole('tab', { name: 'Security' });
expect(securityTab.getAttribute('aria-selected')).toBe('true');
});
expect(messageError).toHaveBeenCalledWith(
expect.stringContaining('REALITY target must include a port'),
);
expect(post).not.toHaveBeenCalled();
});
it('blocks adding TLS without a certificate and directs the user to Security', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
messageError.mockClear();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
onTestFinished(() => consoleError.mockRestore());
renderModal();
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
fireEvent.click(screen.getByRole('radio', { name: 'TLS' }));
fireEvent.click(screen.getByRole('tab', { name: 'Basics' }));
fireEvent.click(primaryButton());
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Security' }).getAttribute('aria-selected')).toBe(
'true',
);
expect(messageError).toHaveBeenCalledWith(
expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
);
});
expect(consoleError).toHaveBeenCalledWith('[InboundFormModal] schema validation failed:', [
'TLS certificate 1: Import a TLS certificate or enter its file path before saving',
'TLS certificate 1: Import the TLS private key or enter its file path before saving',
]);
expect(post).not.toHaveBeenCalled();
});
it('submits a valid clone-like Reality inbound', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(primaryButton());
await waitFor(() => {
expect(post).toHaveBeenCalledWith(
'/panel/api/inbounds/update/42',
expect.objectContaining({ enable: false, port: 41234, protocol: 'vless' }),
);
});
});
// Clients and enable change through their own endpoints; the server keeps the
// stored ones, so the edit form must neither send nor validate its stale copy.
it('edit save neither sends nor validates the clients it loaded', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
const dbInbound = cloneLikeVlessInbound('example.com:443');
const legacy = new DBInbound({
...dbInbound,
settings: {
...(dbInbound.settings as Record<string, unknown>),
clients: [{ email: 'legacy', id: '' }],
},
});
renderCloneLikeEdit(legacy);
fireEvent.click(primaryButton());
await waitFor(() => expect(post).toHaveBeenCalled());
const payload = post.mock.calls[0][1] as { settings: string };
expect(JSON.parse(payload.settings)).not.toHaveProperty('clients');
});
it('offers the enable switch when adding an inbound but not when editing one', () => {
renderModal();
expect(document.getElementById('inbound-enable')).not.toBeNull();
cleanup();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
expect(document.getElementById('inbound-enable')).toBeNull();
});
});