From 81c2a19a682a55b3ad4d6f798012a135f4481352 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:23:07 -0500 Subject: [PATCH 1/6] fix(web): reserve comment media objects atomically before issuing uploads --- .../api/upload/[...route]/comment-media.ts | 65 ++++++++++------ apps/web/lib/comment-media-quota.ts | 78 +++++++++++++++++++ apps/web/lib/rate-limit.ts | 6 ++ 3 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 apps/web/lib/comment-media-quota.ts diff --git a/apps/web/app/api/upload/[...route]/comment-media.ts b/apps/web/app/api/upload/[...route]/comment-media.ts index 7dd712868a9..d15b4b42ca9 100644 --- a/apps/web/app/api/upload/[...route]/comment-media.ts +++ b/apps/web/app/api/upload/[...route]/comment-media.ts @@ -17,7 +17,12 @@ import { Hono } from "hono"; import { z } from "zod"; import { withAuth } from "@/app/api/utils"; import { commentMediaKeys } from "@/lib/comment-media"; +import { + MAX_MEDIA_OBJECTS_PER_VIDEO, + reserveCommentMediaObjects, +} from "@/lib/comment-media-quota"; import { canUseMediaComments } from "@/lib/media-comments"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; import { stringOrNumberOptional } from "@/utils/zod"; import { zValidator } from "@/utils/zod-validator"; @@ -28,12 +33,11 @@ const runPromiseAnyEnv = runPromise as ( effect: Effect.Effect, ) => Promise; -// Stateless storage-growth bounds. Uploads land in the video owner's -// (possibly BYO) bucket, so an unbounded viewer must not be able to fill it -// unattended: per-commenter comment-rate, per-video object count, and a -// per-object multipart ceiling (single PUTs are capped at 5GB by S3 itself). +// Storage-growth bounds. Uploads land in the video owner's (possibly BYO) +// bucket, so an unbounded viewer must not be able to fill it unattended: +// per-commenter comment-rate, per-video object count, and a per-object +// multipart ceiling (single PUTs are capped at 5GB by S3 itself). const MAX_MEDIA_COMMENTS_PER_HOUR = 30; -const MAX_MEDIA_OBJECTS_PER_VIDEO = 400; const MAX_MEDIA_OBJECT_BYTES = 20 * 1024 * 1024 * 1024; app.post( @@ -96,26 +100,19 @@ app.post( return c.json({ error: "Too many media comments, slow down" }); } - const [bucket] = yield* Storage.getAccessForVideo(video); - - // The comment-row throttle above only counts finished comments; an - // initiate-and-PUT loop that never creates rows would bypass it. The - // object count under the comments prefix is the ground truth for bytes - // actually landed in the owner's (possibly BYO) bucket, so it is the - // bound that matters. - const existingObjects = yield* bucket.listObjects({ - prefix: `${video.ownerId}/${video.id}/comments/`, - maxKeys: MAX_MEDIA_OBJECTS_PER_VIDEO, - }); - if ( - (existingObjects.Contents?.length ?? 0) >= MAX_MEDIA_OBJECTS_PER_VIDEO - ) { + const initiatesLimited = yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.COMMENT_MEDIA_UPLOAD, { + key: `comment-media-upload:${user.id}`, + headers: c.req.raw.headers, + }), + ); + if (initiatesLimited) { c.status(429); - return c.json({ - error: "This video has reached its media comment limit", - }); + return c.json({ error: "Too many media comments, slow down" }); } + const [bucket] = yield* Storage.getAccessForVideo(video); + const commentId = Comment.CommentId.make(nanoId()); const keys = commentMediaKeys({ ownerId: video.ownerId, @@ -131,6 +128,30 @@ app.post( } const { contentType, key, thumbnailKey } = keys; + // The comment-row throttle above only counts finished comments; an + // initiate-and-PUT loop that never creates rows would bypass it. The + // object count under the comments prefix is the ground truth for bytes + // actually landed in the owner's (possibly BYO) bucket. Uploads that + // have not landed yet are counted through a reservation taken under a + // row lock, so concurrent initiates cannot all pass the same snapshot. + const existingObjects = yield* bucket.listObjects({ + prefix: `${video.ownerId}/${video.id}/comments/`, + maxKeys: MAX_MEDIA_OBJECTS_PER_VIDEO, + }); + const reserved = yield* Effect.tryPromise(() => + reserveCommentMediaObjects({ + videoId: video.id, + objects: thumbnailKey ? 2 : 1, + storedObjects: existingObjects.Contents?.length ?? 0, + }), + ); + if (!reserved) { + c.status(429); + return c.json({ + error: "This video has reached its media comment limit", + }); + } + // Drive rejects presigned POST, so upload targets are always PUT-style; // createUploadTarget returns the provider-appropriate target shape. const thumbnailUpload = thumbnailKey diff --git a/apps/web/lib/comment-media-quota.ts b/apps/web/lib/comment-media-quota.ts new file mode 100644 index 00000000000..b90394682f2 --- /dev/null +++ b/apps/web/lib/comment-media-quota.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { db } from "@cap/database"; +import { mediaProcessingBudgets } from "@cap/database/schema"; +import { asc, inArray, sql } from "drizzle-orm"; + +export const MAX_MEDIA_OBJECTS_PER_VIDEO = 400; + +// Matches the comment media upload token lifetime, so every upload that can +// still land was reserved in the current or the previous window. +export const COMMENT_MEDIA_RESERVATION_WINDOW_MS = 6 * 60 * 60 * 1000; + +const reservationKey = (videoId: string, windowIndex: number) => + createHash("sha256") + .update(JSON.stringify(["comment-media", videoId, windowIndex])) + .digest("hex"); + +export async function reserveCommentMediaObjects(input: { + videoId: string; + objects: number; + storedObjects: number; + now?: Date; +}) { + const now = (input.now ?? new Date()).getTime(); + const windowIndex = Math.floor(now / COMMENT_MEDIA_RESERVATION_WINDOW_MS); + const currentKey = reservationKey(input.videoId, windowIndex); + const windows = [ + { id: currentKey, index: windowIndex }, + { + id: reservationKey(input.videoId, windowIndex - 1), + index: windowIndex - 1, + }, + ].sort((left, right) => left.id.localeCompare(right.id)); + + return db().transaction(async (tx) => { + await tx + .insert(mediaProcessingBudgets) + .values( + windows.map((window) => ({ + id: window.id, + limitBytes: MAX_MEDIA_OBJECTS_PER_VIDEO, + expiresAt: new Date( + (window.index + 2) * COMMENT_MEDIA_RESERVATION_WINDOW_MS, + ), + })), + ) + .onDuplicateKeyUpdate({ + set: { id: sql`${mediaProcessingBudgets.id}` }, + }); + const locked = await tx + .select() + .from(mediaProcessingBudgets) + .where( + inArray( + mediaProcessingBudgets.id, + windows.map((window) => window.id), + ), + ) + .orderBy(asc(mediaProcessingBudgets.id)) + .for("update"); + if (locked.length !== windows.length) + throw new Error("Comment media quota is unavailable"); + + const reserved = locked.reduce((sum, row) => sum + row.reservedBytes, 0); + if ( + input.storedObjects + reserved + input.objects > + MAX_MEDIA_OBJECTS_PER_VIDEO + ) + return false; + + await tx + .update(mediaProcessingBudgets) + .set({ + reservedBytes: sql`${mediaProcessingBudgets.reservedBytes} + ${input.objects}`, + }) + .where(inArray(mediaProcessingBudgets.id, [currentKey])); + return true; + }); +} diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index 2b46bf8327b..626a3a4250d 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -207,6 +207,8 @@ export const RATE_LIMIT_IDS = { COLLECTION_PASSWORD_PER_VIEWER: "rl_collection_password_per_viewer", /** Comments, replies and reactions per author. Suggested: 60 / 1m per key (user). */ COMMENT_CREATE: "rl_comment_create", + /** Comment media upload starts per viewer. Suggested: 60 / 1h per key (user). */ + COMMENT_MEDIA_UPLOAD: "rl_comment_media_upload", /** Video edit saves and restores per user: each one re-encodes the video and regenerates AI output. Suggested: 30 / 1h per key (user). */ VIDEO_EDIT: "rl_video_edit", /** Share-page downloads by viewers who are not members. Suggested: 30 / 1m per IP. */ @@ -269,6 +271,10 @@ const FALLBACK_RATE_LIMITS: Record = { limit: 1, windowMs: 60 * MINUTE_MS, }, + [RATE_LIMIT_IDS.COMMENT_MEDIA_UPLOAD]: { + limit: 60, + windowMs: 60 * MINUTE_MS, + }, [RATE_LIMIT_IDS.VIDEO_EDIT]: { limit: 30, windowMs: 60 * MINUTE_MS }, [RATE_LIMIT_IDS.VIEWER_DOWNLOAD]: { limit: 30, windowMs: MINUTE_MS }, [RATE_LIMIT_IDS.MOBILE_EVENTS]: { limit: 30, windowMs: MINUTE_MS }, From c568148eb0edf8c7ca354c1fa6a1d4fd7efeec70 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:23:07 -0500 Subject: [PATCH 2/6] test(web): cover the comment media quota under concurrent initiates --- .../comment-media-initiate-limits.test.ts | 166 ++++++++++++++++ .../unit/comment-media-quota.test.ts | 184 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 apps/web/__tests__/unit/comment-media-initiate-limits.test.ts create mode 100644 apps/web/__tests__/unit/comment-media-quota.test.ts diff --git a/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts b/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts new file mode 100644 index 00000000000..445b309129c --- /dev/null +++ b/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts @@ -0,0 +1,166 @@ +import { Effect, Layer, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + reserve: vi.fn(), + rateLimited: vi.fn(), + listObjects: vi.fn(), + createUploadTarget: vi.fn(), +})); + +const video = { id: "video-1", ownerId: "owner-1" }; + +vi.mock("@/app/api/utils", () => ({ + withAuth: async ( + c: { set: (key: string, value: unknown) => void }, + next: () => Promise, + ) => { + c.set("user", { id: "viewer-1" }); + await next(); + }, +})); +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [] }) }), + }), + }), +})); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "comment-1" })); +vi.mock("@cap/database/schema", () => ({ comments: {} })); +vi.mock("drizzle-orm", () => ({ + and: () => undefined, + eq: () => undefined, + gt: () => undefined, + inArray: () => undefined, +})); +vi.mock("@cap/web-domain", () => ({ + Comment: { CommentId: { make: (value: string) => value } }, + Policy: { withPublicPolicy: () => (effect: unknown) => effect }, + Video: { + VideoId: { make: (value: string) => value }, + NotFoundError: class {}, + }, +})); +vi.mock("@cap/web-backend", () => ({ + createCommentMediaUploadToken: () => "upload-token", + verifyCommentMediaUploadToken: () => null, + makeCurrentUserLayer: () => Layer.empty, + provideOptionalAuth: (effect: unknown) => effect, + VideosRepo: Effect.succeed({ + getById: () => Effect.succeed(Option.some([video])), + }), + VideosPolicy: Effect.succeed({ canView: () => Effect.void }), + Storage: { + getAccessForVideo: () => + Effect.succeed([ + { + provider: "aws", + listObjects: mocks.listObjects, + createUploadTarget: mocks.createUploadTarget, + }, + ]), + }, +})); +vi.mock("@/lib/media-comments", () => ({ + canUseMediaComments: async () => true, +})); +vi.mock("@/lib/comment-media-quota", () => ({ + MAX_MEDIA_OBJECTS_PER_VIDEO: 400, + reserveCommentMediaObjects: mocks.reserve, +})); +vi.mock("@/lib/rate-limit", () => ({ + RATE_LIMIT_IDS: { COMMENT_MEDIA_UPLOAD: "rl_comment_media_upload" }, + isRateLimited: mocks.rateLimited, +})); +vi.mock("@/lib/server", () => ({ runPromise: Effect.runPromise })); + +import { app } from "@/app/api/upload/[...route]/comment-media"; + +const initiate = (body: Record = {}) => + app.request("/initiate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + videoId: "video-1", + kind: "video", + contentType: "video/webm", + mode: "single", + withThumbnail: true, + ...body, + }), + }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.rateLimited.mockResolvedValue(false); + mocks.reserve.mockResolvedValue(true); + mocks.listObjects.mockReturnValue(Effect.succeed({ Contents: [{}, {}, {}] })); + mocks.createUploadTarget.mockImplementation((key: string) => + Effect.succeed({ url: `https://upload.test/${key}` }), + ); +}); + +describe("comment media initiate limits", () => { + it("issues upload targets for a normal media comment", async () => { + const response = await initiate(); + + expect(response.status).toBe(200); + const body = (await response.json()) as { upload: { url: string } }; + expect(body.upload.url).toContain("comments/comment-1/media.webm"); + expect(mocks.reserve).toHaveBeenCalledExactlyOnceWith({ + videoId: "video-1", + objects: 2, + storedObjects: 3, + }); + expect(mocks.rateLimited).toHaveBeenCalledWith( + "rl_comment_media_upload", + expect.objectContaining({ key: "comment-media-upload:viewer-1" }), + ); + }); + + it("reserves one object for audio", async () => { + const response = await initiate({ + kind: "audio", + contentType: "audio/webm", + withThumbnail: false, + }); + + expect(response.status).toBe(200); + expect(mocks.reserve).toHaveBeenCalledWith( + expect.objectContaining({ objects: 1 }), + ); + }); + + it("refuses with a clear error once the video is full", async () => { + mocks.reserve.mockResolvedValue(false); + + const response = await initiate(); + + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: "This video has reached its media comment limit", + }); + expect(mocks.createUploadTarget).not.toHaveBeenCalled(); + }); + + it("refuses a viewer who starts too many uploads", async () => { + mocks.rateLimited.mockResolvedValue(true); + + const response = await initiate(); + + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: "Too many media comments, slow down", + }); + expect(mocks.reserve).not.toHaveBeenCalled(); + expect(mocks.createUploadTarget).not.toHaveBeenCalled(); + }); + + it("does not reserve space for an unsupported content type", async () => { + const response = await initiate({ contentType: "video/x-msvideo" }); + + expect(response.status).toBe(415); + expect(mocks.reserve).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/comment-media-quota.test.ts b/apps/web/__tests__/unit/comment-media-quota.test.ts new file mode 100644 index 00000000000..ee8e8e4ac5e --- /dev/null +++ b/apps/web/__tests__/unit/comment-media-quota.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + COMMENT_MEDIA_RESERVATION_WINDOW_MS, + MAX_MEDIA_OBJECTS_PER_VIDEO, + reserveCommentMediaObjects, +} from "@/lib/comment-media-quota"; + +type Row = { + id: string; + limitBytes: number; + reservedBytes: number; + expiresAt: Date; +}; + +const state = vi.hoisted(() => ({ + rows: new Map(), + locks: new Map>(), + lockRows: true, +})); + +const pause = () => new Promise((resolve) => setTimeout(resolve, 0)); + +vi.mock("@cap/database/schema", () => ({ + mediaProcessingBudgets: { id: "id", reservedBytes: "reservedBytes" }, +})); +vi.mock("drizzle-orm", () => ({ + asc: (value: unknown) => value, + inArray: (_column: unknown, ids: string[]) => ids, + sql: (_strings: TemplateStringsArray, ...values: unknown[]) => values, +})); +vi.mock("@cap/database", () => ({ + db: () => ({ + transaction: async (run: (tx: unknown) => Promise) => { + const releases: (() => void)[] = []; + const acquire = async (id: string) => { + const previous = state.locks.get(id) ?? Promise.resolve(); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + state.locks.set( + id, + previous.then(() => held), + ); + await previous; + releases.push(release); + }; + try { + return await run({ + insert: () => ({ + values: (rows: Omit[]) => ({ + onDuplicateKeyUpdate: async () => { + await pause(); + for (const row of rows) + if (!state.rows.has(row.id)) + state.rows.set(row.id, { ...row, reservedBytes: 0 }); + }, + }), + }), + select: () => ({ + from: () => ({ + where: (ids: string[]) => ({ + orderBy: () => ({ + for: async () => { + if (state.lockRows) for (const id of ids) await acquire(id); + await pause(); + return ids + .map((id) => state.rows.get(id)) + .filter(Boolean) + .map((row) => ({ ...(row as Row) })); + }, + }), + }), + }), + }), + update: () => ({ + set: (values: { reservedBytes: [unknown, number] }) => ({ + where: async (ids: string[]) => { + await pause(); + for (const id of ids) { + const row = state.rows.get(id); + if (row) row.reservedBytes += values.reservedBytes[1]; + } + }, + }), + }), + }); + } finally { + for (const release of releases) release(); + } + }, + }), +})); + +const now = new Date(COMMENT_MEDIA_RESERVATION_WINDOW_MS * 1000 + 60_000); + +const reserve = ( + input: Partial[0]> = {}, +) => + reserveCommentMediaObjects({ + videoId: "video-1", + objects: 1, + storedObjects: 0, + now, + ...input, + }); + +beforeEach(() => { + state.rows.clear(); + state.locks.clear(); + state.lockRows = true; +}); + +describe("comment media object quota", () => { + it("lets a normal upload through on a video with room", async () => { + await expect(reserve()).resolves.toBe(true); + await expect(reserve({ objects: 2, storedObjects: 3 })).resolves.toBe(true); + }); + + it("refuses an upload that would pass the per-video cap", async () => { + await expect( + reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO }), + ).resolves.toBe(false); + await expect( + reserve({ objects: 2, storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }), + ).resolves.toBe(false); + await expect( + reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }), + ).resolves.toBe(true); + await expect( + reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }), + ).resolves.toBe(false); + }); + + it("never issues more than the cap to concurrent initiates", async () => { + const results = await Promise.all( + Array.from({ length: 500 }, () => reserve()), + ); + expect(results.filter(Boolean)).toHaveLength(MAX_MEDIA_OBJECTS_PER_VIDEO); + }); + + it("counts thumbnails against the cap under concurrency", async () => { + const results = await Promise.all( + Array.from({ length: 300 }, () => reserve({ objects: 2 })), + ); + expect(results.filter(Boolean)).toHaveLength( + MAX_MEDIA_OBJECTS_PER_VIDEO / 2, + ); + }); + + it("needs the row lock to hold the cap", async () => { + state.lockRows = false; + const results = await Promise.all( + Array.from({ length: 500 }, () => reserve()), + ); + expect(results.filter(Boolean).length).toBeGreaterThan( + MAX_MEDIA_OBJECTS_PER_VIDEO, + ); + }); + + it("keeps counting uploads that can still land and frees them after", async () => { + await Promise.all( + Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()), + ); + await expect(reserve()).resolves.toBe(false); + + const nextWindow = new Date( + now.getTime() + COMMENT_MEDIA_RESERVATION_WINDOW_MS, + ); + await expect(reserve({ now: nextWindow })).resolves.toBe(false); + + const afterTokensExpire = new Date( + now.getTime() + 2 * COMMENT_MEDIA_RESERVATION_WINDOW_MS, + ); + await expect(reserve({ now: afterTokensExpire })).resolves.toBe(true); + }); + + it("keeps each video's reservations separate", async () => { + await Promise.all( + Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()), + ); + await expect(reserve({ videoId: "video-2" })).resolves.toBe(true); + }); +}); From 2929ff3a31452fbe4d7679c62d35fa206759160c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:33:28 -0500 Subject: [PATCH 3/6] fix(web): keep comment media reservations counted past their token --- apps/web/lib/comment-media-quota.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/comment-media-quota.ts b/apps/web/lib/comment-media-quota.ts index b90394682f2..6cb525a4fe9 100644 --- a/apps/web/lib/comment-media-quota.ts +++ b/apps/web/lib/comment-media-quota.ts @@ -5,9 +5,13 @@ import { asc, inArray, sql } from "drizzle-orm"; export const MAX_MEDIA_OBJECTS_PER_VIDEO = 400; -// Matches the comment media upload token lifetime, so every upload that can -// still land was reserved in the current or the previous window. +// Matches the comment media upload token lifetime. A reservation counts in its +// own window and the two after it, so it outlives its token by at least one +// full window. That margin covers the bucket listing taken before the +// reservation and the token minted after it falling on either side of a window +// boundary. export const COMMENT_MEDIA_RESERVATION_WINDOW_MS = 6 * 60 * 60 * 1000; +const COUNTED_WINDOWS = 3; const reservationKey = (videoId: string, windowIndex: number) => createHash("sha256") @@ -23,13 +27,10 @@ export async function reserveCommentMediaObjects(input: { const now = (input.now ?? new Date()).getTime(); const windowIndex = Math.floor(now / COMMENT_MEDIA_RESERVATION_WINDOW_MS); const currentKey = reservationKey(input.videoId, windowIndex); - const windows = [ - { id: currentKey, index: windowIndex }, - { - id: reservationKey(input.videoId, windowIndex - 1), - index: windowIndex - 1, - }, - ].sort((left, right) => left.id.localeCompare(right.id)); + const windows = Array.from({ length: COUNTED_WINDOWS }, (_, age) => ({ + id: reservationKey(input.videoId, windowIndex - age), + index: windowIndex - age, + })).sort((left, right) => left.id.localeCompare(right.id)); return db().transaction(async (tx) => { await tx @@ -39,7 +40,8 @@ export async function reserveCommentMediaObjects(input: { id: window.id, limitBytes: MAX_MEDIA_OBJECTS_PER_VIDEO, expiresAt: new Date( - (window.index + 2) * COMMENT_MEDIA_RESERVATION_WINDOW_MS, + (window.index + COUNTED_WINDOWS) * + COMMENT_MEDIA_RESERVATION_WINDOW_MS, ), })), ) From 34ac758bdab45fdaa570a82a554c8d41af4cc4d6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:33:28 -0500 Subject: [PATCH 4/6] test(web): cover comment media reservations across window boundaries --- .../unit/comment-media-quota.test.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/web/__tests__/unit/comment-media-quota.test.ts b/apps/web/__tests__/unit/comment-media-quota.test.ts index ee8e8e4ac5e..5174b94eac5 100644 --- a/apps/web/__tests__/unit/comment-media-quota.test.ts +++ b/apps/web/__tests__/unit/comment-media-quota.test.ts @@ -158,21 +158,23 @@ describe("comment media object quota", () => { ); }); - it("keeps counting uploads that can still land and frees them after", async () => { + it("keeps counting a reservation past its token across window boundaries", async () => { + const W = COMMENT_MEDIA_RESERVATION_WINDOW_MS; + const lastMomentOfWindow = new Date(2 * W - 1); await Promise.all( - Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()), + Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => + reserve({ now: lastMomentOfWindow }), + ), ); - await expect(reserve()).resolves.toBe(false); - const nextWindow = new Date( - now.getTime() + COMMENT_MEDIA_RESERVATION_WINDOW_MS, - ); - await expect(reserve({ now: nextWindow })).resolves.toBe(false); + for (const at of [ + lastMomentOfWindow.getTime() + 1, + lastMomentOfWindow.getTime() + W + 60_000, + 3 * W + W - 1, + ]) + await expect(reserve({ now: new Date(at) })).resolves.toBe(false); - const afterTokensExpire = new Date( - now.getTime() + 2 * COMMENT_MEDIA_RESERVATION_WINDOW_MS, - ); - await expect(reserve({ now: afterTokensExpire })).resolves.toBe(true); + await expect(reserve({ now: new Date(4 * W) })).resolves.toBe(true); }); it("keeps each video's reservations separate", async () => { From 2aa6a0c38a7e0fdda0398258dcf3942cc90cdecc Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 08:14:15 -0500 Subject: [PATCH 5/6] test(web): prove the comment media quota against a real database and bucket --- .../comment-media-quota-database.test.ts | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 apps/web/__tests__/integration/comment-media-quota-database.test.ts diff --git a/apps/web/__tests__/integration/comment-media-quota-database.test.ts b/apps/web/__tests__/integration/comment-media-quota-database.test.ts new file mode 100644 index 00000000000..c636d4c96bf --- /dev/null +++ b/apps/web/__tests__/integration/comment-media-quota-database.test.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; +import { + CreateBucketCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { drizzle } from "drizzle-orm/mysql2"; +import { Effect, Layer, Option } from "effect"; +import { createPool, type Pool } from "mysql2/promise"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +// Drives the real /initiate handler against a real MySQL database and a real +// S3-compatible bucket. Auth, the video lookup and the plan check are the only +// stubs; the comment throttle, the rate limiter, the quota reservation and the +// bucket listing all run for real. + +const fixture = vi.hoisted(() => ({ + database: undefined as unknown, + s3: undefined as unknown, + bucket: "", + ownerId: "", + videoId: "", +})); + +vi.mock("@cap/database", () => ({ + db: () => { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; + }, +})); +vi.mock("@/app/api/utils", () => ({ + withAuth: async ( + c: { + req: { header: (name: string) => string | undefined }; + set: (key: string, value: unknown) => void; + }, + next: () => Promise, + ) => { + c.set("user", { id: c.req.header("x-test-user") }); + await next(); + }, +})); +vi.mock("@cap/web-domain", () => ({ + Comment: { CommentId: { make: (value: string) => value } }, + Policy: { withPublicPolicy: () => (effect: unknown) => effect }, + Video: { + VideoId: { make: (value: string) => value }, + NotFoundError: class {}, + }, +})); +vi.mock("@cap/web-backend", () => { + const client = () => fixture.s3 as S3Client; + return { + createCommentMediaUploadToken: () => "upload-token", + verifyCommentMediaUploadToken: () => null, + makeCurrentUserLayer: () => Layer.empty, + provideOptionalAuth: (effect: unknown) => effect, + VideosRepo: Effect.succeed({ + getById: () => + Effect.succeed( + Option.some([{ id: fixture.videoId, ownerId: fixture.ownerId }]), + ), + }), + VideosPolicy: Effect.succeed({ canView: () => Effect.void }), + Storage: { + getAccessForVideo: () => + Effect.succeed([ + { + provider: "aws", + listObjects: (input: { prefix: string; maxKeys: number }) => + Effect.promise(() => + client().send( + new ListObjectsV2Command({ + Bucket: fixture.bucket, + Prefix: input.prefix, + MaxKeys: input.maxKeys, + }), + ), + ), + createUploadTarget: ( + key: string, + options: { contentType: string }, + ) => + Effect.promise(async () => ({ + url: await getSignedUrl( + client(), + new PutObjectCommand({ + Bucket: fixture.bucket, + Key: key, + ContentType: options.contentType, + }), + { expiresIn: 600 }, + ), + method: "PUT", + })), + }, + ]), + }, + }; +}); +vi.mock("@/lib/media-comments", () => ({ + canUseMediaComments: async () => true, +})); +vi.mock("@/lib/server", () => ({ runPromise: Effect.runPromise })); + +const { app } = await import("@/app/api/upload/[...route]/comment-media"); + +const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL; +const s3Endpoint = process.env.CAP_COMMENT_MEDIA_TEST_S3_ENDPOINT; +const id = () => randomUUID().replaceAll("-", "").slice(0, 15); + +type Initiated = { + key: string; + upload: { url: string }; + thumbnailUpload: { url: string } | null; +}; + +const initiate = (user: string, body: Record = {}) => + app.request("/initiate", { + method: "POST", + headers: { "content-type": "application/json", "x-test-user": user }, + body: JSON.stringify({ + videoId: fixture.videoId, + kind: "video", + contentType: "video/webm", + mode: "single", + withThumbnail: true, + ...body, + }), + }); + +const put = (url: string, contentType: string, bytes: Uint8Array) => + fetch(url, { + method: "PUT", + headers: { "content-type": contentType }, + body: bytes, + }); + +const storedCount = async () => { + const listed = await (fixture.s3 as S3Client).send( + new ListObjectsV2Command({ + Bucket: fixture.bucket, + Prefix: `${fixture.ownerId}/${fixture.videoId}/comments/`, + }), + ); + return listed.KeyCount ?? 0; +}; + +const freshVideo = () => { + fixture.ownerId = `owner-${id()}`; + fixture.videoId = `video-${id()}`; +}; + +describe.runIf(Boolean(databaseUrl && s3Endpoint))( + "comment media initiate against a real database and bucket", + () => { + let pool: Pool; + + beforeAll(async () => { + if (!databaseUrl || !s3Endpoint) + throw new Error("Missing isolated test database or bucket."); + const url = new URL(databaseUrl); + if ( + url.protocol !== "mysql:" || + !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || + !/^\/cap_infra_[a-z0-9_]+$/.test(url.pathname) + ) + throw new Error("These tests require a session-scoped local database."); + if (!["127.0.0.1", "localhost"].includes(new URL(s3Endpoint).hostname)) + throw new Error("These tests require a local bucket."); + pool = createPool({ uri: databaseUrl, connectionLimit: 16 }); + fixture.database = drizzle(pool); + fixture.s3 = new S3Client({ + endpoint: s3Endpoint, + region: "us-east-1", + forcePathStyle: true, + credentials: { + accessKeyId: process.env.CAP_COMMENT_MEDIA_TEST_S3_KEY ?? "", + secretAccessKey: process.env.CAP_COMMENT_MEDIA_TEST_S3_SECRET ?? "", + }, + }); + fixture.bucket = `comment-media-test-${id()}`; + await (fixture.s3 as S3Client).send( + new CreateBucketCommand({ Bucket: fixture.bucket }), + ); + }); + + afterAll(async () => { + await pool?.end(); + }); + + it("uploads a normal video, thumbnail and audio comment end to end", async () => { + freshVideo(); + const user = `viewer-${id()}`; + + const video = await initiate(user); + expect(video.status).toBe(200); + const videoBody = (await video.json()) as Initiated; + expect(videoBody.thumbnailUpload).not.toBeNull(); + expect( + (await put(videoBody.upload.url, "video/webm", new Uint8Array(4096))) + .status, + ).toBe(200); + expect( + ( + await put( + videoBody.thumbnailUpload?.url ?? "", + "image/jpeg", + new Uint8Array(512), + ) + ).status, + ).toBe(200); + + const audio = await initiate(user, { + kind: "audio", + contentType: "audio/webm", + withThumbnail: false, + }); + expect(audio.status).toBe(200); + const audioBody = (await audio.json()) as Initiated; + expect(audioBody.thumbnailUpload).toBeNull(); + expect( + (await put(audioBody.upload.url, "audio/webm", new Uint8Array(2048))) + .status, + ).toBe(200); + + const head = await (fixture.s3 as S3Client).send( + new HeadObjectCommand({ Bucket: fixture.bucket, Key: videoBody.key }), + ); + expect(head.ContentLength).toBe(4096); + expect(await storedCount()).toBe(3); + }); + + it("never refuses a viewer well below the quota", async () => { + freshVideo(); + const user = `viewer-${id()}`; + for (let index = 0; index < 30; index++) { + const response = await initiate(user); + expect(response.status).toBe(200); + const body = (await response.json()) as Initiated; + expect( + (await put(body.upload.url, "video/webm", new Uint8Array(64))).status, + ).toBe(200); + } + expect(await storedCount()).toBe(30); + }); + + it("lets exactly one of many concurrent uploads take the last slots", async () => { + freshVideo(); + const prefix = `${fixture.ownerId}/${fixture.videoId}/comments`; + // 398 objects already stored: room for one video plus its thumbnail. + for (let batch = 0; batch < 398; batch += 50) + await Promise.all( + Array.from({ length: Math.min(50, 398 - batch) }, (_, offset) => + (fixture.s3 as S3Client).send( + new PutObjectCommand({ + Bucket: fixture.bucket, + Key: `${prefix}/seed-${batch + offset}/media.webm`, + Body: new Uint8Array(1), + }), + ), + ), + ); + expect(await storedCount()).toBe(398); + + const responses = await Promise.all( + Array.from({ length: 8 }, () => initiate(`viewer-${id()}`)), + ); + const statuses = responses.map((response) => response.status).sort(); + expect(statuses).toEqual([200, 429, 429, 429, 429, 429, 429, 429]); + for (const response of responses) + if (response.status === 429) + expect(await response.json()).toEqual({ + error: "This video has reached its media comment limit", + }); + }); + }, +); From 38e7b6d4886096fc586276a0761a381aab5433ca Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 08:33:33 -0500 Subject: [PATCH 6/6] test(web): send comment media test uploads as a Blob body --- .../__tests__/integration/comment-media-quota-database.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/__tests__/integration/comment-media-quota-database.test.ts b/apps/web/__tests__/integration/comment-media-quota-database.test.ts index c636d4c96bf..db357605314 100644 --- a/apps/web/__tests__/integration/comment-media-quota-database.test.ts +++ b/apps/web/__tests__/integration/comment-media-quota-database.test.ts @@ -136,7 +136,7 @@ const put = (url: string, contentType: string, bytes: Uint8Array) => fetch(url, { method: "PUT", headers: { "content-type": contentType }, - body: bytes, + body: new Blob([bytes as Uint8Array]), }); const storedCount = async () => {