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
169 changes: 168 additions & 1 deletion apps/web/__tests__/unit/videos-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { buildCanView, type VideosPolicyDeps } from "@cap/web-backend";
import {
buildCanView,
buildCanViewLoaded,
type VideosPolicyDeps,
} from "@cap/web-backend";
import {
CurrentUser,
type Organisation,
Expand Down Expand Up @@ -573,3 +577,166 @@ describe("VideosPolicy.canView", () => {
});
});
});

function runCanViewLoaded(
deps: VideosPolicyDeps,
video: Video.Video,
password: Option.Option<string>,
user: Option.Option<CurrentUser["Type"]>,
attachedPasswords: ReadonlyArray<string> = [],
): Promise<"allowed" | "denied" | "password"> {
const policy = buildCanViewLoaded(deps, video, password);

const program = Effect.zipRight(
policy,
Effect.succeed("allowed" as const),
).pipe(
Effect.catchTag("PolicyDenied", () => Effect.succeed("denied" as const)),
Effect.catchTag("VerifyVideoPasswordError", () =>
Effect.succeed("password" as const),
),
);

const withPassword =
attachedPasswords.length === 0
? program
: Effect.provideService(program, Video.VideoPasswordAttachment, {
passwords: attachedPasswords,
});

const withUser = user.pipe(
Option.match({
onNone: () => withPassword,
onSome: (u) => Effect.provideService(withPassword, CurrentUser, u),
}),
);

return Effect.runPromise(withUser);
}

describe("VideosPolicy.canViewLoaded", () => {
const scenarios: Array<{
name: string;
config: Parameters<typeof makeDeps>[0] & { video: Video.Video };
user: Option.Option<CurrentUser["Type"]>;
attachedPasswords?: string[];
}> = [
{
name: "owner on a private restricted video",
config: {
video: makeVideo({ public: false }),
allowedEmailDomain: Option.some("restricted.com"),
},
user: makeUser("owner@anything.com", TEST_OWNER_ID),
},
{
name: "anonymous viewer on a public video",
config: { video: makeVideo() },
user: noUser,
},
{
name: "anonymous viewer on a private video",
config: { video: makeVideo({ public: false }) },
user: noUser,
},
{
name: "org member on a private video",
config: { video: makeVideo({ public: false }), orgMembership: true },
user: makeUser("member@company.com"),
},
{
name: "space member with an email restriction that does not match",
config: {
video: makeVideo({ public: false }),
spaceMembership: true,
allowedEmailDomain: Option.some("company.com"),
},
user: makeUser("bob@gmail.com"),
},
{
name: "anonymous viewer with a video password and no attachment",
config: { video: makeVideo(), password: Option.some("video-hash") },
user: noUser,
},
{
name: "anonymous viewer with a matching video password",
config: { video: makeVideo(), password: Option.some("video-hash") },
user: noUser,
attachedPasswords: ["video-hash"],
},
{
name: "anonymous viewer with a matching inherited space password",
config: {
video: makeVideo(),
spacePasswords: ["space-one-hash", "space-two-hash"],
},
user: noUser,
attachedPasswords: ["space-two-hash"],
},
{
name: "logged-in viewer outside the allowed email domain",
config: {
video: makeVideo(),
allowedEmailDomain: Option.some("company.com"),
},
user: makeUser("outsider@gmail.com"),
},
{
name: "anonymous viewer with an email restriction",
config: {
video: makeVideo(),
allowedEmailDomain: Option.some("company.com"),
},
user: noUser,
},
{
name: "logged-in viewer inside the allowed email domain",
config: {
video: makeVideo(),
allowedEmailDomain: Option.some("company.com"),
},
user: makeUser("employee@company.com"),
},
];

for (const scenario of scenarios) {
it(`matches canView for ${scenario.name}`, async () => {
const deps = makeDeps(scenario.config);
const password = scenario.config.password ?? Option.none<string>();

expect(
await runCanViewLoaded(
deps,
scenario.config.video,
password,
scenario.user,
scenario.attachedPasswords,
),
).toBe(await runCanView(deps, scenario.user, scenario.attachedPasswords));
});
}

it("never reads the video row again", async () => {
let getByIdCalls = 0;
const deps = makeDeps({ video: makeVideo({ public: false }) });
const countingDeps: VideosPolicyDeps = {
...deps,
repo: {
getById: (id) => {
getByIdCalls += 1;
return deps.repo.getById(id);
},
},
};

expect(
await runCanViewLoaded(
countingDeps,
makeVideo({ public: false }),
Option.none(),
makeUser("member@company.com"),
),
).toBe("denied");
expect(getByIdCalls).toBe(0);
});
});
101 changes: 55 additions & 46 deletions apps/web/app/(org)/dashboard/caps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,40 +37,42 @@ const getSharedSpacesForVideos = Effect.fn(function* (

const db = yield* Database;

// Fetch space-level sharing
const spaceSharing = yield* db.use((db) =>
db
.select({
videoId: spaceVideos.videoId,
id: spaces.id,
name: spaces.name,
organizationId: spaces.organizationId,
iconUrl: spaces.iconUrl,
settings: spaces.settings,
hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean),
})
.from(spaceVideos)
.innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id))
.innerJoin(organizations, eq(spaces.organizationId, organizations.id))
.where(inArray(spaceVideos.videoId, videoIds)),
);

// Fetch organization-level sharing
const orgSharing = yield* db.use((db) =>
db
.select({
videoId: sharedVideos.videoId,
id: organizations.id,
name: organizations.name,
organizationId: organizations.id,
iconUrl: organizations.iconUrl,
})
.from(sharedVideos)
.innerJoin(
organizations,
eq(sharedVideos.organizationId, organizations.id),
)
.where(inArray(sharedVideos.videoId, videoIds)),
const [spaceSharing, orgSharing] = yield* Effect.all(
[
db.use((db) =>
db
.select({
videoId: spaceVideos.videoId,
id: spaces.id,
name: spaces.name,
organizationId: spaces.organizationId,
iconUrl: spaces.iconUrl,
settings: spaces.settings,
hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean),
})
.from(spaceVideos)
.innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id))
.innerJoin(organizations, eq(spaces.organizationId, organizations.id))
.where(inArray(spaceVideos.videoId, videoIds)),
),
db.use((db) =>
db
.select({
videoId: sharedVideos.videoId,
id: organizations.id,
name: organizations.name,
organizationId: organizations.id,
iconUrl: organizations.iconUrl,
})
.from(sharedVideos)
.innerJoin(
organizations,
eq(sharedVideos.organizationId, organizations.id),
)
.where(inArray(sharedVideos.videoId, videoIds)),
),
],
{ concurrency: "unbounded" },
);

// Combine and group by videoId
Expand Down Expand Up @@ -136,7 +138,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
const userId = user.id;
const offset = (page - 1) * limit;

const totalCountResult = await db()
const totalCountPromise = db()
.select({ count: count() })
.from(videos)
.leftJoin(organizations, eq(videos.orgId, organizations.id))
Expand All @@ -148,9 +150,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
),
);

const totalCount = totalCountResult[0]?.count || 0;

const videoData = await db()
const videoDataPromise = db()
.select({
id: videos.id,
ownerId: videos.ownerId,
Expand Down Expand Up @@ -222,7 +222,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
.limit(limit)
.offset(offset);

const foldersData = await db()
const foldersDataPromise = db()
.select({
id: folders.id,
name: folders.name,
Expand All @@ -244,19 +244,28 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
),
);

// Fetch shared spaces data for all videos
const videoIds = videoData.map((video) => video.id);
const sharedSpacesMap =
await getSharedSpacesForVideos(videoIds).pipe(runPromise);
const [organizationSettingsRow] = user.activeOrganizationId
? await db()
const organizationSettingsPromise = user.activeOrganizationId
? db()
.select({ settings: organizations.settings })
.from(organizations)
.where(eq(organizations.id, user.activeOrganizationId))
.limit(1)
: [];
: Promise.resolve([]);

const [totalCountResult, videoData, foldersData, [organizationSettingsRow]] =
await Promise.all([
totalCountPromise,
videoDataPromise,
foldersDataPromise,
organizationSettingsPromise,
]);
const totalCount = totalCountResult[0]?.count || 0;
const organizationSettings = organizationSettingsRow?.settings ?? null;

const videoIds = videoData.map((video) => video.id);
const sharedSpacesMap =
await getSharedSpacesForVideos(videoIds).pipe(runPromise);

const processedVideoData = await Effect.all(
videoData.map(
Effect.fn(function* (video) {
Expand Down
Loading
Loading