diff --git a/app/api/accounts/__tests__/route.test.ts b/app/api/accounts/__tests__/route.test.ts new file mode 100644 index 00000000..8315f82a --- /dev/null +++ b/app/api/accounts/__tests__/route.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateCreateAccountBody", () => ({ + validateCreateAccountBody: vi.fn(), +})); + +vi.mock("@/lib/accounts/createAccountHandler", () => ({ + createAccountHandler: vi.fn(), +})); + +vi.mock("@/lib/accounts/patchAccountHandler", () => ({ + patchAccountHandler: vi.fn(), +})); + +const { POST, PATCH } = await import("@/app/api/accounts/route"); +const { safeParseJson } = await import("@/lib/networking/safeParseJson"); +const { validateCreateAccountBody } = await import("@/lib/accounts/validateCreateAccountBody"); +const { createAccountHandler } = await import("@/lib/accounts/createAccountHandler"); +const { patchAccountHandler } = await import("@/lib/accounts/patchAccountHandler"); + +describe("POST /api/accounts", () => { + const req = {} as never; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation error when body invalid", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateCreateAccountBody).mockReturnValue( + NextResponse.json({ status: "error" }, { status: 400 }), + ); + + const response = await POST(req); + + expect(response.status).toBe(400); + expect(createAccountHandler).not.toHaveBeenCalled(); + }); + + it("delegates to createAccountHandler when body valid", async () => { + const payload = { email: "a@b.com" }; + vi.mocked(safeParseJson).mockResolvedValue(payload); + vi.mocked(validateCreateAccountBody).mockReturnValue(payload as never); + vi.mocked(createAccountHandler).mockResolvedValue(NextResponse.json({ ok: true })); + + await POST(req); + + expect(createAccountHandler).toHaveBeenCalledWith(payload); + }); +}); + +describe("PATCH /api/accounts", () => { + const req = {} as never; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates to patchAccountHandler", async () => { + const expected = NextResponse.json({ data: {} }, { status: 200 }); + vi.mocked(patchAccountHandler).mockResolvedValue(expected); + + const response = await PATCH(req); + + expect(patchAccountHandler).toHaveBeenCalledWith(req); + expect(response).toBe(expected); + }); +}); diff --git a/app/api/accounts/route.ts b/app/api/accounts/route.ts index 7f57cd00..edcb1cae 100644 --- a/app/api/accounts/route.ts +++ b/app/api/accounts/route.ts @@ -6,8 +6,7 @@ import { type CreateAccountBody, } from "@/lib/accounts/validateCreateAccountBody"; import { createAccountHandler } from "@/lib/accounts/createAccountHandler"; -import { updateAccountHandler } from "@/lib/accounts/updateAccountHandler"; -import { validateUpdateAccountRequest } from "@/lib/accounts/validateUpdateAccountRequest"; +import { patchAccountHandler } from "@/lib/accounts/patchAccountHandler"; /** * POST /api/accounts @@ -34,18 +33,13 @@ export async function POST(req: NextRequest) { * PATCH /api/accounts * * Update an existing account's profile information. - * Requires authentication and a permitted accountId in the body. + * At least one profile field is required; optional `accountId` for admin-only override. * - * @param req - The incoming request with accountId and update fields + * @param req - The incoming request with optional accountId and update fields * @returns NextResponse with updated account data or error */ export async function PATCH(req: NextRequest) { - const validated = await validateUpdateAccountRequest(req); - if (validated instanceof NextResponse) { - return validated; - } - - return updateAccountHandler(validated); + return patchAccountHandler(req); } /** diff --git a/lib/accounts/__tests__/patchAccountHandler.test.ts b/lib/accounts/__tests__/patchAccountHandler.test.ts new file mode 100644 index 00000000..aecc095f --- /dev/null +++ b/lib/accounts/__tests__/patchAccountHandler.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { patchAccountHandler } from "@/lib/accounts/patchAccountHandler"; + +vi.mock("@/lib/accounts/validateUpdateAccountBody", () => ({ + validatePatchAccountRequest: vi.fn(), +})); + +vi.mock("@/lib/admins/checkIsAdmin", () => ({ + checkIsAdmin: vi.fn(), +})); + +vi.mock("@/lib/accounts/updateAccountHandler", () => ({ + updateAccountHandler: vi.fn(), +})); + +const { validatePatchAccountRequest } = await import("@/lib/accounts/validateUpdateAccountBody"); +const { checkIsAdmin } = await import("@/lib/admins/checkIsAdmin"); +const { updateAccountHandler } = await import("@/lib/accounts/updateAccountHandler"); + +describe("patchAccountHandler", () => { + const accountA = "11111111-1111-4111-8111-111111111111"; + const accountB = "22222222-2222-4222-8222-222222222222"; + const req = {} as NextRequest; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth error when validatePatchAccountRequest rejects auth", async () => { + vi.mocked(validatePatchAccountRequest).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const response = await patchAccountHandler(req); + + expect(response.status).toBe(401); + expect(updateAccountHandler).not.toHaveBeenCalled(); + }); + + it("updates authenticated caller account when body omits accountId", async () => { + vi.mocked(validatePatchAccountRequest).mockResolvedValue({ + auth: { accountId: accountA, orgId: null, authToken: "token" }, + body: { name: "Alice" }, + }); + vi.mocked(updateAccountHandler).mockResolvedValue( + NextResponse.json({ data: { account_id: accountA } }, { status: 200 }), + ); + + const response = await patchAccountHandler(req); + + expect(response.status).toBe(200); + expect(checkIsAdmin).not.toHaveBeenCalled(); + expect(updateAccountHandler).toHaveBeenCalledWith({ + name: "Alice", + accountId: accountA, + }); + }); + + it("rejects cross-account override for non-admin", async () => { + vi.mocked(validatePatchAccountRequest).mockResolvedValue({ + auth: { accountId: accountA, orgId: null, authToken: "token" }, + body: { accountId: accountB, name: "Bob" }, + }); + vi.mocked(checkIsAdmin).mockResolvedValue(false); + + const response = await patchAccountHandler(req); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error).toBe("accountId override is only allowed for admin accounts"); + expect(updateAccountHandler).not.toHaveBeenCalled(); + }); + + it("allows cross-account override for admin", async () => { + vi.mocked(validatePatchAccountRequest).mockResolvedValue({ + auth: { accountId: accountA, orgId: null, authToken: "token" }, + body: { accountId: accountB, name: "Bob" }, + }); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + vi.mocked(updateAccountHandler).mockResolvedValue( + NextResponse.json({ data: { account_id: accountB } }, { status: 200 }), + ); + + const response = await patchAccountHandler(req); + + expect(response.status).toBe(200); + expect(checkIsAdmin).toHaveBeenCalledWith(accountA); + expect(updateAccountHandler).toHaveBeenCalledWith({ + accountId: accountB, + name: "Bob", + }); + }); + + it("returns validation error when body validation fails", async () => { + vi.mocked(validatePatchAccountRequest).mockResolvedValue( + NextResponse.json({ status: "error", error: "invalid body" }, { status: 400 }), + ); + + const response = await patchAccountHandler(req); + + expect(response.status).toBe(400); + expect(updateAccountHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/accounts/__tests__/validatePatchAccountRequest.test.ts b/lib/accounts/__tests__/validatePatchAccountRequest.test.ts new file mode 100644 index 00000000..91498e12 --- /dev/null +++ b/lib/accounts/__tests__/validatePatchAccountRequest.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { validatePatchAccountRequest } from "@/lib/accounts/validateUpdateAccountBody"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { safeParseJson } = await import("@/lib/networking/safeParseJson"); + +describe("validatePatchAccountRequest", () => { + const accountA = "11111111-1111-4111-8111-111111111111"; + const req = {} as NextRequest; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when validateAuthContext returns error response", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validatePatchAccountRequest(req); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + expect(safeParseJson).not.toHaveBeenCalled(); + }); + + it("returns 400 when body fails schema validation", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: accountA, + orgId: null, + authToken: "token", + }); + vi.mocked(safeParseJson).mockResolvedValue({}); + + const result = await validatePatchAccountRequest(req); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns auth and validated body on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: accountA, + orgId: null, + authToken: "token", + }); + vi.mocked(safeParseJson).mockResolvedValue({ name: "Alice" }); + + const result = await validatePatchAccountRequest(req); + + expect(result).toEqual({ + auth: { accountId: accountA, orgId: null, authToken: "token" }, + body: { name: "Alice" }, + }); + }); +}); diff --git a/lib/accounts/__tests__/validateUpdateAccountBody.test.ts b/lib/accounts/__tests__/validateUpdateAccountBody.test.ts new file mode 100644 index 00000000..b861d277 --- /dev/null +++ b/lib/accounts/__tests__/validateUpdateAccountBody.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateUpdateAccountBody } from "@/lib/accounts/validateUpdateAccountBody"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +describe("validateUpdateAccountBody", () => { + it("returns 400 when no update fields are provided", async () => { + const result = validateUpdateAccountBody({}); + expect(result).toBeInstanceOf(NextResponse); + + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("At least one update field is required"); + }); + + it("accepts body with update field only", () => { + const result = validateUpdateAccountBody({ name: "Alice" }); + expect(result).toEqual({ name: "Alice" }); + }); + + it("accepts admin override shape with accountId plus update field", () => { + const accountId = "11111111-1111-4111-8111-111111111111"; + const result = validateUpdateAccountBody({ accountId, roleType: "manager" }); + expect(result).toEqual({ accountId, roleType: "manager" }); + }); + + it("returns 400 when only accountId is provided (no update fields)", async () => { + const accountId = "11111111-1111-4111-8111-111111111111"; + const result = validateUpdateAccountBody({ accountId }); + expect(result).toBeInstanceOf(NextResponse); + + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("At least one update field is required"); + }); +}); diff --git a/lib/accounts/patchAccountHandler.ts b/lib/accounts/patchAccountHandler.ts new file mode 100644 index 00000000..0c15e365 --- /dev/null +++ b/lib/accounts/patchAccountHandler.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validatePatchAccountRequest } from "@/lib/accounts/validateUpdateAccountBody"; +import { updateAccountHandler } from "@/lib/accounts/updateAccountHandler"; +import type { ValidatedUpdateAccountRequest } from "@/lib/accounts/validateUpdateAccountRequest"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; + +/** + * Handles PATCH /api/accounts: optional admin-only accountId override, then profile update. + * Auth and body validation live in {@link validatePatchAccountRequest}. + * + * @param req - Incoming Next.js request + * @returns Updated account JSON or error response + */ +export async function patchAccountHandler(req: NextRequest): Promise { + const validatedRequest = await validatePatchAccountRequest(req); + if (validatedRequest instanceof NextResponse) { + return validatedRequest; + } + + const { auth: authResult, body: validated } = validatedRequest; + + const targetAccountId = validated.accountId ?? authResult.accountId; + if (validated.accountId && validated.accountId !== authResult.accountId) { + const isAdmin = await checkIsAdmin(authResult.accountId); + if (!isAdmin) { + return NextResponse.json( + { status: "error", error: "accountId override is only allowed for admin accounts" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + } + + return updateAccountHandler({ + ...(validated as Omit), + accountId: targetAccountId, + }); +} diff --git a/lib/accounts/validateUpdateAccountBody.ts b/lib/accounts/validateUpdateAccountBody.ts new file mode 100644 index 00000000..8e5b0522 --- /dev/null +++ b/lib/accounts/validateUpdateAccountBody.ts @@ -0,0 +1,95 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext, type AuthContext } 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( + body => { + const updateFields = { ...body }; + delete updateFields.accountId; + return Object.values(updateFields).some(v => v !== undefined); + }, + { + message: "At least one update field is required", + path: ["body"], + }, + ); + +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; +} + +/** Successful auth + parsed body for PATCH /api/accounts (after validatePatchAccountRequest). */ +export type PatchAccountRequestValidated = { + auth: AuthContext; + body: UpdateAccountBody; +}; + +/** + * Authenticates the request, parses JSON, and validates the PATCH /api/accounts body. + * Keeps HTTP auth + input validation out of the account patch handler (SRP). + * + * @param req - Incoming Next.js request + * @returns Error response, or auth context plus validated body fields + */ +export async function validatePatchAccountRequest( + req: NextRequest, +): Promise { + const authResult = await validateAuthContext(req); + if (authResult instanceof NextResponse) { + return authResult; + } + + const rawBody = await safeParseJson(req); + const validated = validateUpdateAccountBody(rawBody); + if (validated instanceof NextResponse) { + return validated; + } + + return { auth: authResult, body: validated }; +}