diff --git a/src/__tests__/api/versions.test.ts b/src/__tests__/api/versions.test.ts index 2ef1bbe4..18115902 100644 --- a/src/__tests__/api/versions.test.ts +++ b/src/__tests__/api/versions.test.ts @@ -29,6 +29,10 @@ describe("GET /api/prompts/[id]/versions", () => { }); it("should return empty array for prompt with no versions", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + isPrivate: false, + authorId: "user1", + } as never); vi.mocked(db.promptVersion.findMany).mockResolvedValue([]); const request = new Request("http://localhost:3000/api/prompts/123/versions"); @@ -42,6 +46,10 @@ describe("GET /api/prompts/[id]/versions", () => { }); it("should return versions ordered by version desc", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + isPrivate: false, + authorId: "user1", + } as never); vi.mocked(db.promptVersion.findMany).mockResolvedValue([ { id: "v3", @@ -83,6 +91,10 @@ describe("GET /api/prompts/[id]/versions", () => { }); it("should include author info in response", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + isPrivate: false, + authorId: "user1", + } as never); vi.mocked(db.promptVersion.findMany).mockResolvedValue([ { id: "v1", @@ -106,6 +118,10 @@ describe("GET /api/prompts/[id]/versions", () => { }); it("should call findMany with correct parameters", async () => { + vi.mocked(db.prompt.findUnique).mockResolvedValue({ + isPrivate: false, + authorId: "user1", + } as never); vi.mocked(db.promptVersion.findMany).mockResolvedValue([]); const request = new Request("http://localhost:3000/api/prompts/123/versions"); diff --git a/src/__tests__/lib/prompt-access.test.ts b/src/__tests__/lib/prompt-access.test.ts new file mode 100644 index 00000000..5730663b --- /dev/null +++ b/src/__tests__/lib/prompt-access.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canViewPrompt, checkPromptAccess } from "@/lib/prompt-access"; +import { auth } from "@/lib/auth"; +import type { Session } from "next-auth"; + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), +})); + +describe("canViewPrompt", () => { + it("should return false for null prompt", () => { + expect(canViewPrompt(null, null)).toBe(false); + }); + + it("should return true for public prompt with no session", () => { + expect( + canViewPrompt({ isPrivate: false, authorId: "user1" }, null) + ).toBe(true); + }); + + it("should return true for public prompt with any session", () => { + const session = { user: { id: "other" } } as Session; + expect( + canViewPrompt({ isPrivate: false, authorId: "user1" }, session) + ).toBe(true); + }); + + it("should return false for private prompt with no session", () => { + expect( + canViewPrompt({ isPrivate: true, authorId: "user1" }, null) + ).toBe(false); + }); + + it("should return false for private prompt when user is not owner", () => { + const session = { user: { id: "other", role: "USER" } } as Session; + expect( + canViewPrompt({ isPrivate: true, authorId: "user1" }, session) + ).toBe(false); + }); + + it("should return true for private prompt when user is the owner", () => { + const session = { user: { id: "user1", role: "USER" } } as Session; + expect( + canViewPrompt({ isPrivate: true, authorId: "user1" }, session) + ).toBe(true); + }); + + it("should return true for private prompt when user is admin", () => { + const session = { user: { id: "admin1", role: "ADMIN" } } as Session; + expect( + canViewPrompt({ isPrivate: true, authorId: "user1" }, session) + ).toBe(true); + }); + + it("should return false for private prompt with session missing user", () => { + const session = {} as Session; + expect( + canViewPrompt({ isPrivate: true, authorId: "user1" }, session) + ).toBe(false); + }); +}); + +describe("checkPromptAccess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 404 for null prompt", async () => { + const result = await checkPromptAccess(null); + expect(result).not.toBeNull(); + expect(result!.status).toBe(404); + const data = await result!.json(); + expect(data.error).toBe("not_found"); + }); + + it("should return null for public prompt (no auth call)", async () => { + const result = await checkPromptAccess({ isPrivate: false, authorId: "user1" }); + expect(result).toBeNull(); + expect(auth).not.toHaveBeenCalled(); + }); + + it("should return 404 for private prompt with no session", async () => { + vi.mocked(auth).mockResolvedValue(null); + const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" }); + expect(result).not.toBeNull(); + expect(result!.status).toBe(404); + expect(auth).toHaveBeenCalledOnce(); + }); + + it("should return 404 for private prompt when user is not owner", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "other", role: "USER" } } as never); + const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" }); + expect(result).not.toBeNull(); + expect(result!.status).toBe(404); + }); + + it("should return null for private prompt when user is the owner", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never); + const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" }); + expect(result).toBeNull(); + }); + + it("should return null for private prompt when user is admin", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never); + const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" }); + expect(result).toBeNull(); + }); +}); diff --git a/src/app/api/prompts/[id]/changes/[changeId]/route.ts b/src/app/api/prompts/[id]/changes/[changeId]/route.ts index cff5d962..39ff3fae 100644 --- a/src/app/api/prompts/[id]/changes/[changeId]/route.ts +++ b/src/app/api/prompts/[id]/changes/[changeId]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { checkPromptAccess } from "@/lib/prompt-access"; const updateChangeRequestSchema = z.object({ status: z.enum(["APPROVED", "REJECTED", "PENDING"]), @@ -196,7 +197,8 @@ export async function GET( select: { id: true, title: true, - content: true, + isPrivate: true, + authorId: true, }, }, }, @@ -209,6 +211,9 @@ export async function GET( ); } + const denied = await checkPromptAccess(changeRequest.prompt); + if (denied) return denied; + return NextResponse.json(changeRequest); } catch (error) { console.error("Get change request error:", error); diff --git a/src/app/api/prompts/[id]/changes/route.ts b/src/app/api/prompts/[id]/changes/route.ts index 23f35a9e..31c70b19 100644 --- a/src/app/api/prompts/[id]/changes/route.ts +++ b/src/app/api/prompts/[id]/changes/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { checkPromptAccess } from "@/lib/prompt-access"; const createChangeRequestSchema = z.object({ proposedContent: z.string().min(1), @@ -94,6 +95,14 @@ export async function GET( try { const { id: promptId } = await params; + const prompt = await db.prompt.findUnique({ + where: { id: promptId }, + select: { isPrivate: true, authorId: true }, + }); + + const denied = await checkPromptAccess(prompt); + if (denied) return denied; + const changeRequests = await db.changeRequest.findMany({ where: { promptId }, orderBy: { createdAt: "desc" }, diff --git a/src/app/api/prompts/[id]/examples/route.ts b/src/app/api/prompts/[id]/examples/route.ts index 8009c90a..baa57811 100644 --- a/src/app/api/prompts/[id]/examples/route.ts +++ b/src/app/api/prompts/[id]/examples/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { z } from "zod"; +import { checkPromptAccess } from "@/lib/prompt-access"; const addExampleSchema = z.object({ mediaUrl: z.string().url(), @@ -16,12 +17,11 @@ export async function GET( const prompt = await db.prompt.findUnique({ where: { id: promptId }, - select: { id: true, type: true }, + select: { id: true, type: true, isPrivate: true, authorId: true }, }); - if (!prompt) { - return NextResponse.json({ error: "Prompt not found" }, { status: 404 }); - } + const denied = await checkPromptAccess(prompt); + if (denied || !prompt) return denied!; // Only allow examples for IMAGE and VIDEO prompts if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") { diff --git a/src/app/api/prompts/[id]/versions/route.ts b/src/app/api/prompts/[id]/versions/route.ts index a894e81f..19b280b9 100644 --- a/src/app/api/prompts/[id]/versions/route.ts +++ b/src/app/api/prompts/[id]/versions/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { checkPromptAccess } from "@/lib/prompt-access"; const createVersionSchema = z.object({ content: z.string().min(1, "Content is required"), @@ -116,6 +117,14 @@ export async function GET( try { const { id: promptId } = await params; + const prompt = await db.prompt.findUnique({ + where: { id: promptId }, + select: { isPrivate: true, authorId: true }, + }); + + const denied = await checkPromptAccess(prompt); + if (denied) return denied; + const versions = await db.promptVersion.findMany({ where: { promptId }, orderBy: { version: "desc" }, diff --git a/src/app/api/prompts/[id]/vote/route.ts b/src/app/api/prompts/[id]/vote/route.ts index 468723bf..71b64292 100644 --- a/src/app/api/prompts/[id]/vote/route.ts +++ b/src/app/api/prompts/[id]/vote/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { checkPromptAccess } from "@/lib/prompt-access"; // POST - Upvote a prompt export async function POST( @@ -21,14 +22,11 @@ export async function POST( // Check if prompt exists const prompt = await db.prompt.findUnique({ where: { id: promptId }, + select: { isPrivate: true, authorId: true }, }); - if (!prompt) { - return NextResponse.json( - { error: "not_found", message: "Prompt not found" }, - { status: 404 } - ); - } + const denied = await checkPromptAccess(prompt); + if (denied) return denied; // Check if already voted const existing = await db.promptVote.findUnique({ @@ -86,6 +84,14 @@ export async function DELETE( const { id: promptId } = await params; + const prompt = await db.prompt.findUnique({ + where: { id: promptId }, + select: { isPrivate: true, authorId: true }, + }); + + const denied = await checkPromptAccess(prompt); + if (denied) return denied; + // Delete vote await db.promptVote.deleteMany({ where: { diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index 42ecda19..e86d593b 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -8,6 +8,7 @@ import { AnimatedDate } from "@/components/ui/animated-date"; import { ShareDropdown } from "@/components/prompts/share-dropdown"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { canViewPrompt } from "@/lib/prompt-access"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -62,13 +63,20 @@ export async function generateMetadata({ params }: PromptPageProps): Promise { + if (!prompt) { + return NextResponse.json( + { error: "not_found", message: "Prompt not found" }, + { status: 404 } + ); + } + + if (!prompt.isPrivate) return null; + + const session = await auth(); + if (!canViewPrompt(prompt, session)) { + return NextResponse.json( + { error: "not_found", message: "Prompt not found" }, + { status: 404 } + ); + } + + return null; +}