diff --git a/app/api/accounts/route.ts b/app/api/accounts/route.ts index 6bdebbfa..7f57cd00 100644 --- a/app/api/accounts/route.ts +++ b/app/api/accounts/route.ts @@ -5,12 +5,9 @@ import { validateCreateAccountBody, type CreateAccountBody, } from "@/lib/accounts/validateCreateAccountBody"; -import { - validateUpdateAccountBody, - type UpdateAccountBody, -} from "@/lib/accounts/validateUpdateAccountBody"; import { createAccountHandler } from "@/lib/accounts/createAccountHandler"; import { updateAccountHandler } from "@/lib/accounts/updateAccountHandler"; +import { validateUpdateAccountRequest } from "@/lib/accounts/validateUpdateAccountRequest"; /** * POST /api/accounts @@ -37,20 +34,18 @@ export async function POST(req: NextRequest) { * PATCH /api/accounts * * Update an existing account's profile information. - * Requires accountId in the body along with fields to update. + * Requires authentication and a permitted accountId in the body. * * @param req - The incoming request with accountId and update fields * @returns NextResponse with updated account data or error */ export async function PATCH(req: NextRequest) { - const body = await safeParseJson(req); - - const validated = validateUpdateAccountBody(body); + const validated = await validateUpdateAccountRequest(req); if (validated instanceof NextResponse) { return validated; } - return updateAccountHandler(validated as UpdateAccountBody); + return updateAccountHandler(validated); } /** diff --git a/lib/accounts/__tests__/validateUpdateAccountRequest.test.ts b/lib/accounts/__tests__/validateUpdateAccountRequest.test.ts new file mode 100644 index 00000000..17f350aa --- /dev/null +++ b/lib/accounts/__tests__/validateUpdateAccountRequest.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateUpdateAccountRequest } from "../validateUpdateAccountRequest"; + +const mockValidateAuthContext = vi.fn(); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(async (req: Request) => req.json()), +})); + +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/accounts", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-token", + }, + body: JSON.stringify(body), + }); +} + +describe("validateUpdateAccountRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + orgId: null, + authToken: "test-token", + }); + }); + + it("returns validated payload for a valid request", async () => { + const request = createRequest({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + name: "Test User", + knowledges: [ + { + name: "Strategy", + url: "https://example.com/strategy.pdf", + type: "application/pdf", + }, + ], + }); + + const result = await validateUpdateAccountRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result).toEqual({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + name: "Test User", + knowledges: [ + { + name: "Strategy", + url: "https://example.com/strategy.pdf", + type: "application/pdf", + }, + ], + }); + } + expect(mockValidateAuthContext).toHaveBeenCalledWith(request, { + accountId: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("replaces accountId with the validated override account", async () => { + mockValidateAuthContext.mockResolvedValue({ + accountId: "660e8400-e29b-41d4-a716-446655440001", + orgId: "org-123", + authToken: "test-token", + }); + + const request = createRequest({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + name: "Org Member", + }); + + const result = await validateUpdateAccountRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.accountId).toBe("660e8400-e29b-41d4-a716-446655440001"); + } + }); + + it("returns 400 when knowledges payload is invalid", async () => { + const request = createRequest({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + knowledges: ["not-an-object"], + }); + + const result = await validateUpdateAccountRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("succeeds without accountId in body (derives from auth)", async () => { + const request = createRequest({ + name: "Updated Name", + }); + + const result = await validateUpdateAccountRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(result.name).toBe("Updated Name"); + } + }); + + it("returns 400 when no updatable fields are provided", async () => { + const request = createRequest({}); + + const result = await validateUpdateAccountRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns auth error response when auth fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createRequest({ + name: "Test", + }); + + const result = await validateUpdateAccountRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); +}); diff --git a/lib/accounts/updateAccountHandler.ts b/lib/accounts/updateAccountHandler.ts index cacb7d51..42777986 100644 --- a/lib/accounts/updateAccountHandler.ts +++ b/lib/accounts/updateAccountHandler.ts @@ -5,7 +5,7 @@ import { updateAccount } from "@/lib/supabase/accounts/updateAccount"; import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; import { updateAccountInfo } from "@/lib/supabase/account_info/updateAccountInfo"; import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; -import type { UpdateAccountBody } from "./validateUpdateAccountBody"; +import type { ValidatedUpdateAccountRequest } from "./validateUpdateAccountRequest"; /** * Handles PATCH /api/accounts - Update account profile information. @@ -13,7 +13,9 @@ import type { UpdateAccountBody } from "./validateUpdateAccountBody"; * @param body - Validated request body with accountId and fields to update * @returns NextResponse with updated account data */ -export async function updateAccountHandler(body: UpdateAccountBody): Promise { +export async function updateAccountHandler( + body: ValidatedUpdateAccountRequest, +): Promise { const { accountId, name, diff --git a/lib/accounts/validateUpdateAccountBody.ts b/lib/accounts/validateUpdateAccountBody.ts deleted file mode 100644 index 35a06b3c..00000000 --- a/lib/accounts/validateUpdateAccountBody.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const updateAccountBodySchema = z.object({ - accountId: z.string().uuid("accountId must be a valid UUID"), - name: z.string().optional(), - instruction: z.string().optional(), - organization: z.string().optional(), - image: z.string().url("image must be a valid URL").optional().or(z.literal("")), - jobTitle: z.string().optional(), - roleType: z.string().optional(), - companyName: z.string().optional(), - knowledges: z.array(z.string()).optional(), -}); - -export type UpdateAccountBody = z.infer; - -/** - * Validates request body for PATCH /api/accounts. - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. - */ -export function validateUpdateAccountBody(body: unknown): NextResponse | UpdateAccountBody { - const result = updateAccountBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} diff --git a/lib/accounts/validateUpdateAccountRequest.ts b/lib/accounts/validateUpdateAccountRequest.ts new file mode 100644 index 00000000..78e66767 --- /dev/null +++ b/lib/accounts/validateUpdateAccountRequest.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { z } from "zod"; + +const knowledgeSchema = z.object({ + name: z.string(), + url: z.string().url("knowledges.url must be a valid URL"), + type: z.string(), +}); + +export const updateAccountBodySchema = z + .object({ + accountId: z.string().uuid("accountId must be a valid UUID").optional(), + name: z.string().optional(), + instruction: z.string().optional(), + organization: z.string().optional(), + image: z.string().url("image must be a valid URL").optional().or(z.literal("")), + jobTitle: z.string().optional(), + roleType: z.string().optional(), + companyName: z.string().optional(), + knowledges: z.array(knowledgeSchema).optional(), + }) + .refine( + data => { + const { accountId: _, ...fields } = data; + return Object.values(fields).some(v => v !== undefined); + }, + { message: "At least one field to update must be provided" }, + ); + +export type ValidatedUpdateAccountRequest = Omit< + z.infer, + "accountId" +> & { + accountId: string; +}; + +/** + * Validates PATCH /api/accounts including auth, account override access, and body schema. + */ +export async function validateUpdateAccountRequest( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = updateAccountBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const authContext = await validateAuthContext(request, { + accountId: result.data.accountId, + }); + + if (authContext instanceof NextResponse) { + return authContext; + } + + return { + accountId: authContext.accountId, + name: result.data.name, + instruction: result.data.instruction, + organization: result.data.organization, + image: result.data.image, + jobTitle: result.data.jobTitle, + roleType: result.data.roleType, + companyName: result.data.companyName, + knowledges: result.data.knowledges, + }; +}