diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts index 95ea0631..af6634d8 100644 --- a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -30,6 +30,7 @@ describe("validateSnapshotPatchBody", () => { }); it("returns 401 when auth fails", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123" }); vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ error: "Unauthorized" }, { status: 401 }), ); @@ -41,31 +42,24 @@ describe("validateSnapshotPatchBody", () => { expect((result as NextResponse).status).toBe(401); }); - it("returns validated body with auth context when snapshotId is provided", async () => { + it("returns validated body when snapshotId is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123" }); vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123" }); const request = createMockRequest(); const result = await validateSnapshotPatchBody(request); expect(result).toEqual({ accountId: "acc_123", - orgId: "org_456", - authToken: "token", snapshotId: "snap_abc123", }); }); it("returns error response when snapshotId is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); vi.mocked(safeParseJson).mockResolvedValue({}); const request = createMockRequest(); @@ -78,17 +72,68 @@ describe("validateSnapshotPatchBody", () => { }); it("returns error response when snapshotId is empty string", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "" }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("passes account_id to validateAuthContext when provided", async () => { + const targetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: targetAccountId, + }); vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, + accountId: targetAccountId, + orgId: "org_789", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "" }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: targetAccountId, + }); + expect(result).toEqual({ + accountId: targetAccountId, + snapshotId: "snap_abc123", + }); + }); + + it("returns 403 when account_id override is denied", async () => { + const unauthorizedAccountId = "660e8400-e29b-41d4-a716-446655440001"; + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: unauthorizedAccountId, + }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Access denied to specified account_id" }, { status: 403 }), + ); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns error when account_id is not a valid UUID", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: "not-a-uuid", + }); const request = createMockRequest(); const result = await validateSnapshotPatchBody(request); expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(400); + const json = await (result as NextResponse).json(); + expect(json.error).toContain("account_id"); }); }); diff --git a/lib/sandbox/validateSnapshotPatchBody.ts b/lib/sandbox/validateSnapshotPatchBody.ts index 9783d7db..08f33c17 100644 --- a/lib/sandbox/validateSnapshotPatchBody.ts +++ b/lib/sandbox/validateSnapshotPatchBody.ts @@ -1,18 +1,26 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext, type AuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; export const snapshotPatchBodySchema = z.object({ snapshotId: z.string({ message: "snapshotId is required" }).min(1, "snapshotId cannot be empty"), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); -export type SnapshotPatchBody = z.infer & AuthContext; +export type SnapshotPatchBody = { + /** The account ID to update */ + accountId: string; + /** The snapshot ID to set */ + snapshotId: string; +}; /** * Validates auth and request body for PATCH /api/sandboxes/snapshot. + * Handles authentication via x-api-key or Authorization bearer token, + * body validation, and optional account_id override for organization API keys. * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated body with auth context. @@ -20,11 +28,6 @@ export type SnapshotPatchBody = z.infer & AuthCo export async function validateSnapshotPatchBody( request: NextRequest, ): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - const body = await safeParseJson(request); const result = snapshotPatchBodySchema.safeParse(body); @@ -43,8 +46,18 @@ export async function validateSnapshotPatchBody( ); } + const { snapshotId, account_id: targetAccountId } = result.data; + + const authResult = await validateAuthContext(request, { + accountId: targetAccountId, + }); + + if (authResult instanceof NextResponse) { + return authResult; + } + return { - ...authResult, - ...result.data, + accountId: authResult.accountId, + snapshotId, }; }