From 62b7cdaf1e3aeb2202d1cf765a48b0c973387b8c Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 6 Jun 2026 11:11:40 +0200 Subject: [PATCH] test(01-04): implement 7 media upload unit tests covering security and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/__tests__/media-upload.test.ts | 243 ++++++++++++++++++++++++++--- 1 file changed, 219 insertions(+), 24 deletions(-) diff --git a/src/__tests__/media-upload.test.ts b/src/__tests__/media-upload.test.ts index 132230f..62adb95 100644 --- a/src/__tests__/media-upload.test.ts +++ b/src/__tests__/media-upload.test.ts @@ -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 +) { + 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; + + 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 + ).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; + + 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(); + }); });