diff --git a/lib/artists/__tests__/buildGetArtistsParams.test.ts b/lib/artists/__tests__/buildGetArtistsParams.test.ts new file mode 100644 index 000000000..398d670bf --- /dev/null +++ b/lib/artists/__tests__/buildGetArtistsParams.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildGetArtistsParams } from "../buildGetArtistsParams"; + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +describe("buildGetArtistsParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth accountId for personal key", async () => { + const result = await buildGetArtistsParams({ + accountId: "personal-account-123", + orgId: null, + }); + + expect(result).toEqual({ + params: { accountId: "personal-account-123", orgId: null }, + error: null, + }); + }); + + it("returns auth accountId for org key", async () => { + const result = await buildGetArtistsParams({ + accountId: "org-owner-123", + orgId: "org-123", + }); + + expect(result).toEqual({ + params: { accountId: "org-owner-123", orgId: null }, + error: null, + }); + }); + + it("defaults orgId to null when orgIdFilter is omitted", async () => { + const result = await buildGetArtistsParams({ + accountId: "account-123", + orgId: null, + }); + + expect(result.params?.orgId).toBeNull(); + }); + + it("passes orgIdFilter through when provided", async () => { + const result = await buildGetArtistsParams({ + accountId: "account-123", + orgId: null, + orgIdFilter: "filter-org-456", + }); + + expect(result).toEqual({ + params: { accountId: "account-123", orgId: "filter-org-456" }, + error: null, + }); + }); + + it("returns targetAccountId when access is granted", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetArtistsParams({ + accountId: "org-owner-123", + orgId: "org-123", + targetAccountId: "target-456", + }); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "target-456", + }); + expect(result).toEqual({ + params: { accountId: "target-456", orgId: null }, + error: null, + }); + }); + + it("returns targetAccountId with orgIdFilter when both provided", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetArtistsParams({ + accountId: "org-owner-123", + orgId: "org-123", + targetAccountId: "target-456", + orgIdFilter: "filter-org-789", + }); + + expect(result).toEqual({ + params: { accountId: "target-456", orgId: "filter-org-789" }, + error: null, + }); + }); + + it("returns error when personal key tries to filter by targetAccountId", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetArtistsParams({ + accountId: "personal-123", + orgId: null, + targetAccountId: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + + it("returns error when org key lacks access to targetAccountId", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetArtistsParams({ + accountId: "org-owner-123", + orgId: "org-123", + targetAccountId: "not-in-org", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); + }); +}); diff --git a/lib/artists/__tests__/validateGetArtistsRequest.test.ts b/lib/artists/__tests__/validateGetArtistsRequest.test.ts new file mode 100644 index 000000000..699f80c68 --- /dev/null +++ b/lib/artists/__tests__/validateGetArtistsRequest.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetArtistsRequest } from "../validateGetArtistsRequest"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +describe("validateGetArtistsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/artists"); + const result = await validateGetArtistsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns personal artists for personal key (no query params)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "personal-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/artists"); + const result = await validateGetArtistsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "personal-123", + orgId: null, + }); + }); + + it("returns personal artists for org key (no query params)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-owner-123", + orgId: "org-123", + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/artists"); + const result = await validateGetArtistsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "org-owner-123", + orgId: null, + }); + }); + + it("passes org_id filter through", async () => { + const orgFilterId = "a1111111-1111-4111-8111-111111111111"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest( + `http://localhost/api/artists?org_id=${orgFilterId}`, + ); + const result = await validateGetArtistsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: orgFilterId, + }); + }); + + it("returns 400 for invalid account_id format", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest( + "http://localhost/api/artists?account_id=not-a-uuid", + ); + const result = await validateGetArtistsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for invalid org_id format", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest( + "http://localhost/api/artists?org_id=not-a-uuid", + ); + const result = await validateGetArtistsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("allows org key to filter by account_id within org", async () => { + const mockOrgId = "b2222222-2222-4222-8222-222222222222"; + const targetAccountId = "c3333333-3333-4333-8333-333333333333"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-owner", + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const request = new NextRequest( + `http://localhost/api/artists?account_id=${targetAccountId}`, + ); + const result = await validateGetArtistsRequest(request); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: mockOrgId, + targetAccountId, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: targetAccountId, + orgId: null, + }); + }); + + it("returns 403 when personal key tries to filter by account_id", async () => { + const otherAccountId = "d4444444-4444-4444-8444-444444444444"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "personal-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const request = new NextRequest( + `http://localhost/api/artists?account_id=${otherAccountId}`, + ); + const result = await validateGetArtistsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns 403 when org key lacks access to account_id", async () => { + const mockOrgId = "e5555555-5555-4555-8555-555555555555"; + const notInOrgId = "f6666666-6666-4666-8666-666666666666"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-owner", + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const request = new NextRequest( + `http://localhost/api/artists?account_id=${notInOrgId}`, + ); + const result = await validateGetArtistsRequest(request); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: mockOrgId, + targetAccountId: notInOrgId, + }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("combines account_id and org_id filters", async () => { + const mockOrgId = "a1111111-1111-4111-8111-111111111111"; + const targetAccountId = "b2222222-2222-4222-8222-222222222222"; + const orgFilterId = "c3333333-3333-4333-8333-333333333333"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-owner", + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const request = new NextRequest( + `http://localhost/api/artists?account_id=${targetAccountId}&org_id=${orgFilterId}`, + ); + const result = await validateGetArtistsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: targetAccountId, + orgId: orgFilterId, + }); + }); +}); diff --git a/lib/artists/buildGetArtistsParams.ts b/lib/artists/buildGetArtistsParams.ts new file mode 100644 index 000000000..1d6811c43 --- /dev/null +++ b/lib/artists/buildGetArtistsParams.ts @@ -0,0 +1,60 @@ +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import type { GetArtistsOptions } from "@/lib/artists/getArtists"; + +export interface BuildGetArtistsParamsInput { + /** The authenticated account ID */ + accountId: string; + /** The organization ID from the API key (null for personal keys) */ + orgId: string | null; + /** Optional target account ID to filter by */ + targetAccountId?: string; + /** Optional organization filter for which artists to show */ + orgIdFilter?: string; +} + +export type BuildGetArtistsParamsResult = + | { params: GetArtistsOptions; error: null } + | { params: null; error: string }; + +/** + * Builds the parameters for getArtists based on auth context. + * + * For personal keys: Returns the key owner's accountId + * For org keys: Returns the key owner's accountId (can override with targetAccountId) + * For Recoup admin key: Returns the key owner's accountId (can override with targetAccountId) + * + * If targetAccountId is provided, validates access and returns that account. + * + * @param input - The auth context and optional filters + * @returns The params for getArtists or an error + */ +export async function buildGetArtistsParams( + input: BuildGetArtistsParamsInput, +): Promise { + const { accountId, orgId, targetAccountId, orgIdFilter } = input; + + let effectiveAccountId = accountId; + + // Handle account_id filter if provided + if (targetAccountId) { + const hasAccess = await canAccessAccount({ orgId, targetAccountId }); + if (!hasAccess) { + return { + params: null, + error: orgId + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } + effectiveAccountId = targetAccountId; + } + + // When org_id is omitted, default to personal artists (null = personal only) + // When org_id is provided, filter to that organization's artists + const effectiveOrgId = orgIdFilter ?? null; + + return { + params: { accountId: effectiveAccountId, orgId: effectiveOrgId }, + error: null, + }; +} diff --git a/lib/artists/getArtistsHandler.ts b/lib/artists/getArtistsHandler.ts index ac86334ca..384e34259 100644 --- a/lib/artists/getArtistsHandler.ts +++ b/lib/artists/getArtistsHandler.ts @@ -1,14 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateArtistsQuery } from "@/lib/artists/validateArtistsQuery"; +import { validateGetArtistsRequest } from "@/lib/artists/validateGetArtistsRequest"; import { getArtists } from "@/lib/artists/getArtists"; /** - * Handler for retrieving artists with organization filtering. + * Handler for retrieving artists with authentication and organization filtering. + * + * Authenticates via x-api-key or Authorization Bearer token. + * The account is derived from auth; org keys can optionally filter by account_id. * * Query parameters: - * - accountId (required): The account's ID - * - orgId (optional): Filter to artists in a specific organization + * - account_id (optional): Filter to a specific account (org keys only) + * - org_id (optional): Filter to artists in a specific organization * - personal (optional): Set to "true" to show only personal (non-org) artists * * @param request - The request object containing query parameters @@ -16,20 +19,12 @@ import { getArtists } from "@/lib/artists/getArtists"; */ export async function getArtistsHandler(request: NextRequest): Promise { try { - const { searchParams } = new URL(request.url); - - const validatedQuery = validateArtistsQuery(searchParams); - if (validatedQuery instanceof NextResponse) { - return validatedQuery; + const validated = await validateGetArtistsRequest(request); + if (validated instanceof NextResponse) { + return validated; } - // Determine orgId filter: personal=true means null, orgId means specific org - const orgIdFilter = validatedQuery.personal === "true" ? null : validatedQuery.orgId; - - const artists = await getArtists({ - accountId: validatedQuery.accountId, - orgId: orgIdFilter, - }); + const artists = await getArtists(validated); return NextResponse.json( { @@ -55,4 +50,3 @@ export async function getArtistsHandler(request: NextRequest): Promise; + +/** + * Validates GET /api/artists request. + * Handles authentication via x-api-key or Authorization bearer token. + * + * For personal keys/Bearer tokens: Returns the authenticated account's artists + * For org keys: Returns the key owner's artists (can filter by account_id) + * For Recoup admin key: Returns the key owner's artists (can filter by account_id) + * + * Query parameters: + * - account_id: Filter to a specific account (org keys only) + * - org_id: Filter to artists in a specific organization (omit for personal artists) + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or GetArtistsOptions + */ +export async function validateGetArtistsRequest( + request: NextRequest, +): Promise { + // Parse query parameters first + const { searchParams } = new URL(request.url); + const queryParams = { + account_id: searchParams.get("account_id") ?? undefined, + org_id: searchParams.get("org_id") ?? undefined, + }; + + const queryResult = getArtistsQuerySchema.safeParse(queryParams); + if (!queryResult.success) { + const firstError = queryResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const { account_id: targetAccountId, org_id: orgIdFilter } = queryResult.data; + + // Use validateAuthContext for authentication + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId, orgId } = authResult; + + // Use shared function to build params + const { params, error } = await buildGetArtistsParams({ + accountId, + orgId, + targetAccountId, + orgIdFilter, + }); + + if (error) { + return NextResponse.json( + { + status: "error", + error, + }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return params; +}