From a60fcbccca47f85b6ebe56ccb0a56b65ff0560e1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sun, 21 Jun 2026 19:19:56 -0500 Subject: [PATCH] feat(artists): account_id override for DELETE /api/artists/{id} Parse an optional account_id from the request body and thread it into validateAuthContext(request, { accountId }), so a caller with access to multiple accounts (org members / Recoup admins) can delete an artist in another account's context. The resolved account is used for the checkAccountArtistAccess check; a non-admin passing an inaccessible account is still rejected by canAccessAccount (403). Mirrors the existing override pattern on POST /api/artists. chat#1811 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../validateDeleteArtistRequest.test.ts | 53 ++++++++++++++++++- lib/artists/validateDeleteArtistRequest.ts | 27 +++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/lib/artists/__tests__/validateDeleteArtistRequest.test.ts b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts index c0585f9f2..b39790dbe 100644 --- a/lib/artists/__tests__/validateDeleteArtistRequest.test.ts +++ b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts @@ -55,7 +55,7 @@ describe("validateDeleteArtistRequest", () => { const result = await validateDeleteArtistRequest(request, validArtistId); expect(result).toBe(authError); - expect(validateAuthContext).toHaveBeenCalledWith(request); + expect(validateAuthContext).toHaveBeenCalledWith(request, { accountId: undefined }); }); it("returns 404 when the artist does not exist", async () => { @@ -125,4 +125,55 @@ describe("validateDeleteArtistRequest", () => { requesterAccountId: authenticatedAccountId, }); }); + + describe("account_id override", () => { + const overrideAccountId = "770e8400-e29b-41d4-a716-446655440000"; + + it("passes the account_id override to validateAuthContext and resolves against it", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: overrideAccountId, + authToken: "test-token", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ account_id: overrideAccountId }), + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: overrideAccountId, + }); + expect(checkAccountArtistAccess).toHaveBeenCalledWith(overrideAccountId, validArtistId); + expect(result).toEqual({ + artistId: validArtistId, + requesterAccountId: overrideAccountId, + }); + }); + + it("returns 400 when account_id is not a valid UUID", async () => { + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ account_id: "not-a-uuid" }), + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/artists/validateDeleteArtistRequest.ts b/lib/artists/validateDeleteArtistRequest.ts index a69832e45..da237bca8 100644 --- a/lib/artists/validateDeleteArtistRequest.ts +++ b/lib/artists/validateDeleteArtistRequest.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; @@ -10,9 +12,17 @@ export interface DeleteArtistRequest { requesterAccountId: string; } +const deleteArtistBodySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + /** * Validates DELETE /api/artists/{id} path params and authentication. * + * Accepts an optional `account_id` in the request body so a caller with access + * to multiple accounts (org members or Recoup admins) can delete an artist in + * another account's context. The override is authorized by `validateAuthContext`. + * * @param request - The incoming request * @param id - The artist account ID from the route * @returns The validated artist ID plus requester context, or a NextResponse error @@ -26,7 +36,22 @@ export async function validateDeleteArtistRequest( return validatedParams; } - const authResult = await validateAuthContext(request); + const body = await safeParseJson(request); + const bodyResult = deleteArtistBodySchema.safeParse(body); + if (!bodyResult.success) { + const firstError = bodyResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request, { + accountId: bodyResult.data.account_id, + }); if (authResult instanceof NextResponse) { return authResult; }