| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
- type HttpModule = typeof import('@/api/http-init');
- const okEnvelope = (obj: unknown = {}): Response =>
- new Response(JSON.stringify({ success: true, msg: '', obj }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- const csrfResponse = (token: string): Response =>
- new Response(JSON.stringify({ success: true, obj: token }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- describe('http-init fetch wrapper', () => {
- let http: HttpModule;
- let fetchMock: ReturnType<typeof vi.fn>;
- let replaceMock: ReturnType<typeof vi.fn>;
- const initOf = (call = 0): RequestInit => fetchMock.mock.calls[call][1] as RequestInit;
- const urlOf = (call = 0): string => fetchMock.mock.calls[call][0] as string;
- const headersOf = (call = 0): Headers => initOf(call).headers as Headers;
- beforeEach(async () => {
- vi.resetModules();
- document.head.innerHTML = '';
- delete (window as { X_UI_BASE_PATH?: string }).X_UI_BASE_PATH;
- fetchMock = vi.fn();
- vi.stubGlobal('fetch', fetchMock);
- replaceMock = vi.fn();
- Object.defineProperty(window, 'location', {
- configurable: true,
- value: {
- replace: replaceMock,
- href: 'http://localhost/',
- origin: 'http://localhost',
- pathname: '/',
- },
- });
- http = await import('@/api/http-init');
- });
- afterEach(() => {
- vi.unstubAllGlobals();
- });
- it('form-encodes bodies and repeats array keys', async () => {
- document.head.innerHTML = '<meta name="csrf-token" content="tok">';
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- await http.httpRequest('POST', '/panel/x', { a: 1, b: ['x', 'y'] });
- expect(initOf().body).toBe('a=1&b=x&b=y');
- expect(headersOf().get('content-type')).toBe(
- 'application/x-www-form-urlencoded; charset=UTF-8',
- );
- });
- it('JSON-encodes bodies when the caller declares application/json', async () => {
- document.head.innerHTML = '<meta name="csrf-token" content="tok">';
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- await http.httpRequest(
- 'POST',
- '/panel/x',
- { a: 1 },
- { headers: { 'Content-Type': 'application/json' } },
- );
- expect(initOf().body).toBe(JSON.stringify({ a: 1 }));
- expect(headersOf().get('content-type')).toBe('application/json');
- });
- it('passes FormData through without a Content-Type header', async () => {
- document.head.innerHTML = '<meta name="csrf-token" content="tok">';
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- const fd = new FormData();
- fd.append('db', 'contents');
- await http.httpRequest('POST', '/panel/import', fd, {
- headers: { 'Content-Type': 'multipart/form-data' },
- });
- expect(initOf().body).toBe(fd);
- expect(headersOf().has('content-type')).toBe(false);
- });
- it('attaches the CSRF token on POST and omits it on GET', async () => {
- document.head.innerHTML = '<meta name="csrf-token" content="tok">';
- http.setupHttp();
- fetchMock.mockImplementation(() => Promise.resolve(okEnvelope()));
- await http.httpRequest('POST', '/p', { a: 1 });
- expect(headersOf().get('X-CSRF-Token')).toBe('tok');
- expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
- fetchMock.mockClear();
- await http.httpRequest('GET', '/g');
- expect(headersOf().get('X-CSRF-Token')).toBeNull();
- expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
- });
- it('prepends the base path to request and csrf-token URLs', async () => {
- window.X_UI_BASE_PATH = '/xui';
- http.setupHttp();
- fetchMock.mockImplementation((url: string) =>
- Promise.resolve(url.endsWith('/csrf-token') ? csrfResponse('fresh') : okEnvelope()),
- );
- await http.httpRequest('POST', '/panel/api/x', { a: 1 });
- expect(urlOf(0)).toBe('/xui/csrf-token');
- expect(urlOf(1)).toBe('/xui/panel/api/x');
- });
- it('refreshes the token and retries once on 403', async () => {
- http.setupHttp();
- let dataCalls = 0;
- fetchMock.mockImplementation((url: string) => {
- if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse(`tok${dataCalls}`));
- dataCalls += 1;
- return Promise.resolve(dataCalls === 1 ? new Response('', { status: 403 }) : okEnvelope());
- });
- const resp = await http.httpRequest('POST', '/panel/api/x', { a: 1 });
- expect(resp.ok).toBe(true);
- expect(dataCalls).toBe(2);
- expect(headersOf(3).get('X-CSRF-Token')).toBe('tok1');
- });
- it('throws HttpError when the retried request is still 403', async () => {
- http.setupHttp();
- let dataCalls = 0;
- fetchMock.mockImplementation((url: string) => {
- if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse('tok'));
- dataCalls += 1;
- return Promise.resolve(new Response('', { status: 403 }));
- });
- await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(
- http.HttpError,
- );
- expect(dataCalls).toBe(2);
- });
- it('redirects once on 401 and never settles', async () => {
- window.X_UI_BASE_PATH = '/xui';
- document.head.innerHTML = '<meta name="csrf-token" content="tok">';
- http.setupHttp();
- fetchMock.mockResolvedValue(new Response('', { status: 401 }));
- const pending = Symbol('pending');
- const first = await Promise.race([
- http.httpRequest('POST', '/p', { a: 1 }),
- new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
- ]);
- expect(first).toBe(pending);
- expect(replaceMock).toHaveBeenCalledTimes(1);
- expect(replaceMock).toHaveBeenCalledWith('/xui');
- const second = await Promise.race([
- http.httpRequest('POST', '/p', { a: 1 }),
- new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
- ]);
- expect(second).toBe(pending);
- expect(replaceMock).toHaveBeenCalledTimes(1);
- });
- it('parses empty, 204, non-JSON, and malformed bodies tolerantly', async () => {
- http.setupHttp();
- fetchMock.mockResolvedValueOnce(new Response('', { status: 200 }));
- expect((await http.httpRequest('GET', '/a')).data).toBe('');
- fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
- expect((await http.httpRequest('GET', '/b')).data).toBe('');
- fetchMock.mockResolvedValueOnce(
- new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }),
- );
- expect((await http.httpRequest('GET', '/c')).data).toBe('hello');
- fetchMock.mockResolvedValueOnce(
- new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }),
- );
- expect((await http.httpRequest('GET', '/d')).data).toBe('{bad');
- });
- it('rejects when fetch fails at the network level', async () => {
- http.setupHttp();
- fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
- await expect(http.httpRequest('GET', '/x')).rejects.toThrow('Failed to fetch');
- });
- it('passes an AbortSignal when a timeout is set', async () => {
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- await http.httpRequest('GET', '/x', undefined, { timeout: 50 });
- expect(initOf().signal).toBeInstanceOf(AbortSignal);
- });
- it('preserves a caller cancellation signal when a timeout is set', async () => {
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- const controller = new AbortController();
- await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
- controller.abort();
- expect(initOf().signal?.aborted).toBe(true);
- });
- it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
- const timeout = AbortSignal.timeout.bind(AbortSignal);
- vi.resetModules();
- vi.stubGlobal('AbortSignal', { timeout });
- http = await import('@/api/http-init');
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- const controller = new AbortController();
- await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
- controller.abort();
- expect(initOf().signal?.aborted).toBe(true);
- });
- it('times out when AbortSignal.any is unavailable', async () => {
- const timeout = AbortSignal.timeout.bind(AbortSignal);
- vi.resetModules();
- vi.stubGlobal('AbortSignal', { timeout });
- http = await import('@/api/http-init');
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- const controller = new AbortController();
- await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
- const signal = initOf().signal as AbortSignal;
- await new Promise<void>((resolve) =>
- signal.addEventListener('abort', () => resolve(), { once: true }),
- );
- expect(signal.aborted).toBe(true);
- });
- it('aborts on the timeout when a caller signal is present', async () => {
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- const controller = new AbortController();
- await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
- const signal = initOf().signal as AbortSignal;
- await new Promise<void>((resolve) => {
- if (signal.aborted) {
- resolve();
- return;
- }
- signal.addEventListener('abort', () => resolve(), { once: true });
- });
- expect(signal.aborted).toBe(true);
- });
- it.each([
- ['/x?keep=1', '/x?keep=1&added=yes'],
- ['/x?', '/x?added=yes'],
- ['/x#frag', '/x?added=yes#frag'],
- ['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
- ])('appends encoded params to %s', async (url, expected) => {
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
- expect(urlOf()).toBe(expected);
- });
- it('preserves the URL when no params are supplied', async () => {
- http.setupHttp();
- fetchMock.mockResolvedValue(okEnvelope());
- await http.httpRequest('GET', '/x?keep=1#frag');
- expect(urlOf()).toBe('/x?keep=1#frag');
- });
- });
|