fix(frontend): preserve cancellation and reject invalid query data (#6143)

* 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>
This commit is contained in:
PathGao
2026-07-30 02:51:40 +08:00
committed by GitHub
parent c3fa73d5a0
commit 863473783d
6 changed files with 183 additions and 3 deletions

View File

@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
return parts.join('&');
}
function appendQuery(url: string, query: string): string {
if (query === '') return url;
const hashIndex = url.indexOf('#');
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const hasQuery = path.includes('?');
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
return `${path}${separator}${query}${hash}`;
}
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
if (!options.timeout) return options.signal;
const timeout = AbortSignal.timeout(options.timeout);
if (!options.signal) return timeout;
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
const controller = new AbortController();
const abort = () => controller.abort();
options.signal.addEventListener('abort', abort, { once: true });
timeout.addEventListener('abort', abort, { once: true });
return controller.signal;
}
async function performFetch(
method: string,
url: string,
@@ -121,8 +143,8 @@ async function performFetch(
}
const query = encodeForm(options.params);
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
}

View File

@@ -155,7 +155,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
if (!validated.obj) throw new Error('Empty clients response');
return validated.obj;
}

View File

@@ -193,4 +193,87 @@ describe('http-init fetch wrapper', () => {
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');
});
});

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
describe('useAllSettings', () => {
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
const subJsonUserAgentRegex = 'x'.repeat(2_049);
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
});
});

View File

@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { ClientPageResponseSchema } from '@/schemas/client';
import { fetchXrayConfig } from '@/hooks/useXraySetting';
afterEach(() => {
vi.restoreAllMocks();
});
describe('parseMsg', () => {
it('rejects a successful response whose payload violates its schema', () => {
const msg = new Msg(true, '', { id: 'not-a-number' });
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
'test/value response failed validation',
);
});
it('preserves a missing successful payload for callers that handle empty values', () => {
expect(parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj).toBeNull();
});
it('rejects malformed paged-client payloads', () => {
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
'clients/list/paged response failed validation',
);
});
});
describe('fetchXrayConfig', () => {
it('keeps a malformed xray payload available for repair', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
});
});

View File

@@ -1,10 +1,15 @@
import type { z } from 'zod';
import { Msg } from '@/utils';
interface ParseMsgOptions {
strict?: boolean;
}
export function parseMsg<T extends z.ZodType>(
msg: Msg<unknown>,
schema: T,
context: string,
options: ParseMsgOptions = {},
): Msg<z.infer<T>> {
if (!msg.success || msg.obj == null) {
return msg as Msg<z.infer<T>>;
@@ -12,6 +17,7 @@ export function parseMsg<T extends z.ZodType>(
const result = schema.safeParse(msg.obj);
if (!result.success) {
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
if (options.strict) throw new Error(`${context} response failed validation`);
return msg as Msg<z.infer<T>>;
}
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);