diff --git a/lib/tasks/__tests__/createTaskHandler.test.ts b/lib/tasks/__tests__/createTaskHandler.test.ts new file mode 100644 index 00000000..ff030f16 --- /dev/null +++ b/lib/tasks/__tests__/createTaskHandler.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { createTaskHandler } from "@/lib/tasks/createTaskHandler"; +import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody"; +import { createTask } from "@/lib/tasks/createTask"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/tasks/validateCreateTaskBody", () => ({ + validateCreateTaskRequest: vi.fn(), +})); + +vi.mock("@/lib/tasks/createTask", () => ({ + createTask: vi.fn(), +})); + +const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000"; +const ARTIST_ID = "323e4567-e89b-12d3-a456-426614174000"; + +function validValidatedBody() { + return { + title: "Daily report", + prompt: "Summarize fans", + schedule: "0 9 * * *", + account_id: ACCOUNT_A, + artist_account_id: ARTIST_ID, + }; +} + +describe("createTaskHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(console.error).mockRestore(); + }); + + it("returns validation/auth response from validateCreateTaskRequest unchanged", async () => { + const err = NextResponse.json({ status: "error", error: "nope" }, { status: 403 }); + vi.mocked(validateCreateTaskRequest).mockResolvedValue(err); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({}), + }); + + const res = await createTaskHandler(request); + + expect(res).toBe(err); + expect(vi.mocked(createTask)).not.toHaveBeenCalled(); + }); + + it("returns 200 and created task when validateCreateTaskRequest succeeds", async () => { + const validated = validValidatedBody(); + vi.mocked(validateCreateTaskRequest).mockResolvedValue(validated); + const created = { + id: "sched-1", + ...validated, + } as Awaited>; + vi.mocked(createTask).mockResolvedValue(created); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({}), + }); + + const res = await createTaskHandler(request); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "success", + tasks: [created], + }); + expect(createTask).toHaveBeenCalledWith(validated); + }); + + it("returns 500 when createTask throws", async () => { + vi.mocked(validateCreateTaskRequest).mockResolvedValue(validValidatedBody()); + vi.mocked(createTask).mockRejectedValue(new Error("Trigger failure")); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: "{}", + }); + + const res = await createTaskHandler(request); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toMatchObject({ + status: "error", + error: "Trigger failure", + }); + }); + + it("returns 500 when createTask throws non-Error", async () => { + vi.mocked(validateCreateTaskRequest).mockResolvedValue(validValidatedBody()); + vi.mocked(createTask).mockRejectedValue("boom"); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: "{}", + }); + + const res = await createTaskHandler(request); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toMatchObject({ + status: "error", + error: "Internal server error", + }); + }); +}); diff --git a/lib/tasks/__tests__/validateCreateTaskRequest.test.ts b/lib/tasks/__tests__/validateCreateTaskRequest.test.ts new file mode 100644 index 00000000..21abb1a9 --- /dev/null +++ b/lib/tasks/__tests__/validateCreateTaskRequest.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody"; +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(), +})); + +const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000"; +const ACCOUNT_B = "223e4567-e89b-12d3-a456-426614174000"; +const ARTIST_ID = "323e4567-e89b-12d3-a456-426614174000"; + +function validCreateBody(overrides: Record = {}) { + return { + title: "Daily report", + prompt: "Summarize fans", + schedule: "0 9 * * *", + account_id: ACCOUNT_A, + artist_account_id: ARTIST_ID, + ...overrides, + }; +} + +describe("validateCreateTaskRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when JSON body is invalid", async () => { + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: "not-json{", + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + await expect((res as NextResponse).json()).resolves.toMatchObject({ + status: "error", + error: "Invalid JSON body", + }); + expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled(); + }); + + it("returns 400 when body fails Zod validation (empty title)", async () => { + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ title: "" }), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled(); + }); + + it("returns 400 when required fields are missing", async () => { + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ title: "x" }), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled(); + }); + + it("calls validateAuthContext with body account_id after Zod passes", async () => { + const body = validCreateBody(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_A, + orgId: null, + authToken: "token", + }); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(body), + }); + + await validateCreateTaskRequest(request); + + expect(validateAuthContext).toHaveBeenCalledTimes(1); + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: ACCOUNT_A, + }); + }); + + it("returns 401 when validateAuthContext returns 401", async () => { + const authError = NextResponse.json( + { + status: "error", + error: "Exactly one of x-api-key or Authorization must be provided", + }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(validCreateBody()), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBe(authError); + expect((res as NextResponse).status).toBe(401); + }); + + it("returns 403 when validateAuthContext returns 403", async () => { + const forbidden = NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(forbidden); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test.jwt", + }, + body: JSON.stringify(validCreateBody({ account_id: ACCOUNT_B })), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBe(forbidden); + expect((res as NextResponse).status).toBe(403); + }); + + it("returns CreateTaskBody with resolved auth account_id on success", async () => { + const body = validCreateBody({ account_id: ACCOUNT_A }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_A, + orgId: null, + authToken: "key", + }); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(body), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).not.toBeInstanceOf(NextResponse); + expect(res).toEqual({ + ...body, + account_id: ACCOUNT_A, + }); + }); + + it("returns CreateTaskBody with org-resolved account_id", async () => { + const body = validCreateBody({ account_id: ACCOUNT_B }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_B, + orgId: "org-1", + authToken: "key", + }); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(body), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toEqual({ + ...body, + account_id: ACCOUNT_B, + }); + }); + + it("does not call auth when account_id fails validation (empty string)", async () => { + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify( + validCreateBody({ + account_id: "", + }), + ), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled(); + }); + + it("preserves optional model in returned body", async () => { + const body = validCreateBody({ model: "anthropic/claude-sonnet-4.5" }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_A, + orgId: null, + authToken: "key", + }); + + const request = new NextRequest("http://localhost/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify(body), + }); + + const res = await validateCreateTaskRequest(request); + + expect(res).toMatchObject({ + model: "anthropic/claude-sonnet-4.5", + account_id: ACCOUNT_A, + }); + }); +}); diff --git a/lib/tasks/createTaskHandler.ts b/lib/tasks/createTaskHandler.ts index 2fb25f14..6b0979d9 100644 --- a/lib/tasks/createTaskHandler.ts +++ b/lib/tasks/createTaskHandler.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateCreateTaskBody } from "@/lib/tasks/validateCreateTaskBody"; +import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody"; import { createTask } from "@/lib/tasks/createTask"; /** @@ -20,9 +20,7 @@ import { createTask } from "@/lib/tasks/createTask"; */ export async function createTaskHandler(request: NextRequest): Promise { try { - const body = await request.json(); - - const validatedBody = validateCreateTaskBody(body); + const validatedBody = await validateCreateTaskRequest(request); if (validatedBody instanceof NextResponse) { return validatedBody; } diff --git a/lib/tasks/validateCreateTaskBody.ts b/lib/tasks/validateCreateTaskBody.ts index 8d88a1e4..89a7af6c 100644 --- a/lib/tasks/validateCreateTaskBody.ts +++ b/lib/tasks/validateCreateTaskBody.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { z } from "zod"; export const createTaskBodySchema = z.object({ @@ -37,14 +38,25 @@ export const createTaskBodySchema = z.object({ export type CreateTaskBody = z.infer; /** - * Validates create task request body. + * Validates POST /api/tasks: JSON body, Zod schema, and auth + account_id override (same rules as other endpoints). * - * @param body - The request body to validate. - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + * @param request - The incoming Next.js request + * @returns Error response or {@link CreateTaskBody} with account_id set from resolved auth context */ -export function validateCreateTaskBody(body: unknown): NextResponse | CreateTaskBody { - const validationResult = createTaskBodySchema.safeParse(body); +export async function validateCreateTaskRequest( + request: NextRequest, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + const validationResult = createTaskBodySchema.safeParse(body); if (!validationResult.success) { const firstError = validationResult.error.issues[0]; return NextResponse.json( @@ -60,5 +72,17 @@ export function validateCreateTaskBody(body: unknown): NextResponse | CreateTask ); } - return validationResult.data; + const validatedBody = validationResult.data; + + const auth = await validateAuthContext(request, { + accountId: validatedBody.account_id, + }); + if (auth instanceof NextResponse) { + return auth; + } + + return { + ...validatedBody, + account_id: auth.accountId, + }; }