Skip to content

Commit a62e916

Browse files
committed
fix: migrate upload health endpoint to Effect HttpApi
1 parent 0751ab0 commit a62e916

5 files changed

Lines changed: 354 additions & 42 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain";
2+
import {
3+
type HttpApi,
4+
HttpApiBuilder,
5+
HttpApiError,
6+
HttpServer,
7+
HttpServerRequest,
8+
} from "@effect/platform";
9+
import { type Context, Effect, Layer, Option } from "effect";
10+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES } from "@/app/api/desktop/upload-health/upload-health";
12+
13+
const mocks = vi.hoisted(() => ({
14+
authenticate: vi.fn<
15+
(headers: Readonly<Record<string, string | undefined>>) => boolean
16+
>(() => true),
17+
disposers: [] as (() => Promise<void>)[],
18+
}));
19+
20+
vi.mock("@/lib/server", () => ({
21+
apiToHandler: (api: Layer.Layer<HttpApi.Api, never, HttpAuthMiddleware>) => {
22+
const auth = Layer.succeed(
23+
HttpAuthMiddleware,
24+
Effect.gen(function* () {
25+
const request = yield* HttpServerRequest.HttpServerRequest;
26+
if (!mocks.authenticate(request.headers)) {
27+
return yield* Effect.fail(new HttpApiError.Unauthorized());
28+
}
29+
return CurrentUser.of({
30+
id: "test-user",
31+
email: "test@example.com",
32+
activeOrganizationId: "test-organization",
33+
iconUrlOrKey: Option.none(),
34+
} as Context.Tag.Service<typeof CurrentUser>);
35+
}),
36+
);
37+
const web = api.pipe(
38+
Layer.provide(auth),
39+
Layer.merge(HttpServer.layerContext),
40+
Layer.provide(
41+
HttpApiBuilder.middlewareCors({
42+
allowedOrigins: ["https://cap.test"],
43+
credentials: true,
44+
allowedMethods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
45+
allowedHeaders: [
46+
"Content-Type",
47+
"Authorization",
48+
"sentry-trace",
49+
"baggage",
50+
],
51+
}),
52+
),
53+
HttpApiBuilder.toWebHandler,
54+
);
55+
mocks.disposers.push(web.dispose);
56+
return web.handler;
57+
},
58+
}));
59+
60+
const url = "https://cap.test/api/desktop/upload-health";
61+
const authorization = `Bearer ${"a".repeat(36)}`;
62+
63+
beforeEach(() => {
64+
mocks.authenticate.mockReset().mockReturnValue(true);
65+
});
66+
67+
afterAll(async () => {
68+
await Promise.all(mocks.disposers.map((dispose) => dispose()));
69+
});
70+
71+
describe("desktop upload health route", () => {
72+
it("returns an authenticated, empty HEAD response", async () => {
73+
const { HEAD } = await import("@/app/api/desktop/upload-health/route");
74+
const response = await HEAD(
75+
new Request(url, { method: "HEAD", headers: { authorization } }),
76+
);
77+
78+
expect(response.status).toBe(204);
79+
expect(await response.text()).toBe("");
80+
expect(mocks.authenticate).toHaveBeenCalledWith(
81+
expect.objectContaining({ authorization }),
82+
);
83+
});
84+
85+
it.each(["HEAD", "POST"])(
86+
"rejects unauthenticated %s before reading any body",
87+
async (method) => {
88+
mocks.authenticate.mockReturnValue(false);
89+
const route = await import("@/app/api/desktop/upload-health/route");
90+
const request = new Request(url, {
91+
method,
92+
...(method === "POST" ? { body: new Uint8Array(16) } : {}),
93+
});
94+
const getReader = request.body && vi.spyOn(request.body, "getReader");
95+
const response = await (method === "HEAD" ? route.HEAD : route.POST)(
96+
request,
97+
);
98+
99+
expect(response.status).toBe(401);
100+
expect(mocks.authenticate).toHaveBeenCalledOnce();
101+
if (getReader) expect(getReader).not.toHaveBeenCalled();
102+
},
103+
);
104+
105+
it.each([0, 64 * 1024, MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES])(
106+
"accepts a %i-byte body with the existing response fields",
107+
async (size) => {
108+
const { POST } = await import("@/app/api/desktop/upload-health/route");
109+
const response = await POST(
110+
new Request(url, {
111+
method: "POST",
112+
headers: { authorization },
113+
...(size ? { body: new Uint8Array(size) } : {}),
114+
}),
115+
);
116+
117+
expect(response.status).toBe(200);
118+
expect(await response.json()).toEqual({
119+
success: true,
120+
receivedBytes: size,
121+
maxProbeBytes: MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES,
122+
});
123+
},
124+
);
125+
126+
it("rejects a declared oversize without reading the body", async () => {
127+
const { POST } = await import("@/app/api/desktop/upload-health/route");
128+
const request = new Request(url, {
129+
method: "POST",
130+
headers: {
131+
authorization,
132+
"content-length": String(MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES + 1),
133+
},
134+
body: new Uint8Array(16),
135+
});
136+
const getReader = vi.spyOn(
137+
request.body as ReadableStream<Uint8Array>,
138+
"getReader",
139+
);
140+
const response = await POST(request);
141+
142+
expect(response.status).toBe(413);
143+
expect(await response.json()).toEqual({ error: "probe_too_large" });
144+
expect(getReader).not.toHaveBeenCalled();
145+
});
146+
147+
it.each([undefined, "1", "invalid"])(
148+
"rejects actual oversize with Content-Length %s and cancels the stream",
149+
async (contentLength) => {
150+
const { POST } = await import("@/app/api/desktop/upload-health/route");
151+
const cancel = vi.fn();
152+
let chunks = 0;
153+
const body = new ReadableStream<Uint8Array>(
154+
{
155+
pull(controller) {
156+
controller.enqueue(
157+
new Uint8Array(
158+
chunks++ === 0 ? MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES : 1,
159+
),
160+
);
161+
},
162+
cancel,
163+
},
164+
{ highWaterMark: 0 },
165+
);
166+
const options = {
167+
method: "POST",
168+
headers: {
169+
authorization,
170+
...(contentLength ? { "content-length": contentLength } : {}),
171+
},
172+
body,
173+
duplex: "half",
174+
};
175+
const response = await POST(new Request(url, options));
176+
177+
expect(response.status).toBe(413);
178+
expect(await response.json()).toEqual({ error: "probe_too_large" });
179+
expect(cancel).toHaveBeenCalledOnce();
180+
expect(chunks).toBe(2);
181+
expect(body.locked).toBe(false);
182+
},
183+
);
184+
185+
it("returns probe_failed and releases the reader when a stream errors", async () => {
186+
const { POST } = await import("@/app/api/desktop/upload-health/route");
187+
const body = new ReadableStream<Uint8Array>({
188+
pull(controller) {
189+
controller.error(new Error("connection interrupted"));
190+
},
191+
});
192+
const options = {
193+
method: "POST",
194+
headers: { authorization },
195+
body,
196+
duplex: "half",
197+
};
198+
const response = await POST(new Request(url, options));
199+
200+
expect(response.status).toBe(500);
201+
expect(await response.json()).toEqual({ error: "probe_failed" });
202+
expect(body.locked).toBe(false);
203+
});
204+
205+
it("passes preflight through the Effect CORS middleware without authenticating", async () => {
206+
const { OPTIONS } = await import("@/app/api/desktop/upload-health/route");
207+
const response = await OPTIONS(
208+
new Request(url, {
209+
method: "OPTIONS",
210+
headers: {
211+
origin: "https://cap.test",
212+
"access-control-request-method": "POST",
213+
"access-control-request-headers": "Authorization, Content-Type",
214+
},
215+
}),
216+
);
217+
218+
expect(response.status).toBe(204);
219+
expect(response.headers.get("access-control-allow-origin")).toBe(
220+
"https://cap.test",
221+
);
222+
expect(response.headers.get("access-control-allow-credentials")).toBe(
223+
"true",
224+
);
225+
expect(response.headers.get("access-control-allow-methods")).toContain(
226+
"POST",
227+
);
228+
expect(response.headers.get("access-control-allow-methods")).toContain(
229+
"HEAD",
230+
);
231+
expect(response.headers.get("access-control-allow-headers")).toContain(
232+
"Authorization",
233+
);
234+
expect(mocks.authenticate).not.toHaveBeenCalled();
235+
});
236+
});

apps/web/__tests__/unit/desktop-upload-health.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES,
44
readUploadHealthProbeBytes,
55
UploadHealthProbeTooLargeError,
6-
} from "@/app/api/desktop/[...route]/uploadHealth";
6+
} from "@/app/api/desktop/upload-health/upload-health";
77

88
describe("desktop upload health probe", () => {
99
it("counts a bounded probe body without storing it", async () => {
@@ -39,4 +39,30 @@ describe("desktop upload health probe", () => {
3939
UploadHealthProbeTooLargeError,
4040
);
4141
});
42+
43+
it("preserves the size error if cancelling the stream also fails", async () => {
44+
const body = new ReadableStream<Uint8Array>(
45+
{
46+
pull(controller) {
47+
controller.enqueue(
48+
new Uint8Array(MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES + 1),
49+
);
50+
},
51+
cancel() {
52+
throw new Error("connection already closed");
53+
},
54+
},
55+
{ highWaterMark: 0 },
56+
);
57+
const options = { method: "POST", body, duplex: "half" };
58+
const request = new Request(
59+
"https://cap.test/api/desktop/upload-health",
60+
options,
61+
);
62+
63+
await expect(readUploadHealthProbeBytes(request)).rejects.toBeInstanceOf(
64+
UploadHealthProbeTooLargeError,
65+
);
66+
expect(body.locked).toBe(false);
67+
});
4268
});

apps/web/app/api/desktop/[...route]/root.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,6 @@ import {
3838
OrganizationBrandingValidationError,
3939
toDesktopOrganization,
4040
} from "./organization-branding";
41-
import {
42-
MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES,
43-
readUploadHealthProbeBytes,
44-
UploadHealthProbeTooLargeError,
45-
} from "./uploadHealth";
4641

4742
export const app = new Hono();
4843

@@ -451,41 +446,6 @@ app.post(
451446
},
452447
);
453448

454-
app.on("HEAD", "/upload-health", withAuth, (c) => c.body(null, 204));
455-
456-
app.post("/upload-health", withAuth, async (c) => {
457-
const contentLengthHeader = c.req.header("content-length");
458-
const contentLength =
459-
contentLengthHeader === undefined ? null : Number(contentLengthHeader);
460-
461-
if (
462-
contentLength !== null &&
463-
Number.isFinite(contentLength) &&
464-
contentLength > MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES
465-
) {
466-
return c.json({ error: "probe_too_large" }, { status: 413 });
467-
}
468-
469-
try {
470-
// The stream reader below is the enforcement boundary for missing,
471-
// invalid, or understated Content-Length headers.
472-
const receivedBytes = await readUploadHealthProbeBytes(c.req.raw);
473-
474-
return c.json({
475-
success: true,
476-
receivedBytes,
477-
maxProbeBytes: MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES,
478-
});
479-
} catch (error) {
480-
if (error instanceof UploadHealthProbeTooLargeError) {
481-
return c.json({ error: "probe_too_large" }, { status: 413 });
482-
}
483-
484-
console.error("[upload-health] Failed to read probe body:", error);
485-
return c.json({ error: "probe_failed" }, { status: 500 });
486-
}
487-
});
488-
489449
app.get("/org-custom-domain", withAuth, async (c) => {
490450
const user = c.get("user");
491451

0 commit comments

Comments
 (0)