diff --git a/src/lib/feedback/server.test.ts b/src/lib/feedback/server.test.ts index f6bcb19a..e968acf1 100644 --- a/src/lib/feedback/server.test.ts +++ b/src/lib/feedback/server.test.ts @@ -72,15 +72,34 @@ it("cleans partial uploads and does not expose backend errors", async () => { expect(await response.json()).toEqual({ code: "SUBMISSION_FAILED" }); expect(backend.remove).toHaveBeenCalledWith([expect.stringMatching(/^user-id\//)]); }); -it("caps streamed request bodies even without a content-length header", async () => { +it("caps a valid multipart stream before consuming the entire oversized upload", async () => { + const multipart = request([new File([new Uint8Array(16 * 1024 * 1024)], "large.bin")]); + const bytes = new Uint8Array(await multipart.arrayBuffer()); + let produced = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + if (produced === bytes.length) { + controller.close(); + return; + } + const end = Math.min(produced + 64 * 1024, bytes.length); + controller.enqueue(bytes.slice(produced, end)); + produced = end; + }, + cancel() { + cancelled = true; + }, + }); const req = new Request("https://example.test", { method: "POST", - headers: { - Authorization: "Bearer session", - "Content-Type": "multipart/form-data; boundary=x", - }, - body: new Uint8Array(12 * 1024 * 1024), - }); + headers: multipart.headers, + body, + duplex: "half", + } as RequestInit); + expect(req.headers.has("content-length")).toBe(false); expect((await handle(req)).status).toBe(400); expect(backend.reserve).not.toHaveBeenCalled(); + expect(cancelled).toBe(true); + expect(produced).toBeLessThan(bytes.length); }); diff --git a/src/lib/feedback/submitFeedback.test.ts b/src/lib/feedback/submitFeedback.test.ts index 512b69ac..7a094390 100644 --- a/src/lib/feedback/submitFeedback.test.ts +++ b/src/lib/feedback/submitFeedback.test.ts @@ -47,3 +47,35 @@ it("rejects unconfirmed submissions", async () => { api.invoke.mockResolvedValue({ data: {}, error: null }); await expect(submitFeedback(input)).rejects.toThrow("not confirmed"); }); + +it("aborts a stalled request after one minute and permits a subsequent submission", async () => { + const { FunctionsClient } = await import("@supabase/functions-js"); + vi.useFakeTimers(); + let signal: AbortSignal | null | undefined; + try { + const functions = new FunctionsClient("https://example.test/functions/v1", { + customFetch: (_url, options) => + new Promise((_resolve, reject) => { + signal = options?.signal; + signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }), + }); + api.invoke.mockImplementation(functions.invoke.bind(functions)); + const pending = submitFeedback(input).catch((error) => error); + await vi.advanceTimersByTimeAsync(59_999); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); + expect(feedbackErrorMessage(await pending)).toBe( + "Could not send feedback. Please try again.", + ); + api.invoke.mockResolvedValue({ data: { success: true }, error: null }); + await expect(submitFeedback(input)).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/lib/feedback/submitFeedback.ts b/src/lib/feedback/submitFeedback.ts index c9ef457d..7bb52716 100644 --- a/src/lib/feedback/submitFeedback.ts +++ b/src/lib/feedback/submitFeedback.ts @@ -44,7 +44,7 @@ export async function submitFeedback(input: { body.set("message", input.message.trim()); if (input.logs !== null) body.set("logs", input.logs); for (const file of input.files) body.append("files", file); - const result = await client.functions.invoke("submit-feedback", { body }); + const result = await client.functions.invoke("submit-feedback", { body, timeout: 60_000 }); if (result.error) { const response = result.error.context; if (response instanceof Response) {