Skip to content
Merged
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
69 changes: 57 additions & 12 deletions lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
Expand All @@ -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();
Expand All @@ -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");
});
});
31 changes: 22 additions & 9 deletions lib/sandbox/validateSnapshotPatchBody.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
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<typeof snapshotPatchBodySchema> & 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.
*/
export async function validateSnapshotPatchBody(
request: NextRequest,
): Promise<NextResponse | SnapshotPatchBody> {
const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) {
return authResult;
}

const body = await safeParseJson(request);
const result = snapshotPatchBodySchema.safeParse(body);

Expand All @@ -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,
};
}