diff --git a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts new file mode 100644 index 00000000..2ccd8c3d --- /dev/null +++ b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; + +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +describe("buildGetSandboxesParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("personal API key (org_id = null)", () => { + it("returns accountIds with key owner when no target_account_id", async () => { + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + }); + + expect(result).toEqual({ + params: { accountIds: ["account-123"], sandboxId: undefined }, + error: null, + }); + }); + + it("returns error when personal key tries to filter by account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + target_account_id: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + + it("includes sandbox_id filter for personal key", async () => { + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["account-123"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); + }); + + describe("organization API key", () => { + it("fetches org member accountIds when no target_account_id", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + { account_id: "member-2", organization_id: "org-123", organization: null }, + { account_id: "member-3", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetSandboxesParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(getAccountOrganizations).toHaveBeenCalledWith({ organizationId: "org-123" }); + expect(result).toEqual({ + params: { accountIds: ["member-1", "member-2", "member-3"], sandboxId: undefined }, + error: null, + }); + }); + + it("allows filtering by account_id if member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "member-account", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-account"], sandboxId: undefined }, + error: null, + }); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "member-account", + }); + }); + + it("returns error when account_id is not member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "non-member-account", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); + }); + + it("includes sandbox_id filter for org key", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-1"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); + + it("returns empty accountIds when org has no members", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + const result = await buildGetSandboxesParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(result).toEqual({ + params: { accountIds: [], sandboxId: undefined }, + error: null, + }); + }); + + it("includes both target_account_id and sandbox_id when access granted", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "member-account", + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-account"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); + }); + + describe("Recoup admin key", () => { + const recoupOrgId = "recoup-org-id"; + + it("returns empty params (no filter) to get all records", async () => { + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + }); + + expect(result).toEqual({ + params: { sandboxId: undefined }, + error: null, + }); + // Should NOT call getAccountOrganizations for admin + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("allows filtering by any account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + target_account_id: "any-account", + }); + + expect(result).toEqual({ + params: { accountIds: ["any-account"], sandboxId: undefined }, + error: null, + }); + }); + + it("includes sandbox_id filter for admin key", async () => { + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { sandboxId: "sbx_abc123" }, + error: null, + }); + }); + }); +}); diff --git a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts index 98660d7f..3069ab19 100644 --- a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts +++ b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts @@ -4,11 +4,20 @@ import { NextResponse } from "next/server"; import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("../buildGetSandboxesParams", () => ({ + buildGetSandboxesParams: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + /** * Creates a mock NextRequest for testing. * @@ -31,80 +40,194 @@ describe("validateGetSandboxesRequest", () => { vi.clearAllMocks(); }); - it("returns error when auth fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); + describe("authentication", () => { + it("returns error when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(401); - }); - - it("returns params with accountIds for personal key", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); - - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - accountIds: ["acc_123"], - sandboxId: undefined, + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); }); }); - it("returns params with orgId for org key", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: "org_456", - authToken: "token", + describe("query parameter validation", () => { + it("accepts valid sandbox_id parameter", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["acc_123"], sandboxId: "sbx_specific" }, + error: null, + }); + + const request = createMockRequest({ sandbox_id: "sbx_specific" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: "sbx_specific", + }); }); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - orgId: "org_456", - sandboxId: undefined, + it("rejects invalid account_id format (not UUID)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest({ account_id: "invalid-not-uuid" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.status).toBe("error"); }); }); - it("includes sandbox_id in params when provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", + describe("authorization via buildGetSandboxesParams", () => { + it("returns 403 when buildGetSandboxesParams returns personal key error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "acc_123", + org_id: null, + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: undefined, + }); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toBe("Personal API keys cannot filter by account_id"); }); - const request = createMockRequest({ sandbox_id: "sbx_specific" }); - const result = await validateGetSandboxesRequest(request); + it("returns 403 when buildGetSandboxesParams returns non-member error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "account_id is not a member of this organization", + }); + + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: undefined, + }); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toBe("account_id is not a member of this organization"); + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - accountIds: ["acc_123"], - sandboxId: "sbx_specific", + it("passes target_account_id and sandbox_id to buildGetSandboxesParams", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: "sbx_abc123" }, + error: null, + }); + + const request = createMockRequest({ + sandbox_id: "sbx_abc123", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: "sbx_abc123", + }); + expect(result).toEqual({ + accountIds: ["550e8400-e29b-41d4-a716-446655440000"], + sandboxId: "sbx_abc123", + }); }); }); - it("handles empty sandbox_id query param", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", + describe("default behavior", () => { + it("returns params with accountIds for personal key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["acc_123"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "acc_123", + org_id: null, + target_account_id: undefined, + sandbox_id: undefined, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: undefined, + }); }); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { sandboxId?: string }).sandboxId).toBeUndefined(); + it("returns params with org member accountIds for org key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org_456", + orgId: "org_456", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["member-1", "member-2"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["member-1", "member-2"], + sandboxId: undefined, + }); + }); }); }); diff --git a/lib/sandbox/buildGetSandboxesParams.ts b/lib/sandbox/buildGetSandboxesParams.ts new file mode 100644 index 00000000..a446597c --- /dev/null +++ b/lib/sandbox/buildGetSandboxesParams.ts @@ -0,0 +1,67 @@ +import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { RECOUP_ORG_ID } from "@/lib/const"; + +export interface BuildGetSandboxesParamsInput { + /** The authenticated account ID */ + account_id: string; + /** The organization ID from the API key (null for personal keys) */ + org_id: string | null; + /** Optional target account ID to filter by */ + target_account_id?: string; + /** Optional sandbox ID to filter by */ + sandbox_id?: string; +} + +export type BuildGetSandboxesParamsResult = + | { params: SelectAccountSandboxesParams; error: null } + | { params: null; error: string }; + +/** + * Builds the parameters for selectAccountSandboxes based on auth context. + * + * For personal keys: Returns accountIds with the key owner's account + * For org keys: Fetches all org member accountIds and returns them + * For Recoup admin key: Returns empty params to indicate ALL records + * + * If target_account_id is provided, validates access and returns that account. + * + * @param input - The auth context and optional filters + * @returns The params for selectAccountSandboxes or an error + */ +export async function buildGetSandboxesParams( + input: BuildGetSandboxesParamsInput, +): Promise { + const { account_id, org_id, target_account_id, sandbox_id } = input; + + // Handle account_id filter if provided + if (target_account_id) { + const hasAccess = await canAccessAccount({ orgId: org_id, targetAccountId: target_account_id }); + if (!hasAccess) { + return { + params: null, + error: org_id + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } + return { params: { accountIds: [target_account_id], sandboxId: sandbox_id }, error: null }; + } + + // No account_id filter - determine what to return based on key type + if (org_id === RECOUP_ORG_ID) { + // Recoup admin: return undefined accountIds to indicate ALL records + return { params: { sandboxId: sandbox_id }, error: null }; + } + + if (org_id) { + // Org key: fetch all member account IDs for this organization + const orgMembers = await getAccountOrganizations({ organizationId: org_id }); + const memberAccountIds = orgMembers.map(member => member.account_id); + return { params: { accountIds: memberAccountIds, sandboxId: sandbox_id }, error: null }; + } + + // Personal key: Only return the key owner's account + return { params: { accountIds: [account_id], sandboxId: sandbox_id }, error: null }; +} diff --git a/lib/sandbox/validateGetSandboxesRequest.ts b/lib/sandbox/validateGetSandboxesRequest.ts index aa2d73bb..7252e33d 100644 --- a/lib/sandbox/validateGetSandboxesRequest.ts +++ b/lib/sandbox/validateGetSandboxesRequest.ts @@ -3,10 +3,12 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { buildGetSandboxesParams } from "./buildGetSandboxesParams"; import { z } from "zod"; const getSandboxesQuerySchema = z.object({ sandbox_id: z.string().optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); /** @@ -15,6 +17,7 @@ const getSandboxesQuerySchema = z.object({ * * Query parameters: * - sandbox_id: Filter to a specific sandbox (must belong to account/org) + * - account_id: Filter to a specific account (validated against org membership) * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or SelectAccountSandboxesParams @@ -26,6 +29,7 @@ export async function validateGetSandboxesRequest( const { searchParams } = new URL(request.url); const queryParams = { sandbox_id: searchParams.get("sandbox_id") ?? undefined, + account_id: searchParams.get("account_id") ?? undefined, }; const queryResult = getSandboxesQuerySchema.safeParse(queryParams); @@ -40,7 +44,7 @@ export async function validateGetSandboxesRequest( ); } - const { sandbox_id: sandboxId } = queryResult.data; + const { sandbox_id: sandboxId, account_id: targetAccountId } = queryResult.data; // Use validateAuthContext for authentication const authResult = await validateAuthContext(request); @@ -50,17 +54,22 @@ export async function validateGetSandboxesRequest( const { accountId, orgId } = authResult; - // Build params based on auth context - const params: SelectAccountSandboxesParams = { - sandboxId, - }; + // Use shared function to build params + const { params, error } = await buildGetSandboxesParams({ + account_id: accountId, + org_id: orgId, + target_account_id: targetAccountId, + sandbox_id: sandboxId, + }); - if (orgId) { - // Org key - filter by org membership - params.orgId = orgId; - } else { - // Personal key - filter by account - params.accountIds = [accountId]; + if (error) { + return NextResponse.json( + { + status: "error", + error, + }, + { status: 403, headers: getCorsHeaders() }, + ); } return params;