From 9d2245b0036a1f66a0e151980d811d9f740cc3d4 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 9 Apr 2026 04:11:48 +0530 Subject: [PATCH 1/4] feat: add artist detail read endpoint --- app/api/artists/[id]/route.ts | 29 +++++ .../__tests__/getArtistHandler.test.ts | 119 ++++++++++++++++++ .../validateGetArtistRequest.test.ts | 71 +++++++++++ lib/artists/getArtistById.ts | 34 +++++ lib/artists/getArtistHandler.ts | 71 +++++++++++ lib/artists/validateGetArtistRequest.ts | 35 ++++++ .../selectAccountWithArtistDetails.ts | 33 +++++ 7 files changed, 392 insertions(+) create mode 100644 app/api/artists/[id]/route.ts create mode 100644 lib/artists/__tests__/getArtistHandler.test.ts create mode 100644 lib/artists/__tests__/validateGetArtistRequest.test.ts create mode 100644 lib/artists/getArtistById.ts create mode 100644 lib/artists/getArtistHandler.ts create mode 100644 lib/artists/validateGetArtistRequest.ts create mode 100644 lib/supabase/accounts/selectAccountWithArtistDetails.ts diff --git a/app/api/artists/[id]/route.ts b/app/api/artists/[id]/route.ts new file mode 100644 index 00000000..447ada3d --- /dev/null +++ b/app/api/artists/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getArtistHandler } from "@/lib/artists/getArtistHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/artists/[id] + * + * Retrieves a single artist detail payload for an accessible artist account. + * + * @param request - The request object + * @param options - Route options containing params + * @param options.params - Route params containing the artist account ID + * @returns A NextResponse with artist data + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return getArtistHandler(request, options.params); +} diff --git a/lib/artists/__tests__/getArtistHandler.test.ts b/lib/artists/__tests__/getArtistHandler.test.ts new file mode 100644 index 00000000..06bc7924 --- /dev/null +++ b/lib/artists/__tests__/getArtistHandler.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getArtistHandler } from "../getArtistHandler"; +import { validateGetArtistRequest } from "../validateGetArtistRequest"; +import { getArtistById } from "../getArtistById"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetArtistRequest", () => ({ + validateGetArtistRequest: vi.fn(), +})); + +vi.mock("../getArtistById", () => ({ + getArtistById: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ + validateAccountIdOverride: vi.fn(), +})); + +const validUuid = "550e8400-e29b-41d4-a716-446655440000"; + +describe("getArtistHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth/validation response when params validation fails", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue( + NextResponse.json({ error: "unauthorized" }, { status: 401 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(res.status).toBe(401); + expect(getArtistById).not.toHaveBeenCalled(); + }); + + it("returns 404 when the artist does not exist", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue(null); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.error).toBe("Artist not found"); + }); + + it("returns 200 with the merged artist payload", async () => { + const artist = { + account_id: validUuid, + id: validUuid, + name: "Test Artist", + instruction: "Be specific", + knowledges: [], + label: "Indie", + image: "https://example.com/artist.png", + account_socials: [], + }; + + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue(artist as never); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.artist).toEqual(artist); + }); + + it("passes the request and path id to validation", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue({ account_id: validUuid } as never); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(validateGetArtistRequest).toHaveBeenCalledWith(req, validUuid); + }); + + it("returns 403 when the authenticated account cannot access the artist", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: "11111111-1111-4111-8111-111111111111", + }); + vi.mocked(getArtistById).mockResolvedValue({ + account_id: validUuid, + id: validUuid, + name: "Test Artist", + account_socials: [], + } as never); + vi.mocked(validateAccountIdOverride).mockResolvedValue( + NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(res.status).toBe(403); + }); +}); diff --git a/lib/artists/__tests__/validateGetArtistRequest.test.ts b/lib/artists/__tests__/validateGetArtistRequest.test.ts new file mode 100644 index 00000000..38ef7010 --- /dev/null +++ b/lib/artists/__tests__/validateGetArtistRequest.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetArtistRequest } from "../validateGetArtistRequest"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/accounts/validateAccountParams", () => ({ + validateAccountParams: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const validUuid = "550e8400-e29b-41d4-a716-446655440000"; + +describe("validateGetArtistRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the artist id is invalid", async () => { + vi.mocked(validateAccountParams).mockReturnValue( + NextResponse.json({ error: "invalid UUID" }, { status: 400 }), + ); + + const req = new NextRequest("http://localhost/api/artists/not-valid"); + const result = await validateGetArtistRequest(req, "not-valid"); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "unauthorized" }, { status: 401 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + expect(validateAuthContext).toHaveBeenCalledWith(req); + }); + + it("returns the validated artist id when auth succeeds", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: validUuid, + orgId: null, + authToken: "token", + }); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toEqual({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + expect(validateAccountParams).toHaveBeenCalledWith(validUuid); + expect(validateAuthContext).toHaveBeenCalledWith(req); + }); +}); diff --git a/lib/artists/getArtistById.ts b/lib/artists/getArtistById.ts new file mode 100644 index 00000000..0b2ab32d --- /dev/null +++ b/lib/artists/getArtistById.ts @@ -0,0 +1,34 @@ +import { getFormattedArtist } from "@/lib/artists/getFormattedArtist"; +import { selectAccountWithArtistDetails } from "@/lib/supabase/accounts/selectAccountWithArtistDetails"; + +export interface ArtistDetail { + id: string; + account_id: string; + name: string; + image: string | null; + instruction: string | null; + knowledges: ReturnType["knowledges"]; + label: string | null; + account_socials: ReturnType["account_socials"]; +} + +/** + * Retrieves a single artist by account ID and formats it for the public artist detail route. + * + * @param artistId - The artist account ID + * @returns Artist detail payload or null when not found + */ +export async function getArtistById(artistId: string): Promise { + const account = await selectAccountWithArtistDetails(artistId); + + if (!account) { + return null; + } + + const formattedArtist = getFormattedArtist(account); + + return { + id: artistId, + ...formattedArtist, + }; +} diff --git a/lib/artists/getArtistHandler.ts b/lib/artists/getArtistHandler.ts new file mode 100644 index 00000000..852f9f91 --- /dev/null +++ b/lib/artists/getArtistHandler.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetArtistRequest } from "@/lib/artists/validateGetArtistRequest"; +import { getArtistById } from "@/lib/artists/getArtistById"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; + +/** + * Handler for retrieving a single artist detail by account ID. + * + * @param request - The incoming request + * @param params - Route params containing the artist account ID + * @returns A NextResponse with the artist payload or an error + */ +export async function getArtistHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validatedRequest = await validateGetArtistRequest(request, id); + if (validatedRequest instanceof NextResponse) { + return validatedRequest; + } + + const artist = await getArtistById(validatedRequest.artistId); + + if (!artist) { + return NextResponse.json( + { + status: "error", + error: "Artist not found", + }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + const accessResult = await validateAccountIdOverride({ + currentAccountId: validatedRequest.requesterAccountId, + targetAccountId: validatedRequest.artistId, + }); + if (accessResult instanceof NextResponse) { + return accessResult; + } + + return NextResponse.json( + { + artist, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] getArtistHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateGetArtistRequest.ts b/lib/artists/validateGetArtistRequest.ts new file mode 100644 index 00000000..a06fe635 --- /dev/null +++ b/lib/artists/validateGetArtistRequest.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +export interface GetArtistRequest { + artistId: string; + requesterAccountId: string; +} + +/** + * Validates GET /api/artists/{id} path params and authentication. + * + * @param request - The incoming request + * @param id - The artist account ID from the route + * @returns The validated artist ID plus requester context, or a NextResponse error + */ +export async function validateGetArtistRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) { + return validatedParams; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + return { + artistId: validatedParams.id, + requesterAccountId: authResult.accountId, + }; +} diff --git a/lib/supabase/accounts/selectAccountWithArtistDetails.ts b/lib/supabase/accounts/selectAccountWithArtistDetails.ts new file mode 100644 index 00000000..fd39ef3d --- /dev/null +++ b/lib/supabase/accounts/selectAccountWithArtistDetails.ts @@ -0,0 +1,33 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type AccountSocialWithSocial = Tables<"account_socials"> & { + social: Tables<"socials"> | null; +}; + +export type AccountWithArtistDetails = Tables<"accounts"> & { + account_info: Tables<"account_info">[]; + account_socials: AccountSocialWithSocial[]; +}; + +/** + * Retrieves an account with artist-compatible relations for detail responses. + * + * @param accountId - The account ID to fetch + * @returns Account row with account_info and joined socials, or null when missing + */ +export async function selectAccountWithArtistDetails( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("accounts") + .select("*, account_info(*), account_socials(*, social:socials(*))") + .eq("id", accountId) + .single(); + + if (error || !data) { + return null; + } + + return data as AccountWithArtistDetails; +} From 1b405753e73614fef49a89cb82c58a8d1f604aaf Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 9 Apr 2026 04:40:54 +0530 Subject: [PATCH 2/4] fix: tighten artist detail read behavior --- lib/artists/__tests__/getArtistHandler.test.ts | 12 ++++++------ lib/artists/getArtistById.ts | 10 +++++++++- lib/artists/getArtistHandler.ts | 16 ++++++++-------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/artists/__tests__/getArtistHandler.test.ts b/lib/artists/__tests__/getArtistHandler.test.ts index 06bc7924..b06ce294 100644 --- a/lib/artists/__tests__/getArtistHandler.test.ts +++ b/lib/artists/__tests__/getArtistHandler.test.ts @@ -45,6 +45,7 @@ describe("getArtistHandler", () => { artistId: validUuid, requesterAccountId: validUuid, }); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); vi.mocked(getArtistById).mockResolvedValue(null); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); @@ -53,6 +54,10 @@ describe("getArtistHandler", () => { expect(res.status).toBe(404); expect(body.error).toBe("Artist not found"); + expect(validateAccountIdOverride).toHaveBeenCalledWith({ + currentAccountId: validUuid, + targetAccountId: validUuid, + }); }); it("returns 200 with the merged artist payload", async () => { @@ -101,12 +106,6 @@ describe("getArtistHandler", () => { artistId: validUuid, requesterAccountId: "11111111-1111-4111-8111-111111111111", }); - vi.mocked(getArtistById).mockResolvedValue({ - account_id: validUuid, - id: validUuid, - name: "Test Artist", - account_socials: [], - } as never); vi.mocked(validateAccountIdOverride).mockResolvedValue( NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), ); @@ -115,5 +114,6 @@ describe("getArtistHandler", () => { const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); expect(res.status).toBe(403); + expect(getArtistById).not.toHaveBeenCalled(); }); }); diff --git a/lib/artists/getArtistById.ts b/lib/artists/getArtistById.ts index 0b2ab32d..869c1501 100644 --- a/lib/artists/getArtistById.ts +++ b/lib/artists/getArtistById.ts @@ -26,9 +26,17 @@ export async function getArtistById(artistId: string): Promise Date: Thu, 9 Apr 2026 04:55:38 +0530 Subject: [PATCH 3/4] refactor: move artist access check into validation --- .../__tests__/getArtistHandler.test.ts | 18 +--------- .../validateGetArtistRequest.test.ts | 33 +++++++++++++++++++ lib/artists/getArtistHandler.ts | 9 ----- lib/artists/validateGetArtistRequest.ts | 11 ++++++- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/lib/artists/__tests__/getArtistHandler.test.ts b/lib/artists/__tests__/getArtistHandler.test.ts index b06ce294..4bfe3311 100644 --- a/lib/artists/__tests__/getArtistHandler.test.ts +++ b/lib/artists/__tests__/getArtistHandler.test.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getArtistHandler } from "../getArtistHandler"; import { validateGetArtistRequest } from "../validateGetArtistRequest"; import { getArtistById } from "../getArtistById"; -import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -17,10 +16,6 @@ vi.mock("../getArtistById", () => ({ getArtistById: vi.fn(), })); -vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ - validateAccountIdOverride: vi.fn(), -})); - const validUuid = "550e8400-e29b-41d4-a716-446655440000"; describe("getArtistHandler", () => { @@ -45,7 +40,6 @@ describe("getArtistHandler", () => { artistId: validUuid, requesterAccountId: validUuid, }); - vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); vi.mocked(getArtistById).mockResolvedValue(null); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); @@ -54,10 +48,6 @@ describe("getArtistHandler", () => { expect(res.status).toBe(404); expect(body.error).toBe("Artist not found"); - expect(validateAccountIdOverride).toHaveBeenCalledWith({ - currentAccountId: validUuid, - targetAccountId: validUuid, - }); }); it("returns 200 with the merged artist payload", async () => { @@ -77,7 +67,6 @@ describe("getArtistHandler", () => { requesterAccountId: validUuid, }); vi.mocked(getArtistById).mockResolvedValue(artist as never); - vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); @@ -93,7 +82,6 @@ describe("getArtistHandler", () => { requesterAccountId: validUuid, }); vi.mocked(getArtistById).mockResolvedValue({ account_id: validUuid } as never); - vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); await getArtistHandler(req, Promise.resolve({ id: validUuid })); @@ -102,11 +90,7 @@ describe("getArtistHandler", () => { }); it("returns 403 when the authenticated account cannot access the artist", async () => { - vi.mocked(validateGetArtistRequest).mockResolvedValue({ - artistId: validUuid, - requesterAccountId: "11111111-1111-4111-8111-111111111111", - }); - vi.mocked(validateAccountIdOverride).mockResolvedValue( + vi.mocked(validateGetArtistRequest).mockResolvedValue( NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), ); diff --git a/lib/artists/__tests__/validateGetArtistRequest.test.ts b/lib/artists/__tests__/validateGetArtistRequest.test.ts index 38ef7010..9377deca 100644 --- a/lib/artists/__tests__/validateGetArtistRequest.test.ts +++ b/lib/artists/__tests__/validateGetArtistRequest.test.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validateGetArtistRequest } from "../validateGetArtistRequest"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -16,6 +17,10 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ + validateAccountIdOverride: vi.fn(), +})); + const validUuid = "550e8400-e29b-41d4-a716-446655440000"; describe("validateGetArtistRequest", () => { @@ -48,6 +53,29 @@ describe("validateGetArtistRequest", () => { expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(401); expect(validateAuthContext).toHaveBeenCalledWith(req); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + }); + + it("returns 403 when the authenticated account cannot access the artist", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "11111111-1111-4111-8111-111111111111", + orgId: null, + authToken: "token", + }); + vi.mocked(validateAccountIdOverride).mockResolvedValue( + NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + expect(validateAccountIdOverride).toHaveBeenCalledWith({ + currentAccountId: "11111111-1111-4111-8111-111111111111", + targetAccountId: validUuid, + }); }); it("returns the validated artist id when auth succeeds", async () => { @@ -57,6 +85,7 @@ describe("validateGetArtistRequest", () => { orgId: null, authToken: "token", }); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); const result = await validateGetArtistRequest(req, validUuid); @@ -67,5 +96,9 @@ describe("validateGetArtistRequest", () => { }); expect(validateAccountParams).toHaveBeenCalledWith(validUuid); expect(validateAuthContext).toHaveBeenCalledWith(req); + expect(validateAccountIdOverride).toHaveBeenCalledWith({ + currentAccountId: validUuid, + targetAccountId: validUuid, + }); }); }); diff --git a/lib/artists/getArtistHandler.ts b/lib/artists/getArtistHandler.ts index 8231369b..d6ec0bf3 100644 --- a/lib/artists/getArtistHandler.ts +++ b/lib/artists/getArtistHandler.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetArtistRequest } from "@/lib/artists/validateGetArtistRequest"; import { getArtistById } from "@/lib/artists/getArtistById"; -import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; /** * Handler for retrieving a single artist detail by account ID. @@ -23,14 +22,6 @@ export async function getArtistHandler( return validatedRequest; } - const accessResult = await validateAccountIdOverride({ - currentAccountId: validatedRequest.requesterAccountId, - targetAccountId: validatedRequest.artistId, - }); - if (accessResult instanceof NextResponse) { - return accessResult; - } - const artist = await getArtistById(validatedRequest.artistId); if (!artist) { diff --git a/lib/artists/validateGetArtistRequest.ts b/lib/artists/validateGetArtistRequest.ts index a06fe635..b95a58b1 100644 --- a/lib/artists/validateGetArtistRequest.ts +++ b/lib/artists/validateGetArtistRequest.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; export interface GetArtistRequest { artistId: string; @@ -28,8 +29,16 @@ export async function validateGetArtistRequest( return authResult; } + const overrideResult = await validateAccountIdOverride({ + currentAccountId: authResult.accountId, + targetAccountId: validatedParams.id, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + return { - artistId: validatedParams.id, + artistId: overrideResult.accountId, requesterAccountId: authResult.accountId, }; } From de0d19c63def81a589e580397874fab78214970a Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 9 Apr 2026 05:38:03 +0530 Subject: [PATCH 4/4] fix: align artist detail access with list visibility --- .../validateGetArtistRequest.test.ts | 46 +++++++++++++------ lib/artists/validateGetArtistRequest.ts | 23 ++++++---- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/artists/__tests__/validateGetArtistRequest.test.ts b/lib/artists/__tests__/validateGetArtistRequest.test.ts index 9377deca..67bcdab9 100644 --- a/lib/artists/__tests__/validateGetArtistRequest.test.ts +++ b/lib/artists/__tests__/validateGetArtistRequest.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validateGetArtistRequest } from "../validateGetArtistRequest"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; +import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -17,8 +17,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ - validateAccountIdOverride: vi.fn(), +vi.mock("../checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), })); const validUuid = "550e8400-e29b-41d4-a716-446655440000"; @@ -53,7 +53,7 @@ describe("validateGetArtistRequest", () => { expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(401); expect(validateAuthContext).toHaveBeenCalledWith(req); - expect(validateAccountIdOverride).not.toHaveBeenCalled(); + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); }); it("returns 403 when the authenticated account cannot access the artist", async () => { @@ -63,19 +63,17 @@ describe("validateGetArtistRequest", () => { orgId: null, authToken: "token", }); - vi.mocked(validateAccountIdOverride).mockResolvedValue( - NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), - ); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); const result = await validateGetArtistRequest(req, validUuid); expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(403); - expect(validateAccountIdOverride).toHaveBeenCalledWith({ - currentAccountId: "11111111-1111-4111-8111-111111111111", - targetAccountId: validUuid, - }); + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + validUuid, + ); }); it("returns the validated artist id when auth succeeds", async () => { @@ -85,7 +83,6 @@ describe("validateGetArtistRequest", () => { orgId: null, authToken: "token", }); - vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: validUuid }); const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); const result = await validateGetArtistRequest(req, validUuid); @@ -96,9 +93,28 @@ describe("validateGetArtistRequest", () => { }); expect(validateAccountParams).toHaveBeenCalledWith(validUuid); expect(validateAuthContext).toHaveBeenCalledWith(req); - expect(validateAccountIdOverride).toHaveBeenCalledWith({ - currentAccountId: validUuid, - targetAccountId: validUuid, + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + }); + + it("returns the validated artist id when the authenticated account can access the artist", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "11111111-1111-4111-8111-111111111111", + orgId: null, + authToken: "token", }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toEqual({ + artistId: validUuid, + requesterAccountId: "11111111-1111-4111-8111-111111111111", + }); + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + validUuid, + ); }); }); diff --git a/lib/artists/validateGetArtistRequest.ts b/lib/artists/validateGetArtistRequest.ts index b95a58b1..43cda908 100644 --- a/lib/artists/validateGetArtistRequest.ts +++ b/lib/artists/validateGetArtistRequest.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; export interface GetArtistRequest { artistId: string; @@ -29,16 +30,22 @@ export async function validateGetArtistRequest( return authResult; } - const overrideResult = await validateAccountIdOverride({ - currentAccountId: authResult.accountId, - targetAccountId: validatedParams.id, - }); - if (overrideResult instanceof NextResponse) { - return overrideResult; + if (validatedParams.id !== authResult.accountId) { + const hasArtistAccess = await checkAccountArtistAccess( + authResult.accountId, + validatedParams.id, + ); + + if (!hasArtistAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified artist" }, + { status: 403, headers: getCorsHeaders() }, + ); + } } return { - artistId: overrideResult.accountId, + artistId: validatedParams.id, requesterAccountId: authResult.accountId, }; }