inbound-form-modal.test.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import { describe, it, expect, onTestFinished, vi } from 'vitest';
  2. import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
  3. import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
  4. import { DBInbound } from '@/models/dbinbound';
  5. import { ThemeProvider } from '@/hooks/useTheme';
  6. import { HttpUtil } from '@/utils';
  7. import {
  8. renderWithProviders,
  9. fieldLabels,
  10. listSelectOptions,
  11. chooseSelectOption,
  12. } from './test-utils';
  13. const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
  14. vi.mock('antd', async (importOriginal) => {
  15. const actual = await importOriginal<typeof import('antd')>();
  16. return {
  17. ...actual,
  18. message: {
  19. ...actual.message,
  20. useMessage: () => [{ error: messageError }, null],
  21. },
  22. };
  23. });
  24. function renderModal() {
  25. return renderWithProviders(
  26. <InboundFormModal
  27. open
  28. mode="add"
  29. dbInbound={null}
  30. dbInbounds={[]}
  31. availableNodes={[]}
  32. onClose={() => {}}
  33. onSaved={() => {}}
  34. />,
  35. );
  36. }
  37. function primaryButton(): HTMLElement {
  38. const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
  39. if (!button) throw new Error('Primary modal button not found');
  40. return button as HTMLElement;
  41. }
  42. function cloneLikeVlessInbound(target: string) {
  43. return new DBInbound({
  44. id: 42,
  45. port: 41234,
  46. listen: '',
  47. protocol: 'vless',
  48. remark: 'source clone',
  49. enable: false,
  50. settings: {
  51. clients: [],
  52. decryption: 'none',
  53. encryption: 'none',
  54. fallbacks: [],
  55. },
  56. streamSettings: {
  57. network: 'tcp',
  58. security: 'reality',
  59. tcpSettings: { header: { type: 'none' } },
  60. realitySettings: {
  61. target,
  62. serverNames: ['example.com'],
  63. privateKey: 'test-private-key',
  64. shortIds: ['abcd'],
  65. settings: {
  66. publicKey: 'test-public-key',
  67. fingerprint: 'chrome',
  68. spiderX: '/',
  69. },
  70. },
  71. },
  72. sniffing: { enabled: false },
  73. nodeId: null,
  74. shareAddrStrategy: 'listen',
  75. shareAddr: '',
  76. });
  77. }
  78. function renderCloneLikeEdit(dbInbound: DBInbound) {
  79. renderWithProviders(
  80. <InboundFormModal
  81. open
  82. mode="edit"
  83. dbInbound={dbInbound}
  84. dbInbounds={[dbInbound]}
  85. availableNodes={[]}
  86. onClose={() => {}}
  87. onSaved={() => {}}
  88. />,
  89. );
  90. }
  91. describe('InboundFormModal', () => {
  92. it('renders add mode without crashing', () => {
  93. renderModal();
  94. expect(document.querySelector('.ant-modal')).toBeTruthy();
  95. expect(fieldLabels().length).toBeGreaterThan(0);
  96. });
  97. it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
  98. renderModal();
  99. const protocols = listSelectOptions('protocol');
  100. expect(protocols.length).toBeGreaterThan(3);
  101. const labelsByProto: Record<string, string[]> = {};
  102. for (const proto of protocols) {
  103. chooseSelectOption('protocol', proto);
  104. // Flush antd Form.useWatch('protocol') before reading — without it every iteration
  105. // sees the same pre-update DOM and the loop asserts nothing (the original bug here).
  106. await act(async () => {
  107. await new Promise((r) => setTimeout(r, 0));
  108. });
  109. labelsByProto[proto] = fieldLabels();
  110. }
  111. // The loop must actually exercise protocol-specific rendering: distinct protocols
  112. // must yield distinct field sets (a vacuous loop makes them all identical).
  113. const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
  114. expect(distinctShapes.size).toBeGreaterThan(1);
  115. // Spot-check a protocol-distinguishing field that must appear after the switch.
  116. if (labelsByProto.shadowsocks) {
  117. expect(labelsByProto.shadowsocks).toContain('Encryption method');
  118. }
  119. }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
  120. it('preserves custom share address strategy when editing a local inbound', async () => {
  121. renderWithProviders(
  122. <InboundFormModal
  123. open
  124. mode="edit"
  125. dbInbound={
  126. new DBInbound({
  127. id: 1,
  128. port: 12345,
  129. listen: '',
  130. protocol: 'shadowsocks',
  131. remark: 'edge',
  132. enable: true,
  133. settings: {
  134. method: '2022-blake3-aes-128-gcm',
  135. password: 'server-password',
  136. network: 'tcp,udp',
  137. clients: [],
  138. },
  139. streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
  140. sniffing: { enabled: false },
  141. nodeId: null,
  142. shareAddrStrategy: 'custom',
  143. shareAddr: 'edge.example.test',
  144. })
  145. }
  146. dbInbounds={[]}
  147. availableNodes={[]}
  148. onClose={() => {}}
  149. onSaved={() => {}}
  150. />,
  151. );
  152. const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
  153. expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
  154. });
  155. it('uses Hosts instead of showing the custom share address fields for MTProto', async () => {
  156. renderWithProviders(
  157. <InboundFormModal
  158. open
  159. mode="edit"
  160. dbInbound={
  161. new DBInbound({
  162. id: 2,
  163. port: 4060,
  164. listen: '',
  165. protocol: 'mtproto',
  166. remark: 'proxy',
  167. enable: true,
  168. settings: { clients: [] },
  169. streamSettings: {},
  170. sniffing: { enabled: false },
  171. nodeId: null,
  172. shareAddrStrategy: 'custom',
  173. shareAddr: 'proxy.example.test',
  174. })
  175. }
  176. dbInbounds={[]}
  177. availableNodes={[]}
  178. onClose={() => {}}
  179. onSaved={() => {}}
  180. />,
  181. );
  182. await act(async () => {
  183. await new Promise((resolve) => setTimeout(resolve, 0));
  184. });
  185. expect(fieldLabels()).not.toContain('Share address strategy');
  186. expect(screen.queryByDisplayValue('proxy.example.test')).toBeNull();
  187. });
  188. it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
  189. const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
  190. const buildInbound = () =>
  191. new DBInbound({
  192. id: 1,
  193. port: 23456,
  194. listen: '',
  195. protocol: 'vless',
  196. remark: 'noded',
  197. enable: true,
  198. settings: { clients: [] },
  199. streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
  200. sniffing: { enabled: false },
  201. nodeId: 1,
  202. shareAddrStrategy: 'node',
  203. });
  204. const flush = async () => {
  205. await act(async () => {
  206. await new Promise((r) => setTimeout(r, 0));
  207. });
  208. };
  209. const strategyItem = (title: string) =>
  210. document.querySelector(`.ant-select-content[title="${title}"]`);
  211. const modal = (nodes: never[], fetched: boolean) => (
  212. <ThemeProvider>
  213. <InboundFormModal
  214. open
  215. mode="edit"
  216. dbInbound={buildInbound()}
  217. dbInbounds={[]}
  218. availableNodes={nodes}
  219. availableNodesFetched={fetched}
  220. onClose={() => {}}
  221. onSaved={() => {}}
  222. />
  223. </ThemeProvider>
  224. );
  225. // Baseline: nodes already loaded, so the node option is offered and selected.
  226. render(modal([node], true));
  227. await flush();
  228. expect(strategyItem('Node address')).toBeTruthy();
  229. cleanup();
  230. // Race: the modal mounts before /nodes/list resolves (empty placeholder),
  231. // then nodes arrive. The persisted 'node' strategy must survive the gap and
  232. // stay selected once the option reappears — not silently revert to listen.
  233. const { rerender } = render(modal([], false));
  234. await flush();
  235. rerender(modal([node], true));
  236. await flush();
  237. expect(strategyItem('Node address')).toBeTruthy();
  238. expect(strategyItem('Inbound listen')).toBeFalsy();
  239. });
  240. it('surfaces a Reality validation error and switches to its tab', async () => {
  241. const post = vi.mocked(HttpUtil.post);
  242. post.mockClear();
  243. messageError.mockClear();
  244. renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
  245. fireEvent.click(primaryButton());
  246. await waitFor(() => {
  247. const securityTab = screen.getByRole('tab', { name: 'Security' });
  248. expect(securityTab.getAttribute('aria-selected')).toBe('true');
  249. });
  250. expect(messageError).toHaveBeenCalledWith(
  251. expect.stringContaining('REALITY target must include a port'),
  252. );
  253. expect(post).not.toHaveBeenCalled();
  254. });
  255. it('blocks adding TLS without a certificate and directs the user to Security', async () => {
  256. const post = vi.mocked(HttpUtil.post);
  257. post.mockClear();
  258. messageError.mockClear();
  259. const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
  260. onTestFinished(() => consoleError.mockRestore());
  261. renderModal();
  262. fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
  263. fireEvent.click(screen.getByRole('radio', { name: 'TLS' }));
  264. fireEvent.click(screen.getByRole('tab', { name: 'Basics' }));
  265. fireEvent.click(primaryButton());
  266. await waitFor(() => {
  267. expect(screen.getByRole('tab', { name: 'Security' }).getAttribute('aria-selected')).toBe(
  268. 'true',
  269. );
  270. expect(messageError).toHaveBeenCalledWith(
  271. expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
  272. );
  273. });
  274. expect(consoleError).toHaveBeenCalledWith('[InboundFormModal] schema validation failed:', [
  275. 'TLS certificate 1: Import a TLS certificate or enter its file path before saving',
  276. 'TLS certificate 1: Import the TLS private key or enter its file path before saving',
  277. ]);
  278. expect(post).not.toHaveBeenCalled();
  279. });
  280. it('submits a valid clone-like Reality inbound', async () => {
  281. const post = vi.mocked(HttpUtil.post);
  282. post.mockClear();
  283. renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
  284. fireEvent.click(primaryButton());
  285. await waitFor(() => {
  286. expect(post).toHaveBeenCalledWith(
  287. '/panel/api/inbounds/update/42',
  288. expect.objectContaining({ enable: false, port: 41234, protocol: 'vless' }),
  289. );
  290. });
  291. });
  292. });