Skip to content
Open
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
123 changes: 123 additions & 0 deletions apps/web/__tests__/unit/video-download-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
organizationMembers,
sharedVideos,
spaceMembers,
spaceVideos,
} from "@cap/database/schema";
import type { Organisation, User, Video } from "@cap/web-domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canUserDownloadVideo } from "../../../lib/video-download-permissions";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relative path resolves to apps/lib/video-download-permissions, which doesn't exist — the source file is at apps/web/lib/. The __tests__/unit/ directory is only two levels below apps/web/, so you'd need ../../lib/... for a relative path. Since @/lib is already aliased in both tsconfig.json and vitest.config.ts (and used by other unit tests like roles-permissions.test.ts), reverting to the alias is probably cleanest:

Suggested change
import { canUserDownloadVideo } from "../../../lib/video-download-permissions";
import { canUserDownloadVideo } from "@/lib/video-download-permissions";


let mockSharedOrgs: Array<{ organizationId: string }> = [];
let mockOrgMembers: Array<{ id: string }> = [];
let mockSharedSpaces: Array<{ spaceId: string }> = [];
let mockSpaceMembers: Array<{ id: string }> = [];

vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({
from: (table: unknown) => ({
where: (condition: any) => {
let result: unknown[] = [];
if (table === sharedVideos) {
result = mockSharedOrgs;
} else if (table === organizationMembers) {
// Simple condition check for tests: if the where clause doesn't include the target orgs/users, return empty
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key: string, value: any) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return;
seen.add(value);
}
return value;
};
};
const conditionStr = JSON.stringify(condition, getCircularReplacer()) || "";
if (mockOrgMembers.length > 0 && !conditionStr.includes("org-789")) {
result = [];
} else {
result = mockOrgMembers;
}
} else if (table === spaceVideos) {
result = mockSharedSpaces;
} else if (table === spaceMembers) {
result = mockSpaceMembers;
}
const promise = Promise.resolve(result);
(promise as Record<string, unknown>).limit = () =>
Promise.resolve(result);
return promise;
},
}),
}),
}),
}));

describe("canUserDownloadVideo", () => {
beforeEach(() => {
mockSharedOrgs = [];
mockOrgMembers = [];
mockSharedSpaces = [];
mockSpaceMembers = [];
});

it("grants download access to the video owner", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Permission change remains untested

The sole test uses identical userId and ownerId values, so it returns through the unchanged owner shortcut without exercising the new organization-sharing check. Add cases proving that an organization member is denied without a sharedVideos entry and allowed with one, otherwise the regression addressed by this PR can return without failing this suite.

Knowledge Base Used: Web App (apps/web)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/video-download-permissions.test.ts
Line: 5

Comment:
**Permission change remains untested**

The sole test uses identical `userId` and `ownerId` values, so it returns through the unchanged owner shortcut without exercising the new organization-sharing check. Add cases proving that an organization member is denied without a `sharedVideos` entry and allowed with one, otherwise the regression addressed by this PR can return without failing this suite.

**Knowledge Base Used:** [Web App (apps/web)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/web-app.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test currently only covers the owner short-circuit. Since this PR changes org-level download behavior, it’d be useful to add a case that exercises the new shared_videos gating (member of org + explicit share => allow; member of org without share => deny), to prevent regressions.

const allowed = await canUserDownloadVideo({
userId: "user-123" as User.UserId,
ownerId: "user-123" as User.UserId,
videoId: "vid-456" as Video.VideoId,
orgId: "org-789" as Organisation.OrganisationId,
});

expect(allowed).toBe(true);
});

it("denies download access to an org member when the video is not explicitly shared with the organization", async () => {
mockSharedOrgs = [];
mockOrgMembers = [{ id: "member-1" }];
mockSharedSpaces = [];
mockSpaceMembers = [];

const allowed = await canUserDownloadVideo({
userId: "user-123" as User.UserId,
ownerId: "user-456" as User.UserId,
videoId: "vid-789" as Video.VideoId,
orgId: "org-789" as Organisation.OrganisationId,
});

expect(allowed).toBe(false);
});

it("grants download access to an org member when the video is explicitly shared with the organization", async () => {
mockSharedOrgs = [{ organizationId: "org-789" }];
mockOrgMembers = [{ id: "member-1" }];
mockSharedSpaces = [];
mockSpaceMembers = [];

const allowed = await canUserDownloadVideo({
userId: "user-123" as User.UserId,
ownerId: "user-456" as User.UserId,
videoId: "vid-789" as Video.VideoId,
orgId: "org-789" as Organisation.OrganisationId,
});

expect(allowed).toBe(true);
});

it("denies download access when the video is shared with a different organization", async () => {
mockSharedOrgs = [{ organizationId: "other-org-id" }];
mockOrgMembers = [{ id: "member-1" }];
mockSharedSpaces = [];
mockSpaceMembers = [];

const allowed = await canUserDownloadVideo({
userId: "user-123" as User.UserId,
ownerId: "user-456" as User.UserId,
videoId: "vid-789" as Video.VideoId,
orgId: "org-789" as Organisation.OrganisationId,
});

expect(allowed).toBe(false);
});
});
28 changes: 15 additions & 13 deletions apps/web/lib/video-download-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export async function canUserDownloadVideo({
userId,
ownerId,
videoId,
orgId,
orgId: _orgId,
}: {
userId: User.UserId;
ownerId: User.UserId;
Expand All @@ -26,20 +26,22 @@ export async function canUserDownloadVideo({
.from(sharedVideos)
.where(eq(sharedVideos.videoId, videoId));

const orgIds = [orgId, ...sharedOrgs.map((org) => org.organizationId)];
if (sharedOrgs.length > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change makes the orgId arg effectively unused in this function. If the signature is meant to stay stable, consider aliasing it to _orgId (or dropping it entirely) so Biome doesn’t flag an unused variable and the API stays less confusing.

const orgIds = sharedOrgs.map((org) => org.organizationId);

const [orgMembership] = await db()
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.userId, userId),
inArray(organizationMembers.organizationId, orgIds),
),
)
.limit(1);
const [orgMembership] = await db()
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.userId, userId),
inArray(organizationMembers.organizationId, orgIds),
),
)
.limit(1);

if (orgMembership) return true;
if (orgMembership) return true;
}

const sharedSpaces = await db()
.select({ spaceId: spaceVideos.spaceId })
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk-recorder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ export class CapRecorder {
this.listeners.set(event, new Set());
}
const set = this.listeners.get(event);
if (set) set.add(handler as EventHandler<keyof RecorderEventMap>);
if (!set) return () => {};
const typedHandler = handler as unknown as EventHandler<keyof RecorderEventMap>;
set.add(typedHandler);
return () => {
this.listeners.get(event)?.delete(handler);
set.delete(typedHandler);
};
}

Expand Down