diff --git a/app/api/analysis/[id]/status/route.test.ts b/app/api/analysis/[id]/status/route.test.ts new file mode 100644 index 0000000..166bd3f --- /dev/null +++ b/app/api/analysis/[id]/status/route.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + hashSharePassword, + issueShareAccessToken, + shareAccessCookieName, +} from "@/lib/share-access"; + +const mockFindUnique = vi.fn(); +const mockAuth = vi.fn(); +const mockCookiesGet = vi.fn(); +const mockCanViewSession = vi.fn(); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + analysisSession: { + findUnique: (...args: unknown[]) => mockFindUnique(...args), + }, + }, +})); + +vi.mock("@/auth", () => ({ + auth: () => mockAuth(), +})); + +vi.mock("next/headers", () => ({ + cookies: () => ({ + get: (name: string) => mockCookiesGet(name), + }), +})); + +vi.mock("@/lib/org/access", () => ({ + isSessionCreator: (userId: string | undefined, session: { userId: string | null }) => + Boolean(userId && session.userId === userId), + canViewSession: (...args: unknown[]) => mockCanViewSession(...args), +})); + +import { GET } from "./route"; + +const SLUG = "test-slug-abc"; +const SESSION_ID = "sess_status_1"; +const OWNER_ID = "user_owner_1"; + +const mockResult = { + executiveSummary: "Customers love the product.", + sentimentData: { positive: 60, negative: 20, neutral: 15, mixed: 5 }, + themesData: [{ clusterId: 1, label: "Quality", reviewCount: 10 }], + averageRating: 4.2, + processingMs: 12_000, +}; + +function makeSession(overrides: Record = {}) { + return { + id: SESSION_ID, + userId: OWNER_ID, + organizationId: null, + status: "COMPLETED", + totalReviews: 42, + updatedAt: new Date(), + sharePasswordHash: null, + shareExpiresAt: null, + result: mockResult, + ...overrides, + }; +} + +function callGet(slug = SLUG) { + return GET(new Request(`http://localhost/api/analysis/${slug}/status`), { + params: { id: slug }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockAuth.mockResolvedValue(null); + mockCanViewSession.mockResolvedValue(false); + mockCookiesGet.mockReturnValue(undefined); +}); + +describe("GET /api/analysis/[id]/status", () => { + it("returns 403 for an unknown slug (no result leaked)", async () => { + mockFindUnique.mockResolvedValue(null); + + const res = await callGet("random-unknown-slug"); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.success).toBe(false); + expect(body.data).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain("executiveSummary"); + }); + + it("returns full result for the session owner", async () => { + mockFindUnique.mockResolvedValue(makeSession()); + mockAuth.mockResolvedValue({ user: { id: OWNER_ID } }); + + const res = await callGet(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.status).toBe("COMPLETED"); + expect(body.data.totalReviews).toBe(42); + expect(body.data.result).toMatchObject({ + executiveSummary: mockResult.executiveSummary, + averageRating: mockResult.averageRating, + processingMs: mockResult.processingMs, + }); + expect(body.data.result.themes).toHaveLength(1); + }); + + it("returns full result for a share viewer with a valid cookie", async () => { + const token = issueShareAccessToken(SESSION_ID); + mockFindUnique.mockResolvedValue( + makeSession({ sharePasswordHash: hashSharePassword("secret") }) + ); + mockCookiesGet.mockImplementation((name: string) => + name === shareAccessCookieName(SESSION_ID) + ? { value: token } + : undefined + ); + + const res = await callGet(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.result?.executiveSummary).toBe( + mockResult.executiveSummary + ); + }); + + it("returns 401 when share password is required but cookie is missing", async () => { + mockFindUnique.mockResolvedValue( + makeSession({ sharePasswordHash: hashSharePassword("secret") }) + ); + + const res = await callGet(); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error).toBe("Password required"); + expect(body.data).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain("executiveSummary"); + }); +}); diff --git a/app/api/analysis/[id]/status/route.ts b/app/api/analysis/[id]/status/route.ts index a7c4ccf..754ef82 100644 --- a/app/api/analysis/[id]/status/route.ts +++ b/app/api/analysis/[id]/status/route.ts @@ -1,6 +1,13 @@ import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; import { PIPELINE_STALE_MS } from "@/lib/constants"; +import { + checkShareAccess, + shareAccessCookieName, +} from "@/lib/share-access"; +import { canViewSession, isSessionCreator } from "@/lib/org/access"; import type { ApiResponse, SentimentBreakdown } from "@/types"; import type { ThemeAnalysis, StoredAnalysisResult } from "@/features/analysis/types"; @@ -25,9 +32,14 @@ export async function GET( const session = await prisma.analysisSession.findUnique({ where: { shareableSlug: slug }, select: { + id: true, + userId: true, + organizationId: true, status: true, totalReviews: true, updatedAt: true, + sharePasswordHash: true, + shareExpiresAt: true, result: { select: { executiveSummary: true, @@ -42,11 +54,29 @@ export async function GET( if (!session) { return NextResponse.json( - { success: false as const, error: "Session not found" }, - { status: 404 } + { success: false as const, error: "Forbidden" }, + { status: 403 } ); } + const authUser = await auth(); + const userId = authUser?.user?.id; + const isCreator = isSessionCreator(userId, session); + const hasTeamAccess = await canViewSession(userId, session); + + if (!isCreator && !hasTeamAccess) { + const cookieToken = cookies().get( + shareAccessCookieName(session.id) + )?.value; + const shareAccess = checkShareAccess(session, cookieToken); + if (!shareAccess.allowed) { + return NextResponse.json( + { success: false as const, error: shareAccess.error }, + { status: shareAccess.status } + ); + } + } + const staleThreshold = new Date(Date.now() - PIPELINE_STALE_MS); const isStale = session.status === "PROCESSING" && session.updatedAt < staleThreshold; @@ -60,7 +90,8 @@ export async function GET( if (session.status === "COMPLETED" && session.result) { payload.result = { executiveSummary: session.result.executiveSummary, - sentimentBreakdown: session.result.sentimentData as unknown as SentimentBreakdown, + sentimentBreakdown: + session.result.sentimentData as unknown as SentimentBreakdown, themes: session.result.themesData as unknown as ThemeAnalysis[], averageRating: session.result.averageRating ?? undefined, processingMs: session.result.processingMs ?? 0, diff --git a/app/dashboard/[id]/dashboard-client.tsx b/app/dashboard/[id]/dashboard-client.tsx index c52270e..d217890 100644 --- a/app/dashboard/[id]/dashboard-client.tsx +++ b/app/dashboard/[id]/dashboard-client.tsx @@ -92,7 +92,8 @@ export function DashboardClient({ const fetchStatus = useCallback(async (): Promise => { const data = await apiFetch( - `/api/analysis/${slug}/status` + `/api/analysis/${slug}/status`, + { credentials: "include" } ); setStatus(data.status); setTotalReviews(data.totalReviews); @@ -141,11 +142,17 @@ export function DashboardClient({ return; } } catch (err) { - setPageError( - err instanceof ApiError - ? err.message - : "Could not reach the server. Check your connection." - ); + if (err instanceof ApiError && err.statusCode === 401) { + setPageError( + "Share access expired. Refresh the page and enter the password again." + ); + } else { + setPageError( + err instanceof ApiError + ? err.message + : "Could not reach the server. Check your connection." + ); + } stopPolling(); return; } diff --git a/lib/share-access.test.ts b/lib/share-access.test.ts index aaa1b99..e5dcf82 100644 --- a/lib/share-access.test.ts +++ b/lib/share-access.test.ts @@ -6,6 +6,7 @@ import { issueShareAccessToken, verifyShareAccessToken, shareAccessCookieName, + checkShareAccess, } from "./share-access"; describe("share password hashing", () => { @@ -76,3 +77,59 @@ describe("shareAccessCookieName", () => { expect(shareAccessCookieName("abc")).toBe("rl_share_abc"); }); }); + +describe("checkShareAccess", () => { + const baseSession = { + id: "sess_1", + sharePasswordHash: null as string | null, + shareExpiresAt: null as Date | null, + }; + + it("allows access when no password is set and link is not expired", () => { + expect(checkShareAccess(baseSession, undefined)).toEqual({ + allowed: true, + }); + }); + + it("returns 410 when the share link has expired", () => { + const result = checkShareAccess( + { + ...baseSession, + shareExpiresAt: new Date(Date.now() - 1000), + }, + undefined + ); + expect(result).toEqual({ + allowed: false, + status: 410, + error: "This link has expired.", + }); + }); + + it("returns 401 when a password is required but cookie is missing", () => { + const result = checkShareAccess( + { + ...baseSession, + sharePasswordHash: hashSharePassword("secret"), + }, + undefined + ); + expect(result).toEqual({ + allowed: false, + status: 401, + error: "Password required", + }); + }); + + it("allows access when a valid share cookie is present", () => { + const token = issueShareAccessToken("sess_1"); + const result = checkShareAccess( + { + ...baseSession, + sharePasswordHash: hashSharePassword("secret"), + }, + token + ); + expect(result).toEqual({ allowed: true }); + }); +}); diff --git a/lib/share-access.ts b/lib/share-access.ts index feb6405..50a7045 100644 --- a/lib/share-access.ts +++ b/lib/share-access.ts @@ -70,3 +70,33 @@ export function verifyShareAccessToken( const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); } + +// ── Viewer access gate (password + expiry) ─────────────────────────────────── + +export type ShareAccessSession = { + id: string; + sharePasswordHash: string | null; + shareExpiresAt: Date | null; +}; + +export type ShareAccessResult = + | { allowed: true } + | { allowed: false; status: 401 | 410; error: string }; + +/** Returns whether a non-owner viewer may read shared analysis data. */ +export function checkShareAccess( + session: ShareAccessSession, + cookieToken: string | undefined +): ShareAccessResult { + if (isShareExpired(session.shareExpiresAt)) { + return { allowed: false, status: 410, error: "This link has expired." }; + } + + if (session.sharePasswordHash) { + if (!verifyShareAccessToken(cookieToken, session.id)) { + return { allowed: false, status: 401, error: "Password required" }; + } + } + + return { allowed: true }; +}