diff --git a/apps/web/__tests__/unit/analytics-track-route.test.ts b/apps/web/__tests__/unit/analytics-track-route.test.ts index 0848354704c..e710e9a8cde 100644 --- a/apps/web/__tests__/unit/analytics-track-route.test.ts +++ b/apps/web/__tests__/unit/analytics-track-route.test.ts @@ -1,4 +1,5 @@ -import type { Effect as EffectModule } from "effect"; +import type { ViewableVideo, ViewDecisionDeps } from "@cap/web-backend"; +import type { Effect as EffectModule, Option as OptionModule } from "effect"; import type { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +10,8 @@ const OWNER_ID = "owner-1"; const mocks = vi.hoisted(() => ({ video: undefined as Record | undefined, userId: null as string | null, + orgMember: false, + verifiedPasswords: [] as string[], appendEvents: vi.fn(), })); @@ -32,32 +35,61 @@ vi.mock("@cap/database/auth/request-site", () => ({ isCrossSiteCookieWrite: () => false, })); -vi.mock("@cap/web-backend", async () => { +vi.mock("@cap/web-backend", async (importOriginal) => { + const actual = await importOriginal(); const { Context, Effect } = await import("effect"); - const { CurrentUser } = await import("@cap/web-domain"); + const { CurrentUser, Video } = await import("@cap/web-domain"); const Tinybird = Context.GenericTag<{ appendEvents: (events: unknown[]) => EffectModule.Effect; }>("Tinybird"); + const deps: ViewDecisionDeps = { + repo: { + getById: () => Effect.succeedNone, + hasViewerGrant: () => Effect.succeed(false), + }, + orgsRepo: { + membershipForVideo: () => + Effect.sync(() => (mocks.orgMember ? [{ membershipId: "m" }] : [])), + allowedEmailDomain: () => Effect.succeedNone, + }, + spacesRepo: { + membershipForVideo: () => Effect.succeedNone, + passwordsForVideo: () => Effect.succeed([]), + }, + }; + class VideosPolicy extends Effect.Service()("VideosPolicy", { + sync: () => ({ + canViewLoaded: ( + video: ViewableVideo, + password: OptionModule.Option, + ) => actual.buildCanViewLoaded(deps, video, password), + }), + }) {} return { Tinybird, + VideosPolicy, provideOptionalAuth: ( app: EffectModule.Effect, ) => { - const withTinybird = app.pipe( + const withServices = app.pipe( Effect.provideService(Tinybird, { appendEvents: (events) => Effect.sync(() => mocks.appendEvents(events)), }), + Effect.provide(VideosPolicy.Default), + Effect.provideService(Video.VideoPasswordAttachment, { + passwords: mocks.verifiedPasswords, + }), ); return mocks.userId - ? withTinybird.pipe( + ? withServices.pipe( Effect.provideService(CurrentUser, { id: mocks.userId, email: "viewer@example.com", activeOrganizationId: ORG_ID, } as never), ) - : withTinybird; + : withServices; }, }; }); @@ -73,6 +105,7 @@ vi.mock("@/lib/Notification", () => ({ })); const { POST } = await import("@/app/api/analytics/track/route"); +const { createAnonymousViewNotification } = await import("@/lib/Notification"); const track = (body: Record) => POST( @@ -91,10 +124,16 @@ const recordedEvents = () => describe("POST /api/analytics/track", () => { beforeEach(() => { mocks.appendEvents.mockClear(); + vi.mocked(createAnonymousViewNotification).mockClear(); mocks.userId = null; + mocks.orgMember = false; + mocks.verifiedPasswords = []; mocks.video = { + id: VIDEO_ID, orgId: ORG_ID, ownerId: OWNER_ID, + public: true, + password: null, firstViewEmailSentAt: new Date("2025-01-01T00:00:00Z"), videoName: "Demo", createdAt: new Date("2025-01-01T00:00:00Z"), @@ -146,6 +185,46 @@ describe("POST /api/analytics/track", () => { expect(recordedEvents()).toEqual([]); }); + it("does not record views of private videos the viewer cannot watch", async () => { + mocks.video = { + ...mocks.video, + public: false, + createdAt: new Date(), + firstViewEmailSentAt: null, + }; + + await track({ videoId: VIDEO_ID, sessionId: "session-1" }); + mocks.userId = "stranger"; + await track({ videoId: VIDEO_ID, sessionId: "session-2" }); + + expect(recordedEvents()).toEqual([]); + expect(createAnonymousViewNotification).not.toHaveBeenCalled(); + }); + + it("records views of private videos by org members", async () => { + mocks.video = { ...mocks.video, public: false }; + mocks.userId = "member"; + mocks.orgMember = true; + + await track({ videoId: VIDEO_ID, sessionId: "session-1" }); + + expect(recordedEvents()).toEqual([ + expect.objectContaining({ video_id: VIDEO_ID, user_id: "member" }), + ]); + }); + + it("records password-protected views only after the password is verified", async () => { + mocks.video = { ...mocks.video, password: "password-hash" }; + + await track({ videoId: VIDEO_ID, sessionId: "locked" }); + mocks.verifiedPasswords = ["password-hash"]; + await track({ videoId: VIDEO_ID, sessionId: "unlocked" }); + + expect(recordedEvents()).toEqual([ + expect.objectContaining({ session_id: "unlocked" }), + ]); + }); + it("keeps a client timestamp close to now and replaces one far from it", async () => { const recent = new Date(Date.now() - 30_000).toISOString(); await track({ videoId: VIDEO_ID, occurredAt: recent }); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index d6aa452a9b3..960c2fe3f3f 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -1,7 +1,7 @@ import { db } from "@cap/database"; import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site"; import { videos, videoUploads } from "@cap/database/schema"; -import { provideOptionalAuth, Tinybird } from "@cap/web-backend"; +import { provideOptionalAuth, Tinybird, VideosPolicy } from "@cap/web-backend"; import { CurrentUser, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -107,8 +107,11 @@ export async function POST(request: NextRequest) { const [videoRecord] = yield* Effect.tryPromise(() => db() .select({ + id: videos.id, orgId: videos.orgId, ownerId: videos.ownerId, + public: videos.public, + password: videos.password, firstViewEmailSentAt: videos.firstViewEmailSentAt, videoName: videos.name, createdAt: videos.createdAt, @@ -119,20 +122,7 @@ export async function POST(request: NextRequest) { .leftJoin(videoUploads, eq(videoUploads.videoId, videos.id)) .where(eq(videos.id, Video.VideoId.make(body.videoId))) .limit(1), - ).pipe( - Effect.orElseSucceed( - () => - [] as { - orgId: string; - ownerId: string; - firstViewEmailSentAt: Date | null; - videoName: string; - createdAt: Date; - updatedAt: Date; - activeUploadVideoId: string | null; - }[], - ), - ); + ).pipe(Effect.orElseSucceed(() => [])); if (!videoRecord || userId === videoRecord.ownerId) { return; @@ -145,6 +135,15 @@ export async function POST(request: NextRequest) { return; } + const videosPolicy = yield* VideosPolicy; + const canView = yield* videosPolicy + .canViewLoaded(videoRecord, Option.fromNullable(videoRecord.password)) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!canView) return; + const tinybird = yield* Tinybird; yield* tinybird.appendEvents([ {