test(01-04): implement 7 media upload unit tests covering security and validation
- Test 1: getPresignedUrl generates URL with correct contentType and avatars/-scoped key - Test 2: getPresignedUrl rejects non-owner with FORBIDDEN (T-4-01) - Test 3: getPresignedUrl rejects image/gif (Zod enum validation, T-4-02) - Test 4: getPresignedUrl rejects 11MB file (T-4-06 fileSize limit) - Test 5: confirmUpload writes avatarKey to DB with correct update call - Test 6: confirmUpload rejects path traversal key (T-4-05) - Bonus: confirmUpload rejects non-owner with FORBIDDEN (T-4-01) - Mock getPresignedUploadUrl via vi.mock — no real R2 calls
This commit is contained in:
1 parent
fb8bb5c8fe
commit
62b7cdaf1e
1 file changed
+219
-24
@@ -1,36 +1,231 @@
|
||||
/**
|
||||
* Wave 0 Test Scaffold — Media Upload
|
||||
* Media Upload Unit Tests — Plan 04
|
||||
*
|
||||
* Covers: Presigned R2 URL generation with correct ContentType
|
||||
* Covers: Presigned R2 URL generation and confirmation pipeline
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. getPresignedUploadUrl generates a URL using the provided key and contentType
|
||||
* 2. The contentType in the presigned URL matches what is sent to the S3 client
|
||||
* 3. The URL is never exposed as a raw R2 key (only served as signed URL)
|
||||
* Test 1 — getPresignedUrl generates URL with correct contentType
|
||||
* Test 2 — getPresignedUrl rejects non-owner (FORBIDDEN)
|
||||
* Test 3 — getPresignedUrl rejects unsupported contentType (Zod BAD_REQUEST)
|
||||
* Test 4 — getPresignedUrl rejects oversized file (Zod BAD_REQUEST)
|
||||
* Test 5 — confirmUpload writes avatarKey to DB
|
||||
* Test 6 — confirmUpload rejects arbitrary key path (Zod BAD_REQUEST)
|
||||
*
|
||||
* Implementation will live in src/lib/r2.ts (Plan 01, Task 2).
|
||||
* Security (T-4-01, T-4-02, T-4-03, T-4-05, T-4-06):
|
||||
* - Ownership assertions tested (T-4-01)
|
||||
* - ContentType restriction tested (T-4-02)
|
||||
* - No real R2 calls — getPresignedUploadUrl is mocked (T-4-03)
|
||||
* - key.startsWith('avatars/') validation tested (T-4-05)
|
||||
* - fileSize <= 10MB validation tested (T-4-06)
|
||||
*
|
||||
* All Prisma calls are mocked — no real DB connections.
|
||||
*/
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { mediaRouter } from "@/trpc/routers/media";
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
describe("Presigned R2 upload URL", () => {
|
||||
it.todo(
|
||||
"getPresignedUploadUrl returns a string URL signed for the given key and contentType"
|
||||
);
|
||||
// Mock server-only — the import guard throws in non-server environments
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
it.todo(
|
||||
"getPresignedUploadUrl encodes the correct ContentType in the presigned URL signature — mismatch causes R2 403"
|
||||
);
|
||||
// Mock getPresignedUploadUrl so no real R2 calls are made (T-4-03)
|
||||
vi.mock("@/lib/r2", () => ({
|
||||
getPresignedUploadUrl: vi
|
||||
.fn()
|
||||
.mockResolvedValue("https://fake-r2.example.com/presigned-url"),
|
||||
}));
|
||||
|
||||
it.todo(
|
||||
"getPresignedUploadUrl sets expiresIn to 300 seconds (5 minutes)"
|
||||
);
|
||||
// Mock Clerk server auth
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
||||
}));
|
||||
|
||||
it.todo(
|
||||
"getPresignedUploadUrl only accepts image/jpeg, image/png, image/webp — rejects other content types"
|
||||
);
|
||||
// Mock the prisma singleton used by trpc/init
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
it.todo(
|
||||
"R2 credentials (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY) are NOT exposed in the presigned URL or client bundle"
|
||||
);
|
||||
import { getPresignedUploadUrl } from "@/lib/r2";
|
||||
|
||||
const OWNER_ID = "owner-1";
|
||||
const ATTACKER_ID = "attacker-999";
|
||||
const PET_ID = "pet-1";
|
||||
const AVATAR_KEY = "avatars/owner-1/pet-1/some-uuid.jpg";
|
||||
|
||||
const MOCK_PET = {
|
||||
id: PET_ID,
|
||||
name: "Buddy",
|
||||
ownerId: OWNER_ID,
|
||||
speciesId: "species-dog",
|
||||
breedId: null,
|
||||
bio: null,
|
||||
adoptionStory: null,
|
||||
avatarKey: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tRPC caller with a mock context.
|
||||
* Bypasses HTTP entirely — calls procedure logic directly.
|
||||
*/
|
||||
function createTestCaller(
|
||||
userId: string,
|
||||
mockPrisma: ReturnType<typeof createMockPrisma>
|
||||
) {
|
||||
type CallerCtx = {
|
||||
userId: string | null;
|
||||
prisma: import("@prisma/client").PrismaClient;
|
||||
};
|
||||
const ctx: CallerCtx = {
|
||||
userId,
|
||||
prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
|
||||
};
|
||||
return mediaRouter.createCaller(ctx);
|
||||
}
|
||||
|
||||
describe("media.getPresignedUrl", () => {
|
||||
let mockPrisma: ReturnType<typeof createMockPrisma>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = createMockPrisma();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("Test 1 — generates presigned URL with correct contentType and scoped key", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OWNER_ID });
|
||||
|
||||
const caller = createTestCaller(OWNER_ID, mockPrisma);
|
||||
const result = await caller.getPresignedUrl({
|
||||
petId: PET_ID,
|
||||
contentType: "image/jpeg",
|
||||
fileSize: 1024,
|
||||
});
|
||||
|
||||
// Assert getPresignedUploadUrl was called with correct arguments
|
||||
expect(getPresignedUploadUrl).toHaveBeenCalledOnce();
|
||||
const [calledKey, calledContentType] = (
|
||||
getPresignedUploadUrl as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0];
|
||||
expect(calledKey).toMatch(new RegExp(`^avatars/${OWNER_ID}/${PET_ID}/`));
|
||||
expect(calledContentType).toBe("image/jpeg");
|
||||
|
||||
// Assert returned URL matches mock
|
||||
expect(result.presignedUrl).toBe("https://fake-r2.example.com/presigned-url");
|
||||
|
||||
// Assert key is scoped under avatars/
|
||||
expect(result.key).toMatch(/^avatars\//);
|
||||
});
|
||||
|
||||
it("Test 2 — rejects non-owner with FORBIDDEN (T-4-01)", async () => {
|
||||
// Pet belongs to OWNER_ID, but attacker is calling
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OWNER_ID });
|
||||
|
||||
const caller = createTestCaller(ATTACKER_ID, mockPrisma);
|
||||
|
||||
await expect(
|
||||
caller.getPresignedUrl({
|
||||
petId: PET_ID,
|
||||
contentType: "image/jpeg",
|
||||
fileSize: 1024,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
// No presigned URL should be generated for non-owner
|
||||
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Test 3 — rejects unsupported contentType image/gif (Zod BAD_REQUEST, T-4-02)", async () => {
|
||||
const caller = createTestCaller(OWNER_ID, mockPrisma);
|
||||
|
||||
await expect(
|
||||
caller.getPresignedUrl({
|
||||
petId: PET_ID,
|
||||
contentType: "image/gif" as "image/jpeg",
|
||||
fileSize: 1024,
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
// No DB lookup or presigned URL should be generated for invalid type
|
||||
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Test 4 — rejects oversized file (11MB > 10MB limit, T-4-06)", async () => {
|
||||
const caller = createTestCaller(OWNER_ID, mockPrisma);
|
||||
|
||||
await expect(
|
||||
caller.getPresignedUrl({
|
||||
petId: PET_ID,
|
||||
contentType: "image/jpeg",
|
||||
fileSize: 11 * 1024 * 1024, // 11 MB — exceeds the 10 MB limit
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
expect(getPresignedUploadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("media.confirmUpload", () => {
|
||||
let mockPrisma: ReturnType<typeof createMockPrisma>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = createMockPrisma();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("Test 5 — writes avatarKey to DB for pet owner", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OWNER_ID });
|
||||
mockPrisma.pet.update.mockResolvedValue({
|
||||
...MOCK_PET,
|
||||
avatarKey: AVATAR_KEY,
|
||||
});
|
||||
|
||||
const caller = createTestCaller(OWNER_ID, mockPrisma);
|
||||
const result = await caller.confirmUpload({
|
||||
petId: PET_ID,
|
||||
key: AVATAR_KEY,
|
||||
});
|
||||
|
||||
// Assert DB update was called with the correct avatarKey
|
||||
expect(mockPrisma.pet.update).toHaveBeenCalledWith({
|
||||
where: { id: PET_ID },
|
||||
data: { avatarKey: AVATAR_KEY },
|
||||
});
|
||||
|
||||
// Assert return value contains the avatarKey
|
||||
expect(result).toMatchObject({ avatarKey: AVATAR_KEY });
|
||||
});
|
||||
|
||||
it("Test 6 — rejects arbitrary key path not starting with 'avatars/' (T-4-05)", async () => {
|
||||
const caller = createTestCaller(OWNER_ID, mockPrisma);
|
||||
|
||||
await expect(
|
||||
caller.confirmUpload({
|
||||
petId: PET_ID,
|
||||
key: "malicious/../../etc/passwd",
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
// No DB write should occur for path traversal attempts
|
||||
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Bonus — rejects non-owner confirmUpload with FORBIDDEN (T-4-01)", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OWNER_ID });
|
||||
|
||||
const caller = createTestCaller(ATTACKER_ID, mockPrisma);
|
||||
|
||||
await expect(
|
||||
caller.confirmUpload({
|
||||
petId: PET_ID,
|
||||
key: AVATAR_KEY,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user