pia-modal.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import { useState } from 'react';
  2. import { describe, expect, it, vi } from 'vitest';
  3. import { act, fireEvent, screen, waitFor } from '@testing-library/react';
  4. import PiaModal from '@/pages/xray/overrides/PiaModal';
  5. import { HttpUtil, Msg } from '@/utils';
  6. import { renderWithProviders } from './test-utils';
  7. const ACCOUNT = { username: 'p1234567', accountHint: 'p*****67' };
  8. const COUNTRIES = [{ code: 'US' }, { code: 'DE' }, { code: 'AL' }];
  9. const SERVERS = {
  10. regions: [
  11. { id: 'us-east', name: 'US East' },
  12. { id: 'us-west', name: 'US West' },
  13. { id: 'al', name: 'Albania' },
  14. ],
  15. servers: [
  16. { hostname: 'useast1', ip: '198.51.100.10', regionId: 'us-east', regionName: 'US East' },
  17. { hostname: 'uswest1', ip: '198.51.100.30', regionId: 'us-west', regionName: 'US West' },
  18. {
  19. hostname: 'Server-12406-1a',
  20. ip: '198.51.100.40',
  21. regionId: 'al',
  22. regionName: 'Albania',
  23. },
  24. ],
  25. };
  26. function piaApiPost(url: string, data?: unknown) {
  27. if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
  28. if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
  29. if (url === '/panel/api/xray/pia/servers') {
  30. const code = (data as { countryCode?: string } | undefined)?.countryCode?.toUpperCase();
  31. if (code === 'AL') {
  32. return new Msg(true, '', {
  33. regions: [SERVERS.regions[2]],
  34. servers: [SERVERS.servers[2]],
  35. });
  36. }
  37. if (code === 'US') {
  38. return new Msg(true, '', {
  39. regions: SERVERS.regions.slice(0, 2),
  40. servers: SERVERS.servers.slice(0, 2),
  41. });
  42. }
  43. return new Msg(true, '', { regions: [], servers: [] });
  44. }
  45. if (url === '/panel/api/xray/pia/addKey') {
  46. const hostname = (data as { hostname?: string } | undefined)?.hostname;
  47. if (hostname === 'uswest1') {
  48. return new Msg(true, '', {
  49. tag: 'pia-us-west-uswest1',
  50. hostname: 'uswest1',
  51. secretKey: 'secret-west',
  52. address: '10.8.0.2/32',
  53. publicKey: 'pubkey-west',
  54. endpoint: '198.51.100.30:1337',
  55. });
  56. }
  57. if (hostname === 'Server-12406-1a' || hostname === 'pia-al-server-12406-1a') {
  58. return new Msg(true, '', {
  59. tag: 'pia-al-server-12406-1a',
  60. hostname: 'Server-12406-1a',
  61. secretKey: 'secret-al',
  62. address: '10.8.0.3/32',
  63. publicKey: 'pubkey-al',
  64. endpoint: '198.51.100.40:1337',
  65. });
  66. }
  67. if (hostname === 'useast1' || hostname === 'pia-us-east-useast1') {
  68. return new Msg(true, '', {
  69. tag: 'pia-us-east-useast1',
  70. hostname: 'useast1',
  71. secretKey: 'secret',
  72. address: '10.8.0.1/32',
  73. publicKey: 'pubkey',
  74. endpoint: '198.51.100.10:1337',
  75. });
  76. }
  77. return new Msg(false, `Unexpected addKey hostname ${hostname}`, null);
  78. }
  79. return new Msg(false, `Unexpected POST ${url}`, null);
  80. }
  81. function mockPiaApi() {
  82. vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) =>
  83. piaApiPost(url, data),
  84. );
  85. }
  86. function visibleOptions(): HTMLElement[] {
  87. return Array.from(
  88. document.querySelectorAll<HTMLElement>(
  89. '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
  90. ),
  91. );
  92. }
  93. async function chooseOption(testId: string, labelPart: string) {
  94. const node = screen.getByTestId(testId);
  95. const select = node.closest('.ant-select') ?? node;
  96. const selector = select.querySelector('.ant-select-selector') ?? select;
  97. fireEvent.mouseDown(selector);
  98. await waitFor(() => expect(visibleOptions().length).toBeGreaterThan(0));
  99. const option = visibleOptions().find((item) =>
  100. (item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
  101. );
  102. if (!option) throw new Error(`Missing option containing ${labelPart}`);
  103. // Picking a country fetches its servers; let that settle inside act().
  104. await act(async () => {
  105. fireEvent.click(option);
  106. });
  107. }
  108. async function clickAddOutbound() {
  109. const addButton = await waitFor(() => {
  110. const btn = screen.getByRole('button', { name: /Add outbound/ });
  111. if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
  112. return btn;
  113. });
  114. // Adding provisions a key over HTTP; let that settle inside act().
  115. await act(async () => {
  116. fireEvent.click(addButton);
  117. });
  118. }
  119. function expectPiaOutbound(
  120. outbound: Record<string, unknown>,
  121. want: {
  122. tag: string;
  123. hostname: string;
  124. secretKey: string;
  125. address: string;
  126. publicKey: string;
  127. endpoint: string;
  128. },
  129. ) {
  130. expect(outbound).toMatchObject({
  131. tag: want.tag,
  132. piaHostname: want.hostname,
  133. protocol: 'wireguard',
  134. settings: {
  135. secretKey: want.secretKey,
  136. address: [want.address],
  137. mtu: 1420,
  138. noKernelTun: true,
  139. peers: [
  140. {
  141. publicKey: want.publicKey,
  142. endpoint: want.endpoint,
  143. allowedIPs: ['0.0.0.0/0'],
  144. keepAlive: 25,
  145. },
  146. ],
  147. },
  148. });
  149. }
  150. function PiaHarness({ onAdded }: { onAdded?: (outbound: Record<string, unknown>) => void }) {
  151. const [outbounds, setOutbounds] = useState<Record<string, unknown>[]>([]);
  152. return (
  153. <PiaModal
  154. open
  155. templateSettings={{ outbounds }}
  156. onClose={vi.fn()}
  157. onAddOutbound={(outbound) => {
  158. onAdded?.(outbound);
  159. setOutbounds((prev) => [...prev, outbound]);
  160. }}
  161. onResetOutbound={vi.fn()}
  162. />
  163. );
  164. }
  165. describe('PIA modal', () => {
  166. it('shows username and password when not signed in', async () => {
  167. vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
  168. if (url === '/panel/api/xray/pia/data') return new Msg(true, '', null);
  169. return new Msg(false, `Unexpected POST ${url}`, null);
  170. });
  171. renderWithProviders(
  172. <PiaModal
  173. open
  174. templateSettings={{ outbounds: [] }}
  175. onClose={vi.fn()}
  176. onAddOutbound={vi.fn()}
  177. onResetOutbound={vi.fn()}
  178. />,
  179. );
  180. await waitFor(() => expect(screen.getByPlaceholderText('PIA username')).toBeTruthy());
  181. expect(screen.getByPlaceholderText('PIA password')).toBeTruthy();
  182. expect(screen.getByRole('dialog', { name: 'Private Internet Access WireGuard' })).toBeTruthy();
  183. expect(screen.getByRole('button', { name: /Log In/ })).toBeTruthy();
  184. expect(screen.queryByTestId('pia-country-select')).toBeNull();
  185. });
  186. it('adds two WireGuard outbounds for different servers', async () => {
  187. mockPiaApi();
  188. const added: Record<string, unknown>[] = [];
  189. renderWithProviders(<PiaHarness onAdded={(outbound) => added.push(outbound)} />);
  190. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  191. await chooseOption('pia-country-select', 'US');
  192. await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
  193. await clickAddOutbound();
  194. await waitFor(() => expect(screen.getByTestId('pia-added-table')).toBeTruthy());
  195. expect(screen.getByText('pia-us-east-useast1')).toBeTruthy();
  196. expect(screen.getByRole('button', { name: /Add outbound/ })).toBeTruthy();
  197. await chooseOption('pia-server-select', 'uswest1');
  198. await clickAddOutbound();
  199. await waitFor(() => expect(screen.getByText('pia-us-west-uswest1')).toBeTruthy());
  200. expect(added).toHaveLength(2);
  201. expectPiaOutbound(added[0], {
  202. tag: 'pia-us-east-useast1',
  203. hostname: 'useast1',
  204. secretKey: 'secret',
  205. address: '10.8.0.1/32',
  206. publicKey: 'pubkey',
  207. endpoint: '198.51.100.10:1337',
  208. });
  209. expectPiaOutbound(added[1], {
  210. tag: 'pia-us-west-uswest1',
  211. hostname: 'uswest1',
  212. secretKey: 'secret-west',
  213. address: '10.8.0.2/32',
  214. publicKey: 'pubkey-west',
  215. endpoint: '198.51.100.30:1337',
  216. });
  217. });
  218. it('lists every server again when All regions is chosen after a region', async () => {
  219. mockPiaApi();
  220. renderWithProviders(<PiaHarness />);
  221. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  222. await chooseOption('pia-country-select', 'US');
  223. await chooseOption('pia-region-select', 'US West');
  224. await chooseOption('pia-region-select', 'All regions');
  225. const serverNode = screen.getByTestId('pia-server-select');
  226. const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
  227. fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
  228. await waitFor(() =>
  229. expect(
  230. visibleOptions().filter((option) => /useast1|uswest1/.test(option.textContent ?? '')),
  231. ).toHaveLength(2),
  232. );
  233. });
  234. it('disables Add when the selected server is already in the list', async () => {
  235. mockPiaApi();
  236. renderWithProviders(
  237. <PiaModal
  238. open
  239. templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
  240. onClose={vi.fn()}
  241. onAddOutbound={vi.fn()}
  242. onResetOutbound={vi.fn()}
  243. />,
  244. );
  245. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  246. await chooseOption('pia-country-select', 'US');
  247. await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
  248. await waitFor(() => {
  249. const btn = screen.getByRole('button', { name: /Add outbound/ });
  250. expect((btn as HTMLButtonElement).disabled).toBe(true);
  251. });
  252. expect(screen.getByText(/Use Reset to renew its key/)).toBeTruthy();
  253. });
  254. it('resets an existing PIA outbound in place', async () => {
  255. const onResetOutbound = vi.fn();
  256. mockPiaApi();
  257. renderWithProviders(
  258. <PiaModal
  259. open
  260. templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
  261. onClose={vi.fn()}
  262. onAddOutbound={vi.fn()}
  263. onResetOutbound={onResetOutbound}
  264. />,
  265. );
  266. await waitFor(() => expect(screen.getByTestId('pia-reset-0')).toBeTruthy());
  267. fireEvent.click(screen.getByTestId('pia-reset-0'));
  268. await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
  269. const payload = onResetOutbound.mock.calls[0][0] as {
  270. index: number;
  271. outbound: { tag: string; piaHostname: string; settings: { secretKey: string } };
  272. oldTag?: string;
  273. newTag: string;
  274. };
  275. expect(payload.index).toBe(0);
  276. expect(payload.oldTag).toBe('pia-us-east-useast1');
  277. expect(payload.newTag).toBe('pia-us-east-useast1');
  278. expectPiaOutbound(payload.outbound as Record<string, unknown>, {
  279. tag: 'pia-us-east-useast1',
  280. hostname: 'useast1',
  281. secretKey: 'secret',
  282. address: '10.8.0.1/32',
  283. publicKey: 'pubkey',
  284. endpoint: '198.51.100.10:1337',
  285. });
  286. });
  287. it('disables Add for a hyphenated cn when only the tag remains', async () => {
  288. mockPiaApi();
  289. renderWithProviders(
  290. <PiaModal
  291. open
  292. templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
  293. onClose={vi.fn()}
  294. onAddOutbound={vi.fn()}
  295. onResetOutbound={vi.fn()}
  296. />,
  297. );
  298. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  299. await chooseOption('pia-country-select', 'AL');
  300. await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
  301. await waitFor(() => {
  302. const btn = screen.getByRole('button', { name: /Add outbound/ });
  303. expect((btn as HTMLButtonElement).disabled).toBe(true);
  304. });
  305. });
  306. it('disables Add when only the outbound tag remains', async () => {
  307. mockPiaApi();
  308. renderWithProviders(
  309. <PiaModal
  310. open
  311. templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1' }] }}
  312. onClose={vi.fn()}
  313. onAddOutbound={vi.fn()}
  314. onResetOutbound={vi.fn()}
  315. />,
  316. );
  317. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  318. await chooseOption('pia-country-select', 'US');
  319. await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
  320. await waitFor(() => {
  321. const btn = screen.getByRole('button', { name: /Add outbound/ });
  322. expect((btn as HTMLButtonElement).disabled).toBe(true);
  323. });
  324. });
  325. it('resets from the outbound tag when piaHostname was stripped', async () => {
  326. const onResetOutbound = vi.fn();
  327. const posts: unknown[] = [];
  328. mockPiaApi();
  329. vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) => {
  330. if (url === '/panel/api/xray/pia/addKey') posts.push(data);
  331. return piaApiPost(url, data);
  332. });
  333. renderWithProviders(
  334. <PiaModal
  335. open
  336. templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
  337. onClose={vi.fn()}
  338. onAddOutbound={vi.fn()}
  339. onResetOutbound={onResetOutbound}
  340. />,
  341. );
  342. const reset = await waitFor(() => screen.getByTestId('pia-reset-0'));
  343. expect((reset as HTMLButtonElement).disabled).toBe(false);
  344. fireEvent.click(reset);
  345. await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
  346. expect(posts).toEqual([{ hostname: 'pia-al-server-12406-1a' }]);
  347. expectPiaOutbound(onResetOutbound.mock.calls[0][0].outbound as Record<string, unknown>, {
  348. tag: 'pia-al-server-12406-1a',
  349. hostname: 'Server-12406-1a',
  350. secretKey: 'secret-al',
  351. address: '10.8.0.3/32',
  352. publicKey: 'pubkey-al',
  353. endpoint: '198.51.100.40:1337',
  354. });
  355. });
  356. it('does not add an outbound when addKey omits WireGuard fields', async () => {
  357. const onAddOutbound = vi.fn();
  358. vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
  359. if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
  360. if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
  361. if (url === '/panel/api/xray/pia/servers') return new Msg(true, '', SERVERS);
  362. if (url === '/panel/api/xray/pia/addKey') {
  363. return new Msg(true, '', { tag: 'pia-us-east-useast1', hostname: 'useast1' });
  364. }
  365. return new Msg(false, `Unexpected POST ${url}`, null);
  366. });
  367. renderWithProviders(
  368. <PiaModal
  369. open
  370. templateSettings={{ outbounds: [] }}
  371. onClose={vi.fn()}
  372. onAddOutbound={onAddOutbound}
  373. onResetOutbound={vi.fn()}
  374. />,
  375. );
  376. await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
  377. await chooseOption('pia-country-select', 'US');
  378. await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
  379. await clickAddOutbound();
  380. await waitFor(() =>
  381. expect(screen.getByText('Could not build the PIA outbound. Try again.')).toBeTruthy(),
  382. );
  383. expect(onAddOutbound).not.toHaveBeenCalled();
  384. });
  385. });