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
146 changes: 146 additions & 0 deletions app/api/analysis/[id]/status/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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");
});
});
37 changes: 34 additions & 3 deletions app/api/analysis/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand Down
19 changes: 13 additions & 6 deletions app/dashboard/[id]/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export function DashboardClient({

const fetchStatus = useCallback(async (): Promise<AnalysisStatus> => {
const data = await apiFetch<AnalysisStatusResponse>(
`/api/analysis/${slug}/status`
`/api/analysis/${slug}/status`,
{ credentials: "include" }
);
setStatus(data.status);
setTotalReviews(data.totalReviews);
Expand Down Expand Up @@ -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;
}
Expand Down
57 changes: 57 additions & 0 deletions lib/share-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
issueShareAccessToken,
verifyShareAccessToken,
shareAccessCookieName,
checkShareAccess,
} from "./share-access";

describe("share password hashing", () => {
Expand Down Expand Up @@ -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 });
});
});
30 changes: 30 additions & 0 deletions lib/share-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Loading