Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/api/accounts/emails/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
return getAccountEmailsHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
106 changes: 106 additions & 0 deletions lib/accounts/__tests__/getAccountEmailsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
115 changes: 115 additions & 0 deletions lib/accounts/__tests__/validateGetAccountEmailsQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
});
});
32 changes: 32 additions & 0 deletions lib/accounts/getAccountEmailsHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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(),
},
);
}
}
71 changes: 71 additions & 0 deletions lib/accounts/validateGetAccountEmailsQuery.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | ValidatedGetAccountEmailsQuery> {
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,
};
}
10 changes: 6 additions & 4 deletions lib/supabase/account_emails/selectAccountEmails.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -12,10 +17,7 @@ import type { Tables } from "@/types/database.types";
export default async function selectAccountEmails({
emails,
accountIds,
}: {
emails?: string[];
accountIds?: string | string[];
}): Promise<Tables<"account_emails">[]> {
}: SelectAccountEmailsParams): Promise<Tables<"account_emails">[]> {
let query = supabase.from("account_emails").select("*");

// Build query based on provided parameters
Expand Down
Loading