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
3 changes: 2 additions & 1 deletion .github/workflows/recording-reliability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
180 changes: 180 additions & 0 deletions apps/web/__tests__/unit/comment-reply-parent.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<typeof import("@cap/database/helpers")>()),
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" }),
);
});
});
7 changes: 7 additions & 0 deletions apps/web/actions/videos/new-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/actions/videos/new-media-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 16 additions & 0 deletions apps/web/lib/comment-reply-parent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading