From f28ad17e44e2e2235e6800f496901ba5139201cb Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 01:56:45 +0530 Subject: [PATCH 1/7] feat: add account emails endpoint --- app/api/accounts/emails/route.ts | 32 +++++ .../__tests__/getAccountEmailsHandler.test.ts | 116 ++++++++++++++++++ .../validateGetAccountEmailsQuery.test.ts | 83 +++++++++++++ lib/accounts/getAccountEmailsHandler.ts | 55 +++++++++ lib/accounts/validateGetAccountEmailsQuery.ts | 41 +++++++ .../account_emails/selectAccountEmails.ts | 10 +- 6 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 app/api/accounts/emails/route.ts create mode 100644 lib/accounts/__tests__/getAccountEmailsHandler.test.ts create mode 100644 lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts create mode 100644 lib/accounts/getAccountEmailsHandler.ts create mode 100644 lib/accounts/validateGetAccountEmailsQuery.ts diff --git a/app/api/accounts/emails/route.ts b/app/api/accounts/emails/route.ts new file mode 100644 index 00000000..6b530ae2 --- /dev/null +++ b/app/api/accounts/emails/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountEmailsHandler } from "@/lib/accounts/getAccountEmailsHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/accounts/emails + * + * Retrieves account email rows for the requested account IDs after verifying + * that the authenticated caller has access to the provided artist account. + * + * Query parameters: + * - artist_account_id (required): Artist account used for access checks + * - account_id (optional, repeatable): Account IDs to look up + */ +export async function GET(request: NextRequest) { + return getAccountEmailsHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts new file mode 100644 index 00000000..959b7d69 --- /dev/null +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getAccountEmailsHandler } from "../getAccountEmailsHandler"; +import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetAccountEmailsQuery", () => ({ + validateGetAccountEmailsQuery: vi.fn(), +})); + +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +function createMockRequest(): NextRequest { + return { + url: "http://localhost:3000/api/accounts/emails", + nextUrl: new URL("http://localhost:3000/api/accounts/emails"), + headers: new Headers({ authorization: "Bearer test-token" }), + } as unknown as NextRequest; +} + +describe("getAccountEmailsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation response errors directly", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue( + NextResponse.json({ error: "artist_account_id parameter is required" }, { status: 400 }), + ); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(result.status).toBe(400); + }); + + it("returns an empty array when no account IDs are provided", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: [], + }); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(result.status).toBe(200); + await expect(result.json()).resolves.toEqual([]); + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + }); + + it("returns 403 when the authenticated account cannot access the artist", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: ["acc-1"], + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(result.status).toBe(403); + await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); + }); + + it("returns raw account email rows when access is allowed", async () => { + const rows = [ + { + id: "email-1", + account_id: "acc-1", + email: "owner@example.com", + updated_at: "2026-04-08T00:00:00.000Z", + }, + ]; + + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: ["acc-1", "acc-2"], + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(selectAccountEmails).mockResolvedValue(rows); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(selectAccountEmails).toHaveBeenCalledWith({ accountIds: ["acc-1", "acc-2"] }); + expect(result.status).toBe(200); + await expect(result.json()).resolves.toEqual(rows); + }); + + it("returns an empty array when account email lookup returns no rows", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: ["acc-1"], + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(result.status).toBe(200); + await expect(result.json()).resolves.toEqual([]); + }); +}); diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts new file mode 100644 index 00000000..1ad38629 --- /dev/null +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +function createMockRequest(url: string): NextRequest { + return { + url, + nextUrl: new URL(url), + headers: new Headers({ authorization: "Bearer test-token" }), + } as unknown as NextRequest; +} + +describe("validateGetAccountEmailsQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth error when validateAuthContext fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateGetAccountEmailsQuery( + createMockRequest("http://localhost:3000/api/accounts/emails?artist_account_id=artist-123"), + ); + + expect(result).toBeInstanceOf(NextResponse); + expect(validateAuthContext).toHaveBeenCalledTimes(1); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); + + it("returns 400 when artist_account_id is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "token", + }); + + const result = await validateGetAccountEmailsQuery( + createMockRequest("http://localhost:3000/api/accounts/emails"), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + await expect(result.json()).resolves.toEqual({ + error: "artist_account_id parameter is required", + }); + } + }); + + it("returns parsed artist and repeated account IDs", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "token", + }); + + const result = await validateGetAccountEmailsQuery( + createMockRequest( + "http://localhost:3000/api/accounts/emails?artist_account_id=artist-456&account_id=acc-1&account_id=acc-2", + ), + ); + + expect(result).toEqual({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: ["acc-1", "acc-2"], + }); + }); +}); diff --git a/lib/accounts/getAccountEmailsHandler.ts b/lib/accounts/getAccountEmailsHandler.ts new file mode 100644 index 00000000..d9b90ef1 --- /dev/null +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { validateGetAccountEmailsQuery } from "@/lib/accounts/validateGetAccountEmailsQuery"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Handles GET /api/accounts/emails requests. + */ +export async function getAccountEmailsHandler(request: NextRequest): Promise { + const validatedQuery = await validateGetAccountEmailsQuery(request); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + if (validatedQuery.accountIds.length === 0) { + return NextResponse.json([], { + status: 200, + headers: getCorsHeaders(), + }); + } + + try { + const hasAccess = await checkAccountArtistAccess( + validatedQuery.authenticatedAccountId, + validatedQuery.artistAccountId, + ); + + if (!hasAccess) { + return NextResponse.json( + { error: "Unauthorized" }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + const emails = await selectAccountEmails({ accountIds: validatedQuery.accountIds }); + + return NextResponse.json(emails, { + status: 200, + headers: getCorsHeaders(), + }); + } catch { + return NextResponse.json( + { error: "Failed to fetch account emails" }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/accounts/validateGetAccountEmailsQuery.ts b/lib/accounts/validateGetAccountEmailsQuery.ts new file mode 100644 index 00000000..67943ba8 --- /dev/null +++ b/lib/accounts/validateGetAccountEmailsQuery.ts @@ -0,0 +1,41 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +export interface ValidatedGetAccountEmailsQuery { + authenticatedAccountId: string; + artistAccountId: string; + accountIds: string[]; +} + +/** + * Validates auth and query params for GET /api/accounts/emails. + */ +export async function validateGetAccountEmailsQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { searchParams } = new URL(request.url); + const artistAccountId = searchParams.get("artist_account_id"); + + if (!artistAccountId) { + return NextResponse.json( + { error: "artist_account_id parameter is required" }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { + authenticatedAccountId: authResult.accountId, + artistAccountId, + accountIds: searchParams.getAll("account_id"), + }; +} diff --git a/lib/supabase/account_emails/selectAccountEmails.ts b/lib/supabase/account_emails/selectAccountEmails.ts index 81f05530..a5fc793a 100644 --- a/lib/supabase/account_emails/selectAccountEmails.ts +++ b/lib/supabase/account_emails/selectAccountEmails.ts @@ -1,6 +1,11 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; +interface SelectAccountEmailsParams { + emails?: string[]; + accountIds?: string | string[]; +} + /** * Select account_emails by email addresses and/or account IDs * @@ -12,10 +17,7 @@ import type { Tables } from "@/types/database.types"; export default async function selectAccountEmails({ emails, accountIds, -}: { - emails?: string[]; - accountIds?: string | string[]; -}): Promise[]> { +}: SelectAccountEmailsParams): Promise[]> { let query = supabase.from("account_emails").select("*"); // Build query based on provided parameters From 98c91a644a3013d61ef4981ed152cdddc3e3f0d4 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 02:09:50 +0530 Subject: [PATCH 2/7] fix: harden account emails validation --- app/api/accounts/emails/route.ts | 9 +++-- .../__tests__/getAccountEmailsHandler.test.ts | 18 +++++++++- .../validateGetAccountEmailsQuery.test.ts | 2 ++ lib/accounts/getAccountEmailsHandler.ts | 14 ++++---- lib/accounts/validateGetAccountEmailsQuery.ts | 34 ++++++++++++++++--- 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/app/api/accounts/emails/route.ts b/app/api/accounts/emails/route.ts index 6b530ae2..d7f2c863 100644 --- a/app/api/accounts/emails/route.ts +++ b/app/api/accounts/emails/route.ts @@ -5,8 +5,10 @@ import { getAccountEmailsHandler } from "@/lib/accounts/getAccountEmailsHandler" /** * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. */ -export async function OPTIONS() { +export async function OPTIONS(): Promise { return new NextResponse(null, { status: 200, headers: getCorsHeaders(), @@ -22,8 +24,11 @@ export async function OPTIONS() { * Query parameters: * - artist_account_id (required): Artist account used for access checks * - account_id (optional, repeatable): Account IDs to look up + * + * @param request - The incoming request with artist and account query parameters. + * @returns A NextResponse with matching account email rows or an error response. */ -export async function GET(request: NextRequest) { +export async function GET(request: NextRequest): Promise { return getAccountEmailsHandler(request); } diff --git a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts index 959b7d69..eb9734e5 100644 --- a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -51,12 +51,28 @@ describe("getAccountEmailsHandler", () => { artistAccountId: "artist-456", accountIds: [], }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); const result = await getAccountEmailsHandler(createMockRequest()); expect(result.status).toBe(200); await expect(result.json()).resolves.toEqual([]); - expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + }); + + it("returns 403 for empty account IDs when the authenticated account cannot access the artist", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ + authenticatedAccountId: "account-123", + artistAccountId: "artist-456", + accountIds: [], + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(result.status).toBe(403); + await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); }); it("returns 403 when the authenticated account cannot access the artist", async () => { diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts index 1ad38629..f016923d 100644 --- a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -56,6 +56,8 @@ describe("validateGetAccountEmailsQuery", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); await expect(result.json()).resolves.toEqual({ + status: "error", + missing_fields: ["artist_account_id"], error: "artist_account_id parameter is required", }); } diff --git a/lib/accounts/getAccountEmailsHandler.ts b/lib/accounts/getAccountEmailsHandler.ts index d9b90ef1..fbab8122 100644 --- a/lib/accounts/getAccountEmailsHandler.ts +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -14,13 +14,6 @@ export async function getAccountEmailsHandler(request: NextRequest): Promise value ?? "", + z + .string() + .min(1, "artist_account_id parameter is required") + .describe("Artist account ID to authorize against."), + ), + account_id: z + .array(z.string()) + .optional() + .default([]) + .describe("Repeat this query parameter to fetch multiple account email rows."), +}); + export interface ValidatedGetAccountEmailsQuery { authenticatedAccountId: string; artistAccountId: string; @@ -21,11 +37,19 @@ export async function validateGetAccountEmailsQuery( } const { searchParams } = new URL(request.url); - const artistAccountId = searchParams.get("artist_account_id"); + const validationResult = getAccountEmailsQuerySchema.safeParse({ + artist_account_id: searchParams.get("artist_account_id") ?? undefined, + account_id: searchParams.getAll("account_id"), + }); - if (!artistAccountId) { + if (!validationResult.success) { + const firstError = validationResult.error.issues[0]; return NextResponse.json( - { error: "artist_account_id parameter is required" }, + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, { status: 400, headers: getCorsHeaders(), @@ -35,7 +59,7 @@ export async function validateGetAccountEmailsQuery( return { authenticatedAccountId: authResult.accountId, - artistAccountId, - accountIds: searchParams.getAll("account_id"), + artistAccountId: validationResult.data.artist_account_id, + accountIds: validationResult.data.account_id, }; } From d31bd25c956521ca959d8d9c0b4a79bf11c64d1b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 02:57:23 +0530 Subject: [PATCH 3/7] refactor: scope account emails auth to account access --- app/api/accounts/emails/route.ts | 5 +- .../__tests__/getAccountEmailsHandler.test.ts | 50 +++++++------------ .../validateGetAccountEmailsQuery.test.ts | 28 +++-------- lib/accounts/getAccountEmailsHandler.ts | 25 +++++----- lib/accounts/validateGetAccountEmailsQuery.ts | 40 +-------------- 5 files changed, 41 insertions(+), 107 deletions(-) diff --git a/app/api/accounts/emails/route.ts b/app/api/accounts/emails/route.ts index d7f2c863..ba0927b9 100644 --- a/app/api/accounts/emails/route.ts +++ b/app/api/accounts/emails/route.ts @@ -19,13 +19,12 @@ export async function OPTIONS(): Promise { * GET /api/accounts/emails * * Retrieves account email rows for the requested account IDs after verifying - * that the authenticated caller has access to the provided artist account. + * that the authenticated caller can access every requested account. * * Query parameters: - * - artist_account_id (required): Artist account used for access checks * - account_id (optional, repeatable): Account IDs to look up * - * @param request - The incoming request with artist and account query parameters. + * @param request - The incoming request with account query parameters. * @returns A NextResponse with matching account email rows or an error response. */ export async function GET(request: NextRequest): Promise { diff --git a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts index eb9734e5..37471c56 100644 --- a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -3,7 +3,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getAccountEmailsHandler } from "../getAccountEmailsHandler"; import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; -import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -14,8 +14,8 @@ vi.mock("../validateGetAccountEmailsQuery", () => ({ validateGetAccountEmailsQuery: vi.fn(), })); -vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ - checkAccountArtistAccess: vi.fn(), +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), })); vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ @@ -37,60 +37,46 @@ describe("getAccountEmailsHandler", () => { it("returns validation response errors directly", async () => { vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue( - NextResponse.json({ error: "artist_account_id parameter is required" }, { status: 400 }), + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); const result = await getAccountEmailsHandler(createMockRequest()); - expect(result.status).toBe(400); + expect(result.status).toBe(401); }); it("returns an empty array when no account IDs are provided", async () => { vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ authenticatedAccountId: "account-123", - artistAccountId: "artist-456", accountIds: [], }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); const result = await getAccountEmailsHandler(createMockRequest()); expect(result.status).toBe(200); await expect(result.json()).resolves.toEqual([]); - expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(checkAccountAccess).not.toHaveBeenCalled(); }); - it("returns 403 for empty account IDs when the authenticated account cannot access the artist", async () => { + it("returns 403 when any requested account is unauthorized", async () => { vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ authenticatedAccountId: "account-123", - artistAccountId: "artist-456", - accountIds: [], - }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); - - const result = await getAccountEmailsHandler(createMockRequest()); - - expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); - expect(result.status).toBe(403); - await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); - }); - - it("returns 403 when the authenticated account cannot access the artist", async () => { - vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ - authenticatedAccountId: "account-123", - artistAccountId: "artist-456", - accountIds: ["acc-1"], + accountIds: ["acc-1", "acc-2"], }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + vi.mocked(checkAccountAccess) + .mockResolvedValueOnce({ hasAccess: true, entityType: "self" }) + .mockResolvedValueOnce({ hasAccess: false }); const result = await getAccountEmailsHandler(createMockRequest()); - expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-1"); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-2"); expect(result.status).toBe(403); await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); + expect(selectAccountEmails).not.toHaveBeenCalled(); }); - it("returns raw account email rows when access is allowed", async () => { + it("returns raw account email rows when all requested accounts are authorized", async () => { const rows = [ { id: "email-1", @@ -102,10 +88,9 @@ describe("getAccountEmailsHandler", () => { vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ authenticatedAccountId: "account-123", - artistAccountId: "artist-456", accountIds: ["acc-1", "acc-2"], }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); vi.mocked(selectAccountEmails).mockResolvedValue(rows); const result = await getAccountEmailsHandler(createMockRequest()); @@ -118,10 +103,9 @@ describe("getAccountEmailsHandler", () => { it("returns an empty array when account email lookup returns no rows", async () => { vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ authenticatedAccountId: "account-123", - artistAccountId: "artist-456", accountIds: ["acc-1"], }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); vi.mocked(selectAccountEmails).mockResolvedValue([]); const result = await getAccountEmailsHandler(createMockRequest()); diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts index f016923d..2fb51b9f 100644 --- a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -4,10 +4,6 @@ import { NextResponse } from "next/server"; import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); @@ -31,7 +27,7 @@ describe("validateGetAccountEmailsQuery", () => { ); const result = await validateGetAccountEmailsQuery( - createMockRequest("http://localhost:3000/api/accounts/emails?artist_account_id=artist-123"), + createMockRequest("http://localhost:3000/api/accounts/emails?account_id=acc-1"), ); expect(result).toBeInstanceOf(NextResponse); @@ -41,7 +37,7 @@ describe("validateGetAccountEmailsQuery", () => { } }); - it("returns 400 when artist_account_id is missing", async () => { + it("returns an empty accountIds array when no account IDs are provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -52,18 +48,13 @@ describe("validateGetAccountEmailsQuery", () => { createMockRequest("http://localhost:3000/api/accounts/emails"), ); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - await expect(result.json()).resolves.toEqual({ - status: "error", - missing_fields: ["artist_account_id"], - error: "artist_account_id parameter is required", - }); - } + expect(result).toEqual({ + authenticatedAccountId: "account-123", + accountIds: [], + }); }); - it("returns parsed artist and repeated account IDs", async () => { + it("returns parsed repeated account IDs", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -71,14 +62,11 @@ describe("validateGetAccountEmailsQuery", () => { }); const result = await validateGetAccountEmailsQuery( - createMockRequest( - "http://localhost:3000/api/accounts/emails?artist_account_id=artist-456&account_id=acc-1&account_id=acc-2", - ), + createMockRequest("http://localhost:3000/api/accounts/emails?account_id=acc-1&account_id=acc-2"), ); expect(result).toEqual({ authenticatedAccountId: "account-123", - artistAccountId: "artist-456", accountIds: ["acc-1", "acc-2"], }); }); diff --git a/lib/accounts/getAccountEmailsHandler.ts b/lib/accounts/getAccountEmailsHandler.ts index fbab8122..b9cd5fa7 100644 --- a/lib/accounts/getAccountEmailsHandler.ts +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { validateGetAccountEmailsQuery } from "@/lib/accounts/validateGetAccountEmailsQuery"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; @@ -15,12 +15,20 @@ export async function getAccountEmailsHandler(request: NextRequest): Promise + checkAccountAccess(validatedQuery.authenticatedAccountId, accountId), + ), ); - if (!hasAccess) { + if (accessResults.some(result => !result.hasAccess)) { return NextResponse.json( { error: "Unauthorized" }, { @@ -30,13 +38,6 @@ export async function getAccountEmailsHandler(request: NextRequest): Promise value ?? "", - z - .string() - .min(1, "artist_account_id parameter is required") - .describe("Artist account ID to authorize against."), - ), - account_id: z - .array(z.string()) - .optional() - .default([]) - .describe("Repeat this query parameter to fetch multiple account email rows."), -}); - export interface ValidatedGetAccountEmailsQuery { authenticatedAccountId: string; - artistAccountId: string; accountIds: string[]; } @@ -37,29 +19,9 @@ export async function validateGetAccountEmailsQuery( } const { searchParams } = new URL(request.url); - const validationResult = getAccountEmailsQuerySchema.safeParse({ - artist_account_id: searchParams.get("artist_account_id") ?? undefined, - account_id: searchParams.getAll("account_id"), - }); - - if (!validationResult.success) { - const firstError = validationResult.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } return { authenticatedAccountId: authResult.accountId, - artistAccountId: validationResult.data.artist_account_id, - accountIds: validationResult.data.account_id, + accountIds: searchParams.getAll("account_id"), }; } From abc9ab9733efbfa63a2ff825ae7dd7db837587cd Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:06:12 +0530 Subject: [PATCH 4/7] fix: require account ids for account emails --- .../validateGetAccountEmailsQuery.test.ts | 19 ++++++++---- lib/accounts/validateGetAccountEmailsQuery.ts | 29 ++++++++++++++++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts index 2fb51b9f..359106b6 100644 --- a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -4,6 +4,10 @@ import { NextResponse } from "next/server"; import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); @@ -37,7 +41,7 @@ describe("validateGetAccountEmailsQuery", () => { } }); - it("returns an empty accountIds array when no account IDs are provided", async () => { + it("returns 400 when no account IDs are provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -48,10 +52,15 @@ describe("validateGetAccountEmailsQuery", () => { createMockRequest("http://localhost:3000/api/accounts/emails"), ); - expect(result).toEqual({ - authenticatedAccountId: "account-123", - accountIds: [], - }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + await expect(result.json()).resolves.toEqual({ + status: "error", + missing_fields: ["account_id"], + error: "At least one account_id parameter is required", + }); + } }); it("returns parsed repeated account IDs", async () => { diff --git a/lib/accounts/validateGetAccountEmailsQuery.ts b/lib/accounts/validateGetAccountEmailsQuery.ts index 3ad389b8..6e809f2e 100644 --- a/lib/accounts/validateGetAccountEmailsQuery.ts +++ b/lib/accounts/validateGetAccountEmailsQuery.ts @@ -1,7 +1,16 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +export const getAccountEmailsQuerySchema = z.object({ + account_id: z + .array(z.string()) + .min(1, "At least one account_id parameter is required") + .describe("Repeat this query parameter to fetch one or more account email rows."), +}); + export interface ValidatedGetAccountEmailsQuery { authenticatedAccountId: string; accountIds: string[]; @@ -19,9 +28,27 @@ export async function validateGetAccountEmailsQuery( } const { searchParams } = new URL(request.url); + const validationResult = getAccountEmailsQuerySchema.safeParse({ + account_id: searchParams.getAll("account_id"), + }); + + if (!validationResult.success) { + const firstError = validationResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } return { authenticatedAccountId: authResult.accountId, - accountIds: searchParams.getAll("account_id"), + accountIds: validationResult.data.account_id, }; } From 27f93a62b8423732cf2dc7de250888af9c1ab208 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:07:39 +0530 Subject: [PATCH 5/7] style: format account emails validation test --- lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts index 359106b6..1c7c7caa 100644 --- a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -71,7 +71,9 @@ describe("validateGetAccountEmailsQuery", () => { }); const result = await validateGetAccountEmailsQuery( - createMockRequest("http://localhost:3000/api/accounts/emails?account_id=acc-1&account_id=acc-2"), + createMockRequest( + "http://localhost:3000/api/accounts/emails?account_id=acc-1&account_id=acc-2", + ), ); expect(result).toEqual({ From ecc6633c73bfd7a1fc4630c85cc7d91af578b9d5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:11:41 +0530 Subject: [PATCH 6/7] refactor: drop dead empty account emails branch --- .../__tests__/getAccountEmailsHandler.test.ts | 19 ++++++++++++------- lib/accounts/getAccountEmailsHandler.ts | 7 ------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts index 37471c56..9af9f9d5 100644 --- a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -45,16 +45,21 @@ describe("getAccountEmailsHandler", () => { expect(result.status).toBe(401); }); - it("returns an empty array when no account IDs are provided", async () => { - vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ - authenticatedAccountId: "account-123", - accountIds: [], - }); + it("returns validation errors for empty account ID input", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue( + NextResponse.json( + { + status: "error", + missing_fields: ["account_id"], + error: "At least one account_id parameter is required", + }, + { status: 400 }, + ), + ); const result = await getAccountEmailsHandler(createMockRequest()); - expect(result.status).toBe(200); - await expect(result.json()).resolves.toEqual([]); + expect(result.status).toBe(400); expect(checkAccountAccess).not.toHaveBeenCalled(); }); diff --git a/lib/accounts/getAccountEmailsHandler.ts b/lib/accounts/getAccountEmailsHandler.ts index b9cd5fa7..f686707c 100644 --- a/lib/accounts/getAccountEmailsHandler.ts +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -15,13 +15,6 @@ export async function getAccountEmailsHandler(request: NextRequest): Promise checkAccountAccess(validatedQuery.authenticatedAccountId, accountId), From 1184ae9354235695b125cb25f58e0c2e61184734 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:13:24 +0530 Subject: [PATCH 7/7] refactor: validate account emails access up front --- .../__tests__/getAccountEmailsHandler.test.ts | 23 +++----------- .../validateGetAccountEmailsQuery.test.ts | 31 +++++++++++++++++++ lib/accounts/getAccountEmailsHandler.ts | 17 ---------- lib/accounts/validateGetAccountEmailsQuery.ts | 17 ++++++++++ 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts index 9af9f9d5..5cfa871b 100644 --- a/lib/accounts/__tests__/getAccountEmailsHandler.test.ts +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -3,7 +3,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getAccountEmailsHandler } from "../getAccountEmailsHandler"; import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; -import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -14,10 +13,6 @@ vi.mock("../validateGetAccountEmailsQuery", () => ({ validateGetAccountEmailsQuery: vi.fn(), })); -vi.mock("@/lib/auth/checkAccountAccess", () => ({ - checkAccountAccess: vi.fn(), -})); - vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn(), })); @@ -60,24 +55,16 @@ describe("getAccountEmailsHandler", () => { const result = await getAccountEmailsHandler(createMockRequest()); expect(result.status).toBe(400); - expect(checkAccountAccess).not.toHaveBeenCalled(); }); - it("returns 403 when any requested account is unauthorized", async () => { - vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue({ - authenticatedAccountId: "account-123", - accountIds: ["acc-1", "acc-2"], - }); - vi.mocked(checkAccountAccess) - .mockResolvedValueOnce({ hasAccess: true, entityType: "self" }) - .mockResolvedValueOnce({ hasAccess: false }); + it("returns validation response errors directly for unauthorized accounts", async () => { + vi.mocked(validateGetAccountEmailsQuery).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 403 }), + ); const result = await getAccountEmailsHandler(createMockRequest()); - expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-1"); - expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-2"); expect(result.status).toBe(403); - await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); expect(selectAccountEmails).not.toHaveBeenCalled(); }); @@ -95,7 +82,6 @@ describe("getAccountEmailsHandler", () => { authenticatedAccountId: "account-123", accountIds: ["acc-1", "acc-2"], }); - vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); vi.mocked(selectAccountEmails).mockResolvedValue(rows); const result = await getAccountEmailsHandler(createMockRequest()); @@ -110,7 +96,6 @@ describe("getAccountEmailsHandler", () => { authenticatedAccountId: "account-123", accountIds: ["acc-1"], }); - vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); vi.mocked(selectAccountEmails).mockResolvedValue([]); const result = await getAccountEmailsHandler(createMockRequest()); diff --git a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts index 1c7c7caa..de832e75 100644 --- a/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { validateGetAccountEmailsQuery } from "../validateGetAccountEmailsQuery"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -12,6 +13,10 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), +})); + function createMockRequest(url: string): NextRequest { return { url, @@ -69,6 +74,7 @@ describe("validateGetAccountEmailsQuery", () => { orgId: null, authToken: "token", }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); const result = await validateGetAccountEmailsQuery( createMockRequest( @@ -81,4 +87,29 @@ describe("validateGetAccountEmailsQuery", () => { accountIds: ["acc-1", "acc-2"], }); }); + + it("returns 403 when any requested account is unauthorized", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "token", + }); + vi.mocked(checkAccountAccess) + .mockResolvedValueOnce({ hasAccess: true, entityType: "self" }) + .mockResolvedValueOnce({ hasAccess: false }); + + const result = await validateGetAccountEmailsQuery( + createMockRequest( + "http://localhost:3000/api/accounts/emails?account_id=acc-1&account_id=acc-2", + ), + ); + + expect(result).toBeInstanceOf(NextResponse); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-1"); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", "acc-2"); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + await expect(result.json()).resolves.toEqual({ error: "Unauthorized" }); + } + }); }); diff --git a/lib/accounts/getAccountEmailsHandler.ts b/lib/accounts/getAccountEmailsHandler.ts index f686707c..70656e2d 100644 --- a/lib/accounts/getAccountEmailsHandler.ts +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -1,7 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { validateGetAccountEmailsQuery } from "@/lib/accounts/validateGetAccountEmailsQuery"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; @@ -15,22 +14,6 @@ export async function getAccountEmailsHandler(request: NextRequest): Promise - checkAccountAccess(validatedQuery.authenticatedAccountId, accountId), - ), - ); - - if (accessResults.some(result => !result.hasAccess)) { - return NextResponse.json( - { error: "Unauthorized" }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); - } - const emails = await selectAccountEmails({ accountIds: validatedQuery.accountIds }); return NextResponse.json(emails, { diff --git a/lib/accounts/validateGetAccountEmailsQuery.ts b/lib/accounts/validateGetAccountEmailsQuery.ts index 6e809f2e..e3785afc 100644 --- a/lib/accounts/validateGetAccountEmailsQuery.ts +++ b/lib/accounts/validateGetAccountEmailsQuery.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; export const getAccountEmailsQuerySchema = z.object({ @@ -47,6 +48,22 @@ export async function validateGetAccountEmailsQuery( ); } + const accessResults = await Promise.all( + validationResult.data.account_id.map(accountId => + checkAccountAccess(authResult.accountId, accountId), + ), + ); + + if (accessResults.some(result => !result.hasAccess)) { + return NextResponse.json( + { error: "Unauthorized" }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + return { authenticatedAccountId: authResult.accountId, accountIds: validationResult.data.account_id,