fix(SEC-WEB-32+34): Add input maxLength limits + API request timeout
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

SEC-WEB-32: Added maxLength to form inputs (names: 100, descriptions: 500,
emails: 254) in WorkspaceSettings, TeamSettings, InviteMember components.

SEC-WEB-34: Added AbortController timeout (30s default, configurable) to
apiRequest and apiPostFormData in API client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 18:11:00 -06:00
parent 14b547d468
commit 014264c592
8 changed files with 320 additions and 80 deletions

View File

@@ -10,6 +10,7 @@ import {
fetchCsrfToken,
getCsrfToken,
clearCsrfToken,
DEFAULT_API_TIMEOUT_MS,
} from "./client";
// Mock fetch globally
@@ -718,4 +719,84 @@ describe("API Client", (): void => {
});
});
});
describe("Request timeout", (): void => {
it("should export a default timeout constant of 30000ms", (): void => {
expect(DEFAULT_API_TIMEOUT_MS).toBe(30_000);
});
it("should pass an AbortController signal to fetch", async (): Promise<void> => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: "ok" }),
});
await apiRequest("/test");
const callArgs = mockFetch.mock.calls[0]![1] as RequestInit;
expect(callArgs.signal).toBeDefined();
expect(callArgs.signal).toBeInstanceOf(AbortSignal);
});
it("should abort and throw timeout error when request exceeds timeoutMs", async (): Promise<void> => {
// Mock fetch that never resolves, simulating a hanging request
mockFetch.mockImplementationOnce(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
if (init.signal) {
init.signal.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
}
})
);
await expect(apiRequest("/slow-endpoint", { timeoutMs: 50 })).rejects.toThrow(
"Request to /slow-endpoint timed out after 50ms"
);
});
it("should allow disabling timeout with timeoutMs=0", async (): Promise<void> => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: "ok" }),
});
const result = await apiRequest<{ data: string }>("/test", { timeoutMs: 0 });
expect(result).toEqual({ data: "ok" });
});
it("should clear timeout after successful request", async (): Promise<void> => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: "ok" }),
});
await apiRequest("/test");
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it("should clear timeout after failed request", async (): Promise<void> => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: "Not Found",
status: 404,
json: () =>
Promise.resolve({
code: "NOT_FOUND",
message: "Not found",
}),
});
await expect(apiRequest("/test")).rejects.toThrow("Not found");
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});
});