mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 07:32:23 +03:00
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.
- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
request/response interceptors on fetch: base-path prefixing,
X-Requested-With, same-origin credentials, the CSRF token on unsafe
methods, a single 403 retry with token refresh, and the 401
redirect-and-latch. A small encodeForm() reproduces qs's
arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
envelope, so the ~49 API call sites are untouched. HttpOptions is now
hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
HttpUtil's envelope unwrap / toast / error mapping; this logic was
previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
reword stale "axios" mentions in docs and route comments.
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|