diff --git a/apps/web/__tests__/unit/transcribe-status-access.test.ts b/apps/web/__tests__/unit/transcribe-status-access.test.ts new file mode 100644 index 00000000000..eb68c851b79 --- /dev/null +++ b/apps/web/__tests__/unit/transcribe-status-access.test.ts @@ -0,0 +1,136 @@ +import { Exit, Layer } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), +}; + +function resetDbChain() { + for (const fn of Object.values(mockDbChain)) fn.mockClear(); + mockDbChain.select.mockReturnValue(mockDbChain); + mockDbChain.from.mockReturnValue(mockDbChain); + mockDbChain.where.mockReturnValue(mockDbChain); + mockDbChain.limit.mockReturnValue( + Promise.resolve([{ id: "video-1", transcriptionStatus: "COMPLETE" }]), + ); +} + +vi.mock("@cap/database", () => ({ db: () => mockDbChain })); + +vi.mock("@cap/database/schema", () => ({ + videos: { id: "id" }, +})); + +const mockGetCurrentUser = vi.fn(); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: () => mockGetCurrentUser(), +})); + +const mockCanView = vi.fn(); +vi.mock("@cap/web-backend", () => ({ + makeCurrentUserLayer: () => Layer.empty, + VideosPolicy: { + key: "VideosPolicy", + }, +})); + +// The route runs its query through the Effect runtime; the parts under test are +// which failures reach the handler and how it maps them, so the runtime is +// replaced with one that returns the exit the policy would have produced. +vi.mock("@/lib/server", () => ({ + runPromiseExit: () => mockCanView(), +})); + +vi.mock("@cap/web-domain", async () => { + const actual = + await vi.importActual("@cap/web-domain"); + return { + ...actual, + Policy: { + ...actual.Policy, + withPublicPolicy: () => (self: unknown) => self, + }, + }; +}); + +const { GET } = await import("@/app/api/video/transcribe/status/route"); +const { DatabaseError, Policy } = await import("@cap/web-domain"); + +const request = (videoId: string) => + new Request( + `https://cap.test/api/video/transcribe/status?videoId=${videoId}`, + ) as never; + +describe("GET /api/video/transcribe/status", () => { + beforeEach(() => { + resetDbChain(); + mockGetCurrentUser.mockResolvedValue({ id: "user-1" }); + mockCanView.mockReset(); + }); + + it("returns 401 when there is no session", async () => { + mockGetCurrentUser.mockResolvedValue(null); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(401); + }); + + it("returns 400 when videoId is missing", async () => { + const res = await GET( + new Request("https://cap.test/api/video/transcribe/status") as never, + ); + + expect(res.status).toBe(400); + }); + + it("returns 404 when the access policy denies the caller", async () => { + mockCanView.mockResolvedValue( + Exit.fail(new Policy.PolicyDeniedError({ reason: "denied" })), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toMatchObject({ + message: "Video does not exist", + }); + }); + + it("returns 500, not 404, when the access check hits a database failure", async () => { + mockCanView.mockResolvedValue( + Exit.fail(new DatabaseError({ cause: new Error("connection reset") })), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toMatchObject({ + message: "Failed to fetch transcription status", + }); + }); + + it("returns the transcription status when access is granted", async () => { + mockCanView.mockResolvedValue( + Exit.succeed([{ id: "video-1", transcriptionStatus: "COMPLETE" }]), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + transcriptionStatus: "COMPLETE", + }); + }); + + it("returns 404 when the video row is missing", async () => { + mockCanView.mockResolvedValue(Exit.succeed([])); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(404); + }); +}); diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index aff321848bb..01697777233 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -1,9 +1,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; +import { makeCurrentUserLayer, VideosPolicy } from "@cap/web-backend"; +import { DatabaseError, Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import type { NextRequest } from "next/server"; +import * as EffectRuntime from "@/lib/server"; export const dynamic = "force-dynamic"; @@ -23,7 +26,51 @@ export async function GET(request: NextRequest) { ); } - const video = await db().select().from(videos).where(eq(videos.id, videoId)); + // `videoId` is caller-supplied, so an authenticated session is not enough: + // without this any signed-in user could read the transcription status of + // someone else's private recording. The `getVideoStatus` server action, + // which returns this same field, already gates on `canView`. + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select().from(videos).where(eq(videos.id, videoId)).limit(1), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe( + // The route already required auth above, so provide the user we have + // rather than `provideOptionalAuth`, which would re-run + // getServerSession() and re-query `users`. + Effect.provide(makeCurrentUserLayer(user)), + EffectRuntime.runPromiseExit, + ); + + if (Exit.isFailure(exit)) { + const failure = Cause.failureOption(exit.cause); + + // The 404-for-denied behaviour below is deliberate, so this endpoint + // can't be used to probe which video IDs exist. A database failure is + // not a denial though: `canView` reads the video plus its org/space + // memberships, so an outage in any of those would otherwise be reported + // as "video does not exist" for a video the caller can actually see. + if (Option.isSome(failure) && Schema.is(DatabaseError)(failure.value)) { + console.error( + "[transcribe/status] database error while checking video access:", + failure.value.cause, + ); + + return Response.json( + { error: true, message: "Failed to fetch transcription status" }, + { status: 500 }, + ); + } + + return Response.json( + { error: true, message: "Video does not exist" }, + { status: 404 }, + ); + } + + const video = exit.value; if (video.length === 0 || !video[0]) { return Response.json(