diff --git a/.github/workflows/recording-reliability.yml b/.github/workflows/recording-reliability.yml index 492552d0613..5da9883fb10 100644 --- a/.github/workflows/recording-reliability.yml +++ b/.github/workflows/recording-reliability.yml @@ -58,4 +58,5 @@ jobs: __tests__/unit/recording-prepare.test.ts \ __tests__/unit/s3-bucket-connections.test.ts \ __tests__/unit/media-processing-workflows.test.ts \ - __tests__/unit/new-media-comment-thumbnail.test.ts + __tests__/unit/new-media-comment-thumbnail.test.ts \ + __tests__/unit/comment-reply-parent.test.ts diff --git a/apps/web/__tests__/unit/comment-reply-parent.test.ts b/apps/web/__tests__/unit/comment-reply-parent.test.ts new file mode 100644 index 00000000000..793464700ab --- /dev/null +++ b/apps/web/__tests__/unit/comment-reply-parent.test.ts @@ -0,0 +1,180 @@ +import * as Db from "@cap/database/schema"; +import type { SQL } from "drizzle-orm"; +import { MySqlDialect, type MySqlTable } from "drizzle-orm/mysql-core"; +import { Effect, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const videoId = "accessible-video"; +const commentId = "new-comment"; +const mediaKey = `owner/${videoId}/comments/${commentId}/media.webm`; + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(async () => undefined), + state: { parents: [] as Row[], inserted: [] as Row[] }, +})); + +const dialect = new MySqlDialect(); + +function matchesParent(condition: SQL, row: Row) { + const { sql, params } = dialect.sqlToQuery(condition); + let index = 0; + return sql + .replaceAll(/[()]/g, "") + .split(" and ") + .every((clause) => { + const match = /^`comments`\.`([^`]+)` = \?$/.exec(clause); + if (!match?.[1]) throw new Error(`Unsupported predicate: ${clause}`); + return row[match[1]] === params[index++]; + }); +} + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: (table: MySqlTable) => ({ + where: (condition: SQL) => ({ + limit: async () => { + if (table === Db.videos) return [{ id: videoId }]; + if (table !== Db.comments) throw new Error("Unexpected table"); + return mocks.state.parents.filter((row) => + matchesParent(condition, row), + ); + }, + }), + }), + }), + insert: (table: MySqlTable) => ({ + values: async (row: Row) => { + if (table !== Db.comments) throw new Error("Unexpected insert"); + mocks.state.inserted.push(row); + }, + }), + }), +})); +vi.mock("@cap/database/helpers", async (importOriginal) => ({ + ...(await importOriginal()), + nanoId: () => commentId, +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "viewer", name: "Viewer" }), +})); +vi.mock("@cap/web-domain", () => ({ + Comment: { CommentId: { make: (id: string) => id } }, + Policy: { withPublicPolicy: () => (effect: unknown) => effect }, +})); +vi.mock("@cap/web-backend", () => ({ + provideOptionalAuth: (effect: unknown) => effect, + verifyCommentMediaUploadToken: () => ({ + userId: "viewer", + videoId, + commentId, + kind: "audio", + key: mediaKey, + thumbnailKey: null, + contentType: "audio/webm", + }), + VideosRepo: Effect.succeed({ + getById: () => + Effect.succeed(Option.some([{ id: videoId, ownerId: "owner" }])), + }), + VideosPolicy: Effect.succeed({ canView: () => Effect.void }), + Storage: { + getAccessForVideo: () => + Effect.succeed([ + { headObject: () => Effect.succeed({ ContentLength: 2048 }) }, + ]), + }, +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/comment-media", () => ({ sanitizeWaveform: () => undefined })); +vi.mock("@/lib/media-comments", () => ({ + canUseMediaComments: async () => true, +})); +vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify })); +vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit })); + +import { newComment } from "@/actions/videos/new-comment"; +import { newMediaComment } from "@/actions/videos/new-media-comment"; + +function postText(parentCommentId = "parent") { + return newComment({ + content: "A reply", + videoId: videoId as never, + type: "text", + authorImage: null, + parentCommentId: parentCommentId as never, + timestamp: null, + }); +} + +function postMedia(parentCommentId = "parent") { + return newMediaComment({ + videoId: videoId as never, + commentId: commentId as never, + uploadToken: "token", + kind: "audio", + durationSeconds: 3, + parentCommentId: parentCommentId as never, + timestamp: null, + authorImage: null, + }); +} + +const rejectedParents = [ + { + name: "belongs to another video", + parents: [{ id: "parent", videoId: "private-video", type: "text" }], + }, + { name: "does not exist", parents: [] }, +]; + +beforeEach(() => { + mocks.state.parents = [{ id: "parent", videoId, type: "text" }]; + mocks.state.inserted = []; + mocks.notify.mockClear(); +}); + +describe.each([ + { action: "newComment", post: postText }, + { action: "newMediaComment", post: postMedia }, +])("$action reply parent authorization", ({ post }) => { + it.each(rejectedParents)( + "rejects a parent that $name", + async ({ parents }) => { + mocks.state.parents = parents; + await expect(post()).rejects.toThrow("Comment not found"); + expect(mocks.state.inserted).toEqual([]); + expect(mocks.notify).not.toHaveBeenCalled(); + }, + ); + + it.each(["text", "emoji", "video", "audio"])( + "preserves replies to a %s comment on the same video", + async (type) => { + mocks.state.parents = [{ id: "parent", videoId, type }]; + await expect(post()).resolves.toMatchObject({ + parentCommentId: "parent", + videoId, + }); + expect(mocks.state.inserted).toHaveLength(1); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: "reply", + parentCommentId: "parent", + videoId, + }), + ); + }, + ); + + it("preserves top-level comments", async () => { + mocks.state.parents = []; + await expect(post("")).resolves.toMatchObject({ parentCommentId: "" }); + expect(mocks.state.inserted).toHaveLength(1); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "comment" }), + ); + }); +}); diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 8c2cf7ab573..616ac95a87a 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -10,6 +10,7 @@ import { Comment, Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Exit } from "effect"; import { revalidatePath } from "next/cache"; +import { isReplyParentOnVideo } from "@/lib/comment-reply-parent"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; @@ -68,6 +69,12 @@ export async function newComment(data: { throw new Error("Video not found"); } + if ( + parentCommentId && + !(await isReplyParentOnVideo(parentCommentId, videoId)) + ) + throw new Error("Comment not found"); + const id = Comment.CommentId.make(nanoId()); const newComment = { diff --git a/apps/web/actions/videos/new-media-comment.ts b/apps/web/actions/videos/new-media-comment.ts index 2461b0a6ce0..77e460bee8a 100644 --- a/apps/web/actions/videos/new-media-comment.ts +++ b/apps/web/actions/videos/new-media-comment.ts @@ -15,6 +15,7 @@ import { type Comment, Policy } from "@cap/web-domain"; import { Effect, Exit, Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeWaveform } from "@/lib/comment-media"; +import { isReplyParentOnVideo } from "@/lib/comment-reply-parent"; import { canUseMediaComments } from "@/lib/media-comments"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; @@ -69,6 +70,12 @@ export async function newMediaComment(data: { throw new Error("Video not found"); const [video] = accessExit.value.value; + if ( + data.parentCommentId && + !(await isReplyParentOnVideo(data.parentCommentId, data.videoId)) + ) + throw new Error("Comment not found"); + const expectedPrefix = `${video.ownerId}/${video.id}/comments/${data.commentId}/`; if (!claims.key.startsWith(expectedPrefix)) throw new Error("Invalid media key"); diff --git a/apps/web/lib/comment-reply-parent.ts b/apps/web/lib/comment-reply-parent.ts new file mode 100644 index 00000000000..7e9c3e8514a --- /dev/null +++ b/apps/web/lib/comment-reply-parent.ts @@ -0,0 +1,16 @@ +import { db } from "@cap/database"; +import { comments } from "@cap/database/schema"; +import type { Comment, Video } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; + +export async function isReplyParentOnVideo( + parentCommentId: Comment.CommentId, + videoId: Video.VideoId, +) { + const [parent] = await db() + .select({ id: comments.id }) + .from(comments) + .where(and(eq(comments.id, parentCommentId), eq(comments.videoId, videoId))) + .limit(1); + return parent !== undefined; +}