Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions app/api/upload/presign/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const hashIp = vi.fn(() => "hashed-ip");
const getClientIp = vi.fn(() => "127.0.0.1");
const consumeSharedRateLimit = vi.fn(async () => ({
allowed: true,
remaining: 9,
resetAt: new Date().toISOString(),
currentCount: 1,
}));
const getPresignedUploadUrl = vi.fn(async () => ({
uploadUrl: "https://upload.example/signed",
publicUrl: "https://cdn.example/screenshots/test.png",
key: "screenshots/test.png",
}));

vi.mock("@/lib/utils/hash", () => ({
hashIp,
getClientIp,
}));

vi.mock("@/lib/rate-limit/shared", () => ({
consumeSharedRateLimit,
}));

vi.mock("@/lib/observability/events", () => ({
logEvent: vi.fn(),
}));

vi.mock("@/lib/r2/upload", () => ({
getPresignedUploadUrl,
}));

function createRequest(body: unknown) {
return new NextRequest("http://localhost/api/upload/presign", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

describe("POST /api/upload/presign", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
consumeSharedRateLimit.mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: new Date().toISOString(),
currentCount: 1,
});
});

it("returns 400 if size is missing from request body", async () => {
const { POST } = await import("./route");
const response = await POST(
createRequest({
filename: "screenshot.png",
contentType: "image/png",
}),
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBeDefined();
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
});

it("returns 400 if size exceeds 5 MB limit", async () => {
const { POST } = await import("./route");
const response = await POST(
createRequest({
filename: "large.png",
contentType: "image/png",
size: 6 * 1024 * 1024,
}),
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("File must be under 5 MB.");
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
});

it("returns 400 if size is 0 or negative", async () => {
const { POST } = await import("./route");
const responseZero = await POST(
createRequest({
filename: "zero.png",
contentType: "image/png",
size: 0,
}),
);
expect(responseZero.status).toBe(400);

const responseNegative = await POST(
createRequest({
filename: "negative.png",
contentType: "image/png",
size: -500,
}),
);
expect(responseNegative.status).toBe(400);
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
});

it("returns 400 if size is not an integer", async () => {
const { POST } = await import("./route");
const response = await POST(
createRequest({
filename: "float.png",
contentType: "image/png",
size: 1024.5,
}),
);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("File size must be an integer byte count.");
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
});

it("allows exactly 5 MB upload size", async () => {
const { POST } = await import("./route");
const exact5MB = 5 * 1024 * 1024;
const response = await POST(
createRequest({
filename: "exact5mb.png",
contentType: "image/png",
size: exact5MB,
}),
);

expect(response.status).toBe(200);
expect(getPresignedUploadUrl).toHaveBeenCalledWith(
"exact5mb.png",
"image/png",
"screenshots",
exact5MB,
);
});

it("generates presigned upload URL with ContentLength when valid size is provided", async () => {
const { POST } = await import("./route");
const response = await POST(
createRequest({
filename: "valid.png",
contentType: "image/png",
size: 2 * 1024 * 1024,
}),
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe("https://upload.example/signed");
expect(getPresignedUploadUrl).toHaveBeenCalledWith(
"valid.png",
"image/png",
"screenshots",
2 * 1024 * 1024,
);
});

it("returns 429 when rate limit is exceeded", async () => {
consumeSharedRateLimit.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date().toISOString(),
currentCount: 11,
});

const { POST } = await import("./route");
const response = await POST(
createRequest({
filename: "valid.png",
contentType: "image/png",
size: 1024,
}),
);

expect(response.status).toBe(429);
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
});
});
13 changes: 6 additions & 7 deletions app/api/upload/presign/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ const schema = z.object({
contentType: z.string().refine((t) => ALLOWED_TYPES.includes(t), {
message: "Only JPEG, PNG, WEBP, and GIF images are allowed.",
}),
size: z.number().max(MAX_SIZE_BYTES, "File must be under 5 MB.").optional(),
size: z
.number()
.int("File size must be an integer byte count.")
.min(1, "File size must be greater than 0 bytes.")
.max(MAX_SIZE_BYTES, "File must be under 5 MB."),
});

const WINDOW_SECONDS = 10 * 60;
Expand Down Expand Up @@ -52,17 +56,12 @@ export async function POST(req: NextRequest) {
}

const { filename, contentType, size } = parsed.data;
if (size != null && size > MAX_SIZE_BYTES) {
return NextResponse.json(
{ error: "File must be under 5 MB." },
{ status: 400 },
);
}

const result = await getPresignedUploadUrl(
filename,
contentType,
"screenshots",
size,
);

return NextResponse.json({
Expand Down
14 changes: 14 additions & 0 deletions lib/r2/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ describe("getPresignedUploadUrl", () => {
expect(command.Metadata?.["original-filename"]).toBe("my_photo__1_.png");
});

it("includes ContentLength in PutObjectCommand when size is provided", async () => {
await upload.getPresignedUploadUrl(
"photo.png",
"image/png",
"screenshots",
1048576,
);

const command = putObjectCommand.mock.calls[0][0] as {
ContentLength?: number;
};
expect(command.ContentLength).toBe(1048576);
});

it("reuses one client for upload and read presigns", async () => {
await upload.getPresignedUploadUrl("first.png", "image/png");
await upload.getPresignedReadUrl("screenshots/existing.png");
Expand Down
2 changes: 2 additions & 0 deletions lib/r2/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export async function getPresignedUploadUrl(
filename: string,
contentType: string,
folder = "screenshots",
contentLength?: number,
): Promise<PresignUploadResult> {
const ext = extensionForContentType(contentType);
const key = `${folder}/${randomUUID()}.${ext}`;
Expand All @@ -80,6 +81,7 @@ export async function getPresignedUploadUrl(
Bucket: bucketName,
Key: key,
ContentType: contentType,
...(contentLength != null ? { ContentLength: contentLength } : {}),
Metadata: { "original-filename": sanitizeFilenameForMetadata(filename) },
});

Expand Down