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
125 changes: 125 additions & 0 deletions lib/artists/__tests__/buildGetArtistsParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildGetArtistsParams } from "../buildGetArtistsParams";

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

import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

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

it("returns auth accountId for personal key", async () => {
const result = await buildGetArtistsParams({
accountId: "personal-account-123",
orgId: null,
});

expect(result).toEqual({
params: { accountId: "personal-account-123", orgId: null },
error: null,
});
});

it("returns auth accountId for org key", async () => {
const result = await buildGetArtistsParams({
accountId: "org-owner-123",
orgId: "org-123",
});

expect(result).toEqual({
params: { accountId: "org-owner-123", orgId: null },
error: null,
});
});

it("defaults orgId to null when orgIdFilter is omitted", async () => {
const result = await buildGetArtistsParams({
accountId: "account-123",
orgId: null,
});

expect(result.params?.orgId).toBeNull();
});

it("passes orgIdFilter through when provided", async () => {
const result = await buildGetArtistsParams({
accountId: "account-123",
orgId: null,
orgIdFilter: "filter-org-456",
});

expect(result).toEqual({
params: { accountId: "account-123", orgId: "filter-org-456" },
error: null,
});
});

it("returns targetAccountId when access is granted", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await buildGetArtistsParams({
accountId: "org-owner-123",
orgId: "org-123",
targetAccountId: "target-456",
});

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: "org-123",
targetAccountId: "target-456",
});
expect(result).toEqual({
params: { accountId: "target-456", orgId: null },
error: null,
});
});

it("returns targetAccountId with orgIdFilter when both provided", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await buildGetArtistsParams({
accountId: "org-owner-123",
orgId: "org-123",
targetAccountId: "target-456",
orgIdFilter: "filter-org-789",
});

expect(result).toEqual({
params: { accountId: "target-456", orgId: "filter-org-789" },
error: null,
});
});

it("returns error when personal key tries to filter by targetAccountId", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await buildGetArtistsParams({
accountId: "personal-123",
orgId: null,
targetAccountId: "other-account",
});

expect(result).toEqual({
params: null,
error: "Personal API keys cannot filter by account_id",
});
});

it("returns error when org key lacks access to targetAccountId", async () => {
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await buildGetArtistsParams({
accountId: "org-owner-123",
orgId: "org-123",
targetAccountId: "not-in-org",
});

expect(result).toEqual({
params: null,
error: "account_id is not a member of this organization",
});
});
});
212 changes: 212 additions & 0 deletions lib/artists/__tests__/validateGetArtistsRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateGetArtistsRequest } from "../validateGetArtistsRequest";

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

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

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => new Headers()),
}));

import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

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

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

const request = new NextRequest("http://localhost/api/artists");
const result = await validateGetArtistsRequest(request);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(401);
});

it("returns personal artists for personal key (no query params)", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "personal-123",
orgId: null,
authToken: "test-token",
});

const request = new NextRequest("http://localhost/api/artists");
const result = await validateGetArtistsRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({
accountId: "personal-123",
orgId: null,
});
});

it("returns personal artists for org key (no query params)", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "org-owner-123",
orgId: "org-123",
authToken: "test-token",
});

const request = new NextRequest("http://localhost/api/artists");
const result = await validateGetArtistsRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({
accountId: "org-owner-123",
orgId: null,
});
});

it("passes org_id filter through", async () => {
const orgFilterId = "a1111111-1111-4111-8111-111111111111";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
orgId: null,
authToken: "test-token",
});

const request = new NextRequest(
`http://localhost/api/artists?org_id=${orgFilterId}`,
);
const result = await validateGetArtistsRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({
accountId: "account-123",
orgId: orgFilterId,
});
});

it("returns 400 for invalid account_id format", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
orgId: null,
authToken: "test-token",
});

const request = new NextRequest(
"http://localhost/api/artists?account_id=not-a-uuid",
);
const result = await validateGetArtistsRequest(request);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("returns 400 for invalid org_id format", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "account-123",
orgId: null,
authToken: "test-token",
});

const request = new NextRequest(
"http://localhost/api/artists?org_id=not-a-uuid",
);
const result = await validateGetArtistsRequest(request);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("allows org key to filter by account_id within org", async () => {
const mockOrgId = "b2222222-2222-4222-8222-222222222222";
const targetAccountId = "c3333333-3333-4333-8333-333333333333";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "org-owner",
orgId: mockOrgId,
authToken: "test-token",
});
vi.mocked(canAccessAccount).mockResolvedValue(true);

const request = new NextRequest(
`http://localhost/api/artists?account_id=${targetAccountId}`,
);
const result = await validateGetArtistsRequest(request);

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: mockOrgId,
targetAccountId,
});
expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({
accountId: targetAccountId,
orgId: null,
});
});

it("returns 403 when personal key tries to filter by account_id", async () => {
const otherAccountId = "d4444444-4444-4444-8444-444444444444";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "personal-123",
orgId: null,
authToken: "test-token",
});
vi.mocked(canAccessAccount).mockResolvedValue(false);

const request = new NextRequest(
`http://localhost/api/artists?account_id=${otherAccountId}`,
);
const result = await validateGetArtistsRequest(request);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(403);
});

it("returns 403 when org key lacks access to account_id", async () => {
const mockOrgId = "e5555555-5555-4555-8555-555555555555";
const notInOrgId = "f6666666-6666-4666-8666-666666666666";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "org-owner",
orgId: mockOrgId,
authToken: "test-token",
});
vi.mocked(canAccessAccount).mockResolvedValue(false);

const request = new NextRequest(
`http://localhost/api/artists?account_id=${notInOrgId}`,
);
const result = await validateGetArtistsRequest(request);

expect(canAccessAccount).toHaveBeenCalledWith({
orgId: mockOrgId,
targetAccountId: notInOrgId,
});
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(403);
});

it("combines account_id and org_id filters", async () => {
const mockOrgId = "a1111111-1111-4111-8111-111111111111";
const targetAccountId = "b2222222-2222-4222-8222-222222222222";
const orgFilterId = "c3333333-3333-4333-8333-333333333333";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "org-owner",
orgId: mockOrgId,
authToken: "test-token",
});
vi.mocked(canAccessAccount).mockResolvedValue(true);

const request = new NextRequest(
`http://localhost/api/artists?account_id=${targetAccountId}&org_id=${orgFilterId}`,
);
const result = await validateGetArtistsRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({
accountId: targetAccountId,
orgId: orgFilterId,
});
});
});
60 changes: 60 additions & 0 deletions lib/artists/buildGetArtistsParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";
import type { GetArtistsOptions } from "@/lib/artists/getArtists";

export interface BuildGetArtistsParamsInput {
/** The authenticated account ID */
accountId: string;
/** The organization ID from the API key (null for personal keys) */
orgId: string | null;
/** Optional target account ID to filter by */
targetAccountId?: string;
/** Optional organization filter for which artists to show */
orgIdFilter?: string;
}

export type BuildGetArtistsParamsResult =
| { params: GetArtistsOptions; error: null }
| { params: null; error: string };

/**
* Builds the parameters for getArtists based on auth context.
*
* For personal keys: Returns the key owner's accountId
* For org keys: Returns the key owner's accountId (can override with targetAccountId)
* For Recoup admin key: Returns the key owner's accountId (can override with targetAccountId)
*
* If targetAccountId is provided, validates access and returns that account.
*
* @param input - The auth context and optional filters
* @returns The params for getArtists or an error
*/
export async function buildGetArtistsParams(
input: BuildGetArtistsParamsInput,
): Promise<BuildGetArtistsParamsResult> {
const { accountId, orgId, targetAccountId, orgIdFilter } = input;

let effectiveAccountId = accountId;

// Handle account_id filter if provided
if (targetAccountId) {
const hasAccess = await canAccessAccount({ orgId, targetAccountId });
if (!hasAccess) {
return {
params: null,
error: orgId
? "account_id is not a member of this organization"
: "Personal API keys cannot filter by account_id",
};
}
effectiveAccountId = targetAccountId;
}

// When org_id is omitted, default to personal artists (null = personal only)
// When org_id is provided, filter to that organization's artists
const effectiveOrgId = orgIdFilter ?? null;

return {
params: { accountId: effectiveAccountId, orgId: effectiveOrgId },
error: null,
};
}
Loading