diff --git a/app/api/accounts/emails/route.ts b/app/api/accounts/emails/route.ts new file mode 100644 index 00000000..ba0927b9 --- /dev/null +++ b/app/api/accounts/emails/route.ts @@ -0,0 +1,36 @@ +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. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS(): Promise { + 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 can access every requested account. + * + * Query parameters: + * - account_id (optional, repeatable): Account IDs to look up + * + * @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 { + 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..5cfa871b --- /dev/null +++ b/lib/accounts/__tests__/getAccountEmailsHandler.test.ts @@ -0,0 +1,106 @@ +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 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/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({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await getAccountEmailsHandler(createMockRequest()); + + expect(result.status).toBe(401); + }); + + 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(400); + }); + + 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(result.status).toBe(403); + expect(selectAccountEmails).not.toHaveBeenCalled(); + }); + + it("returns raw account email rows when all requested accounts are authorized", 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", + accountIds: ["acc-1", "acc-2"], + }); + 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", + accountIds: ["acc-1"], + }); + 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..de832e75 --- /dev/null +++ b/lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts @@ -0,0 +1,115 @@ +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", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: 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?account_id=acc-1"), + ); + + expect(result).toBeInstanceOf(NextResponse); + expect(validateAuthContext).toHaveBeenCalledTimes(1); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); + + it("returns 400 when no account IDs are provided", 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({ + status: "error", + missing_fields: ["account_id"], + error: "At least one account_id parameter is required", + }); + } + }); + + it("returns parsed repeated account IDs", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); + + const result = await validateGetAccountEmailsQuery( + createMockRequest( + "http://localhost:3000/api/accounts/emails?account_id=acc-1&account_id=acc-2", + ), + ); + + expect(result).toEqual({ + authenticatedAccountId: "account-123", + 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 new file mode 100644 index 00000000..70656e2d --- /dev/null +++ b/lib/accounts/getAccountEmailsHandler.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +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; + } + + try { + 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..e3785afc --- /dev/null +++ b/lib/accounts/validateGetAccountEmailsQuery.ts @@ -0,0 +1,71 @@ +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({ + 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[]; +} + +/** + * 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 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(), + }, + ); + } + + 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, + }; +} 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