http-init.test.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  2. type HttpModule = typeof import('@/api/http-init');
  3. const okEnvelope = (obj: unknown = {}): Response =>
  4. new Response(JSON.stringify({ success: true, msg: '', obj }), {
  5. status: 200,
  6. headers: { 'content-type': 'application/json' },
  7. });
  8. const csrfResponse = (token: string): Response =>
  9. new Response(JSON.stringify({ success: true, obj: token }), {
  10. status: 200,
  11. headers: { 'content-type': 'application/json' },
  12. });
  13. describe('http-init fetch wrapper', () => {
  14. let http: HttpModule;
  15. let fetchMock: ReturnType<typeof vi.fn>;
  16. let replaceMock: ReturnType<typeof vi.fn>;
  17. const initOf = (call = 0): RequestInit => fetchMock.mock.calls[call][1] as RequestInit;
  18. const urlOf = (call = 0): string => fetchMock.mock.calls[call][0] as string;
  19. const headersOf = (call = 0): Headers => initOf(call).headers as Headers;
  20. beforeEach(async () => {
  21. vi.resetModules();
  22. document.head.innerHTML = '';
  23. delete (window as { X_UI_BASE_PATH?: string }).X_UI_BASE_PATH;
  24. fetchMock = vi.fn();
  25. vi.stubGlobal('fetch', fetchMock);
  26. replaceMock = vi.fn();
  27. Object.defineProperty(window, 'location', {
  28. configurable: true,
  29. value: {
  30. replace: replaceMock,
  31. href: 'http://localhost/',
  32. origin: 'http://localhost',
  33. pathname: '/',
  34. },
  35. });
  36. http = await import('@/api/http-init');
  37. });
  38. afterEach(() => {
  39. vi.unstubAllGlobals();
  40. });
  41. it('form-encodes bodies and repeats array keys', async () => {
  42. document.head.innerHTML = '<meta name="csrf-token" content="tok">';
  43. http.setupHttp();
  44. fetchMock.mockResolvedValue(okEnvelope());
  45. await http.httpRequest('POST', '/panel/x', { a: 1, b: ['x', 'y'] });
  46. expect(initOf().body).toBe('a=1&b=x&b=y');
  47. expect(headersOf().get('content-type')).toBe(
  48. 'application/x-www-form-urlencoded; charset=UTF-8',
  49. );
  50. });
  51. it('JSON-encodes bodies when the caller declares application/json', async () => {
  52. document.head.innerHTML = '<meta name="csrf-token" content="tok">';
  53. http.setupHttp();
  54. fetchMock.mockResolvedValue(okEnvelope());
  55. await http.httpRequest(
  56. 'POST',
  57. '/panel/x',
  58. { a: 1 },
  59. { headers: { 'Content-Type': 'application/json' } },
  60. );
  61. expect(initOf().body).toBe(JSON.stringify({ a: 1 }));
  62. expect(headersOf().get('content-type')).toBe('application/json');
  63. });
  64. it('passes FormData through without a Content-Type header', async () => {
  65. document.head.innerHTML = '<meta name="csrf-token" content="tok">';
  66. http.setupHttp();
  67. fetchMock.mockResolvedValue(okEnvelope());
  68. const fd = new FormData();
  69. fd.append('db', 'contents');
  70. await http.httpRequest('POST', '/panel/import', fd, {
  71. headers: { 'Content-Type': 'multipart/form-data' },
  72. });
  73. expect(initOf().body).toBe(fd);
  74. expect(headersOf().has('content-type')).toBe(false);
  75. });
  76. it('attaches the CSRF token on POST and omits it on GET', async () => {
  77. document.head.innerHTML = '<meta name="csrf-token" content="tok">';
  78. http.setupHttp();
  79. fetchMock.mockImplementation(() => Promise.resolve(okEnvelope()));
  80. await http.httpRequest('POST', '/p', { a: 1 });
  81. expect(headersOf().get('X-CSRF-Token')).toBe('tok');
  82. expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
  83. fetchMock.mockClear();
  84. await http.httpRequest('GET', '/g');
  85. expect(headersOf().get('X-CSRF-Token')).toBeNull();
  86. expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
  87. });
  88. it('prepends the base path to request and csrf-token URLs', async () => {
  89. window.X_UI_BASE_PATH = '/xui';
  90. http.setupHttp();
  91. fetchMock.mockImplementation((url: string) =>
  92. Promise.resolve(url.endsWith('/csrf-token') ? csrfResponse('fresh') : okEnvelope()),
  93. );
  94. await http.httpRequest('POST', '/panel/api/x', { a: 1 });
  95. expect(urlOf(0)).toBe('/xui/csrf-token');
  96. expect(urlOf(1)).toBe('/xui/panel/api/x');
  97. });
  98. it('refreshes the token and retries once on 403', async () => {
  99. http.setupHttp();
  100. let dataCalls = 0;
  101. fetchMock.mockImplementation((url: string) => {
  102. if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse(`tok${dataCalls}`));
  103. dataCalls += 1;
  104. return Promise.resolve(dataCalls === 1 ? new Response('', { status: 403 }) : okEnvelope());
  105. });
  106. const resp = await http.httpRequest('POST', '/panel/api/x', { a: 1 });
  107. expect(resp.ok).toBe(true);
  108. expect(dataCalls).toBe(2);
  109. expect(headersOf(3).get('X-CSRF-Token')).toBe('tok1');
  110. });
  111. it('throws HttpError when the retried request is still 403', async () => {
  112. http.setupHttp();
  113. let dataCalls = 0;
  114. fetchMock.mockImplementation((url: string) => {
  115. if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse('tok'));
  116. dataCalls += 1;
  117. return Promise.resolve(new Response('', { status: 403 }));
  118. });
  119. await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(
  120. http.HttpError,
  121. );
  122. expect(dataCalls).toBe(2);
  123. });
  124. it('redirects once on 401 and never settles', async () => {
  125. window.X_UI_BASE_PATH = '/xui';
  126. document.head.innerHTML = '<meta name="csrf-token" content="tok">';
  127. http.setupHttp();
  128. fetchMock.mockResolvedValue(new Response('', { status: 401 }));
  129. const pending = Symbol('pending');
  130. const first = await Promise.race([
  131. http.httpRequest('POST', '/p', { a: 1 }),
  132. new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
  133. ]);
  134. expect(first).toBe(pending);
  135. expect(replaceMock).toHaveBeenCalledTimes(1);
  136. expect(replaceMock).toHaveBeenCalledWith('/xui');
  137. const second = await Promise.race([
  138. http.httpRequest('POST', '/p', { a: 1 }),
  139. new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
  140. ]);
  141. expect(second).toBe(pending);
  142. expect(replaceMock).toHaveBeenCalledTimes(1);
  143. });
  144. it('parses empty, 204, non-JSON, and malformed bodies tolerantly', async () => {
  145. http.setupHttp();
  146. fetchMock.mockResolvedValueOnce(new Response('', { status: 200 }));
  147. expect((await http.httpRequest('GET', '/a')).data).toBe('');
  148. fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
  149. expect((await http.httpRequest('GET', '/b')).data).toBe('');
  150. fetchMock.mockResolvedValueOnce(
  151. new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }),
  152. );
  153. expect((await http.httpRequest('GET', '/c')).data).toBe('hello');
  154. fetchMock.mockResolvedValueOnce(
  155. new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }),
  156. );
  157. expect((await http.httpRequest('GET', '/d')).data).toBe('{bad');
  158. });
  159. it('rejects when fetch fails at the network level', async () => {
  160. http.setupHttp();
  161. fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
  162. await expect(http.httpRequest('GET', '/x')).rejects.toThrow('Failed to fetch');
  163. });
  164. it('passes an AbortSignal when a timeout is set', async () => {
  165. http.setupHttp();
  166. fetchMock.mockResolvedValue(okEnvelope());
  167. await http.httpRequest('GET', '/x', undefined, { timeout: 50 });
  168. expect(initOf().signal).toBeInstanceOf(AbortSignal);
  169. });
  170. it('preserves a caller cancellation signal when a timeout is set', async () => {
  171. http.setupHttp();
  172. fetchMock.mockResolvedValue(okEnvelope());
  173. const controller = new AbortController();
  174. await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
  175. controller.abort();
  176. expect(initOf().signal?.aborted).toBe(true);
  177. });
  178. it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
  179. const timeout = AbortSignal.timeout.bind(AbortSignal);
  180. vi.resetModules();
  181. vi.stubGlobal('AbortSignal', { timeout });
  182. http = await import('@/api/http-init');
  183. http.setupHttp();
  184. fetchMock.mockResolvedValue(okEnvelope());
  185. const controller = new AbortController();
  186. await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
  187. controller.abort();
  188. expect(initOf().signal?.aborted).toBe(true);
  189. });
  190. it('times out when AbortSignal.any is unavailable', async () => {
  191. const timeout = AbortSignal.timeout.bind(AbortSignal);
  192. vi.resetModules();
  193. vi.stubGlobal('AbortSignal', { timeout });
  194. http = await import('@/api/http-init');
  195. http.setupHttp();
  196. fetchMock.mockResolvedValue(okEnvelope());
  197. const controller = new AbortController();
  198. await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
  199. const signal = initOf().signal as AbortSignal;
  200. await new Promise<void>((resolve) =>
  201. signal.addEventListener('abort', () => resolve(), { once: true }),
  202. );
  203. expect(signal.aborted).toBe(true);
  204. });
  205. it('aborts on the timeout when a caller signal is present', async () => {
  206. http.setupHttp();
  207. fetchMock.mockResolvedValue(okEnvelope());
  208. const controller = new AbortController();
  209. await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
  210. const signal = initOf().signal as AbortSignal;
  211. await new Promise<void>((resolve) => {
  212. if (signal.aborted) {
  213. resolve();
  214. return;
  215. }
  216. signal.addEventListener('abort', () => resolve(), { once: true });
  217. });
  218. expect(signal.aborted).toBe(true);
  219. });
  220. it.each([
  221. ['/x?keep=1', '/x?keep=1&added=yes'],
  222. ['/x?', '/x?added=yes'],
  223. ['/x#frag', '/x?added=yes#frag'],
  224. ['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
  225. ])('appends encoded params to %s', async (url, expected) => {
  226. http.setupHttp();
  227. fetchMock.mockResolvedValue(okEnvelope());
  228. await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
  229. expect(urlOf()).toBe(expected);
  230. });
  231. it('preserves the URL when no params are supplied', async () => {
  232. http.setupHttp();
  233. fetchMock.mockResolvedValue(okEnvelope());
  234. await http.httpRequest('GET', '/x?keep=1#frag');
  235. expect(urlOf()).toBe('/x?keep=1#frag');
  236. });
  237. });