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
2 changes: 2 additions & 0 deletions app/api/accounts/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
});
}

/**

Check failure on line 17 in app/api/accounts/[id]/route.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "params.params" declaration
* GET /api/accounts/[id]
*
* Retrieves account details by ID including profile info, emails, and wallets.
* Requires authentication via `x-api-key` or `Authorization: Bearer`; the caller must be
* allowed to access the requested account (same account, org delegation, or Recoup admin).
*
* Path parameters:
* - id (required): The unique identifier of the account (UUID)
*
* @param request - The request object

Check failure on line 27 in app/api/accounts/[id]/route.ts

View workflow job for this annotation

GitHub Actions / lint

Missing @param "params.params"
* @param params - Route params containing the account ID
* @returns A NextResponse with account data
*/
Expand Down
97 changes: 97 additions & 0 deletions lib/accounts/__tests__/getAccountHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails";
import { getAccountHandler } from "@/lib/accounts/getAccountHandler";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

vi.mock("@/lib/accounts/validateAccountParams", () => ({
validateAccountParams: vi.fn((id: string) => {
if (!uuidRe.test(id)) {
return NextResponse.json(
{
status: "error",
missing_fields: ["id"],
error: "id must be a valid UUID",
},
{ status: 400, headers: {} },
);
}
return { id };
}),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({
getAccountWithDetails: vi.fn(),
}));

const validUuid = "550e8400-e29b-41d4-a716-446655440000";

describe("getAccountHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 400 when id is not a valid UUID", async () => {
const req = new NextRequest("http://localhost/api/accounts/not-a-uuid");
const res = await getAccountHandler(req, Promise.resolve({ id: "not-a-uuid" }));
expect(res.status).toBe(400);
expect(validateAuthContext).not.toHaveBeenCalled();
expect(getAccountWithDetails).not.toHaveBeenCalled();
});

it("returns auth error when validateAuthContext fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "unauthorized" }, { status: 401 }),
);

const req = new NextRequest("http://localhost/api/accounts/" + validUuid);
const res = await getAccountHandler(req, Promise.resolve({ id: validUuid }));

expect(res.status).toBe(401);
expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: validUuid });
expect(getAccountWithDetails).not.toHaveBeenCalled();
});

it("returns 404 when account is not found after successful auth", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: validUuid,
orgId: null,
authToken: "token",
});
vi.mocked(getAccountWithDetails).mockResolvedValue(null);

const req = new NextRequest("http://localhost/api/accounts/" + validUuid);
const res = await getAccountHandler(req, Promise.resolve({ id: validUuid }));

expect(res.status).toBe(404);
expect(getAccountWithDetails).toHaveBeenCalledWith(validUuid);
});

it("returns 200 with account when authorized and found", async () => {
const account = { id: validUuid, name: "Test" };
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: validUuid,
orgId: null,
authToken: "token",
});
vi.mocked(getAccountWithDetails).mockResolvedValue(account as never);

const req = new NextRequest("http://localhost/api/accounts/" + validUuid);
const res = await getAccountHandler(req, Promise.resolve({ id: validUuid }));
const body = await res.json();

expect(res.status).toBe(200);
expect(body.status).toBe("success");
expect(body.account).toEqual(account);
});
});
11 changes: 11 additions & 0 deletions lib/accounts/getAccountHandler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { validateAccountParams } from "@/lib/accounts/validateAccountParams";
import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails";

/**
* Handler for retrieving account details by ID.
*
* Requires exactly one of `x-api-key` or `Authorization: Bearer`.
* The caller must be allowed to access the account in the path (self, shared org, or Recoup admin).
*
* @param request - The request object
* @param params - Route params containing the account ID
* @returns A NextResponse with account data or error
Expand All @@ -22,6 +26,13 @@ export async function getAccountHandler(
return validatedParams;
}

const authResult = await validateAuthContext(request, {
accountId: validatedParams.id,
});
if (authResult instanceof NextResponse) {
return authResult;
}

const account = await getAccountWithDetails(validatedParams.id);

if (!account) {
Expand Down
Loading