mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 13:12:07 +03:00
863473783d
* fix(frontend): preserve request cancellation and schema failures * fix(frontend): limit schema failures to query boundaries * fix(frontend): keep invalid settings recoverable Keep settings payload validation tolerant so values accepted by the backend remain editable, while paged clients still fail closed. Add an AbortSignal.any fallback and make timeout tests event-driven. --------- Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
280 lines
9.9 KiB
TypeScript
280 lines
9.9 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);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|