From 0b437fe66d7828904133b76c92e5ec146b6273bb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 3 Jul 2026 11:03:10 -0500 Subject: [PATCH 1/3] feat(organizations): organization domain management endpoints GET, POST, and DELETE /api/organizations/domains per the contract in recoupable/docs#261. Rows in organization_domains drive email-domain auto-join (assignAccountToOrg / selectOrgByDomain). - GET lists an org's mapped domains (member or Recoup admin only) - POST maps a domain to an org: normalizes (lowercase, trim, strip leading @), idempotent for the same org, 409 when the domain is already mapped to a different org (selectOrgByDomain uses .single(), duplicates would break auto-join) - DELETE unmaps a domain, idempotent - New supabase functions under lib/supabase/organization_domains/ - canManageOrganization helper: org member or Recoup admin Part of recoupable/chat#1841 Co-Authored-By: Claude Fable 5 --- app/api/organizations/domains/route.ts | 68 +++++++ .../__tests__/addOrgDomainHandler.test.ts | 172 ++++++++++++++++++ .../__tests__/canManageOrganization.test.ts | 46 +++++ .../__tests__/getOrgDomainsHandler.test.ts | 118 ++++++++++++ .../__tests__/normalizeOrgDomain.test.ts | 57 ++++++ .../__tests__/removeOrgDomainHandler.test.ts | 98 ++++++++++ .../validateAddOrgDomainBody.test.ts | 76 ++++++++ .../validateGetOrgDomainsQuery.test.ts | 40 ++++ .../validateRemoveOrgDomainQuery.test.ts | 55 ++++++ lib/organizations/addOrgDomainHandler.ts | 87 +++++++++ lib/organizations/canManageOrganization.ts | 28 +++ lib/organizations/getOrgDomainsHandler.ts | 65 +++++++ lib/organizations/normalizeOrgDomain.ts | 15 ++ lib/organizations/removeOrgDomainHandler.ts | 66 +++++++ lib/organizations/validateAddOrgDomainBody.ts | 48 +++++ .../validateGetOrgDomainsQuery.ts | 43 +++++ .../validateRemoveOrgDomainQuery.ts | 55 ++++++ .../deleteOrganizationDomain.test.ts | 51 ++++++ .../insertOrganizationDomain.test.ts | 59 ++++++ .../selectOrganizationDomain.test.ts | 64 +++++++ .../selectOrganizationDomains.test.ts | 66 +++++++ .../deleteOrganizationDomain.ts | 31 ++++ .../insertOrganizationDomain.ts | 31 ++++ .../selectOrganizationDomain.ts | 26 +++ .../selectOrganizationDomains.ts | 25 +++ 25 files changed, 1490 insertions(+) create mode 100644 app/api/organizations/domains/route.ts create mode 100644 lib/organizations/__tests__/addOrgDomainHandler.test.ts create mode 100644 lib/organizations/__tests__/canManageOrganization.test.ts create mode 100644 lib/organizations/__tests__/getOrgDomainsHandler.test.ts create mode 100644 lib/organizations/__tests__/normalizeOrgDomain.test.ts create mode 100644 lib/organizations/__tests__/removeOrgDomainHandler.test.ts create mode 100644 lib/organizations/__tests__/validateAddOrgDomainBody.test.ts create mode 100644 lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts create mode 100644 lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts create mode 100644 lib/organizations/addOrgDomainHandler.ts create mode 100644 lib/organizations/canManageOrganization.ts create mode 100644 lib/organizations/getOrgDomainsHandler.ts create mode 100644 lib/organizations/normalizeOrgDomain.ts create mode 100644 lib/organizations/removeOrgDomainHandler.ts create mode 100644 lib/organizations/validateAddOrgDomainBody.ts create mode 100644 lib/organizations/validateGetOrgDomainsQuery.ts create mode 100644 lib/organizations/validateRemoveOrgDomainQuery.ts create mode 100644 lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts create mode 100644 lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts create mode 100644 lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts create mode 100644 lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts create mode 100644 lib/supabase/organization_domains/deleteOrganizationDomain.ts create mode 100644 lib/supabase/organization_domains/insertOrganizationDomain.ts create mode 100644 lib/supabase/organization_domains/selectOrganizationDomain.ts create mode 100644 lib/supabase/organization_domains/selectOrganizationDomains.ts diff --git a/app/api/organizations/domains/route.ts b/app/api/organizations/domains/route.ts new file mode 100644 index 000000000..b030aa1b7 --- /dev/null +++ b/app/api/organizations/domains/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getOrgDomainsHandler } from "@/lib/organizations/getOrgDomainsHandler"; +import { addOrgDomainHandler } from "@/lib/organizations/addOrgDomainHandler"; +import { removeOrgDomainHandler } from "@/lib/organizations/removeOrgDomainHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/organizations/domains + * + * Lists the email domains mapped to an organization. Accounts that sign up + * with an email at a mapped domain automatically join the organization. + * The caller must be a member of the organization or a Recoup admin. + * + * Query parameters: + * - organization_id (required): The organization's account ID (UUID) + * + * @param request - The request object + * @returns A NextResponse with the organization's domain mappings + */ +export async function GET(request: NextRequest) { + return getOrgDomainsHandler(request); +} + +/** + * POST /api/organizations/domains + * + * Maps an email domain to an organization for automatic membership. + * Idempotent for the same organization; returns 409 when the domain is + * already mapped to a different organization. + * + * Body parameters: + * - organizationId (required): The organization's account ID (UUID) + * - domain (required): The email domain to map (e.g. "seekermusic.com") + * + * @param request - The request object containing the body + * @returns A NextResponse with the domain mapping + */ +export async function POST(request: NextRequest) { + return addOrgDomainHandler(request); +} + +/** + * DELETE /api/organizations/domains + * + * Removes an email domain mapping from an organization. Idempotent. + * + * Query parameters: + * - organization_id (required): The organization's account ID (UUID) + * - domain (required): The email domain to unmap (e.g. "seekermusic.com") + * + * @param request - The request object + * @returns A NextResponse indicating success + */ +export async function DELETE(request: NextRequest) { + return removeOrgDomainHandler(request); +} diff --git a/lib/organizations/__tests__/addOrgDomainHandler.test.ts b/lib/organizations/__tests__/addOrgDomainHandler.test.ts new file mode 100644 index 000000000..6f14a43f7 --- /dev/null +++ b/lib/organizations/__tests__/addOrgDomainHandler.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { addOrgDomainHandler } from "../addOrgDomainHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "../canManageOrganization"; +import { selectOrganizationDomain } from "@/lib/supabase/organization_domains/selectOrganizationDomain"; +import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/insertOrganizationDomain"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +vi.mock("@/lib/supabase/organization_domains/selectOrganizationDomain", () => ({ + selectOrganizationDomain: vi.fn(), +})); + +vi.mock("@/lib/supabase/organization_domains/insertOrganizationDomain", () => ({ + insertOrganizationDomain: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; +const OTHER_ORG_ID = "1b2c3d4e-5f60-4a71-8b92-a3b4c5d6e7f8"; + +function makeRequest(body: unknown) { + return new NextRequest("http://x/api/organizations/domains", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("addOrgDomainHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + vi.mocked(selectOrganizationDomain).mockResolvedValue(null); + }); + + describe("successful cases", () => { + it("inserts a new mapping with a normalized domain", async () => { + vi.mocked(insertOrganizationDomain).mockResolvedValue({ + id: "dom-1", + domain: "seekermusic.com", + organization_id: ORG_ID, + created_at: "2026-01-01", + }); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: " @SeekerMusic.COM " }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "success", + id: "dom-1", + domain: "seekermusic.com", + organization_id: ORG_ID, + }); + expect(selectOrganizationDomain).toHaveBeenCalledWith("seekermusic.com"); + expect(insertOrganizationDomain).toHaveBeenCalledWith({ + domain: "seekermusic.com", + organizationId: ORG_ID, + }); + }); + + it("is idempotent when the domain is already mapped to the same org", async () => { + vi.mocked(selectOrganizationDomain).mockResolvedValue({ + id: "dom-1", + domain: "seekermusic.com", + organization_id: ORG_ID, + created_at: "2026-01-01", + }); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "success", + id: "dom-1", + domain: "seekermusic.com", + organization_id: ORG_ID, + }); + expect(insertOrganizationDomain).not.toHaveBeenCalled(); + }); + }); + + describe("error cases", () => { + it("returns the auth error response when unauthenticated", async () => { + const authError = NextResponse.json( + { status: "error", message: "unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + + expect(response.status).toBe(401); + expect(insertOrganizationDomain).not.toHaveBeenCalled(); + }); + + it("returns 400 for an invalid body", async () => { + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "not a domain" }), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.status).toBe("error"); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + expect(insertOrganizationDomain).not.toHaveBeenCalled(); + }); + + it("returns 409 when the domain is mapped to a different org", async () => { + vi.mocked(selectOrganizationDomain).mockResolvedValue({ + id: "dom-1", + domain: "seekermusic.com", + organization_id: OTHER_ORG_ID, + created_at: "2026-01-01", + }); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body.status).toBe("error"); + expect(body.message).toContain("already mapped"); + expect(insertOrganizationDomain).not.toHaveBeenCalled(); + }); + + it("returns 500 when the insert fails", async () => { + vi.mocked(insertOrganizationDomain).mockResolvedValue(null); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/organizations/__tests__/canManageOrganization.test.ts b/lib/organizations/__tests__/canManageOrganization.test.ts new file mode 100644 index 000000000..f85543d06 --- /dev/null +++ b/lib/organizations/__tests__/canManageOrganization.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canManageOrganization } from "../canManageOrganization"; +import { validateOrganizationAccess } from "../validateOrganizationAccess"; +import { isRecoupAdmin } from "../isRecoupAdmin"; + +vi.mock("../validateOrganizationAccess", () => ({ + validateOrganizationAccess: vi.fn(), +})); + +vi.mock("../isRecoupAdmin", () => ({ + isRecoupAdmin: vi.fn(), +})); + +describe("canManageOrganization", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when the account is a member of the organization", async () => { + vi.mocked(validateOrganizationAccess).mockResolvedValue(true); + + const result = await canManageOrganization({ accountId: "acc-1", organizationId: "org-1" }); + + expect(result).toBe(true); + expect(isRecoupAdmin).not.toHaveBeenCalled(); + }); + + it("returns true for a Recoup admin who is not a member", async () => { + vi.mocked(validateOrganizationAccess).mockResolvedValue(false); + vi.mocked(isRecoupAdmin).mockResolvedValue(true); + + const result = await canManageOrganization({ accountId: "acc-1", organizationId: "org-1" }); + + expect(result).toBe(true); + expect(isRecoupAdmin).toHaveBeenCalledWith("acc-1"); + }); + + it("returns false when neither a member nor a Recoup admin", async () => { + vi.mocked(validateOrganizationAccess).mockResolvedValue(false); + vi.mocked(isRecoupAdmin).mockResolvedValue(false); + + const result = await canManageOrganization({ accountId: "acc-1", organizationId: "org-1" }); + + expect(result).toBe(false); + }); +}); diff --git a/lib/organizations/__tests__/getOrgDomainsHandler.test.ts b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts new file mode 100644 index 000000000..01602fae6 --- /dev/null +++ b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getOrgDomainsHandler } from "../getOrgDomainsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "../canManageOrganization"; +import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +vi.mock("@/lib/supabase/organization_domains/selectOrganizationDomains", () => ({ + selectOrganizationDomains: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +function makeRequest(query = `?organization_id=${ORG_ID}`) { + return new NextRequest(`http://x/api/organizations/domains${query}`); +} + +describe("getOrgDomainsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + }); + + describe("successful cases", () => { + it("returns the organization's domains", async () => { + const rows = [ + { + id: "dom-1", + domain: "seekermusic.com", + organization_id: ORG_ID, + created_at: "2026-01-01", + }, + ]; + vi.mocked(selectOrganizationDomains).mockResolvedValue(rows); + + const response = await getOrgDomainsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "success", domains: rows }); + expect(selectOrganizationDomains).toHaveBeenCalledWith(ORG_ID); + expect(canManageOrganization).toHaveBeenCalledWith({ + accountId: "acc-1", + organizationId: ORG_ID, + }); + }); + + it("returns an empty list when no domains are mapped", async () => { + vi.mocked(selectOrganizationDomains).mockResolvedValue([]); + + const response = await getOrgDomainsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.domains).toEqual([]); + }); + }); + + describe("error cases", () => { + it("returns the auth error response when unauthenticated", async () => { + const authError = NextResponse.json( + { status: "error", message: "unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const response = await getOrgDomainsHandler(makeRequest()); + + expect(response.status).toBe(401); + expect(selectOrganizationDomains).not.toHaveBeenCalled(); + }); + + it("returns 400 when organization_id is missing", async () => { + const response = await getOrgDomainsHandler(makeRequest("")); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.status).toBe("error"); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const response = await getOrgDomainsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + expect(selectOrganizationDomains).not.toHaveBeenCalled(); + }); + + it("returns 500 when the query fails", async () => { + vi.mocked(selectOrganizationDomains).mockResolvedValue(null); + + const response = await getOrgDomainsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/organizations/__tests__/normalizeOrgDomain.test.ts b/lib/organizations/__tests__/normalizeOrgDomain.test.ts new file mode 100644 index 000000000..6c67619b1 --- /dev/null +++ b/lib/organizations/__tests__/normalizeOrgDomain.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { normalizeOrgDomain } from "../normalizeOrgDomain"; + +describe("normalizeOrgDomain", () => { + describe("normalization", () => { + it("lowercases the domain", () => { + expect(normalizeOrgDomain("SeekerMusic.COM")).toBe("seekermusic.com"); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeOrgDomain(" seekermusic.com ")).toBe("seekermusic.com"); + }); + + it("strips a leading @", () => { + expect(normalizeOrgDomain("@seekermusic.com")).toBe("seekermusic.com"); + }); + + it("applies all normalizations together", () => { + expect(normalizeOrgDomain(" @SeekerMusic.com ")).toBe("seekermusic.com"); + }); + + it("keeps subdomains and hyphens", () => { + expect(normalizeOrgDomain("mail.seeker-music.co.uk")).toBe("mail.seeker-music.co.uk"); + }); + }); + + describe("rejection", () => { + it("rejects a domain without a dot", () => { + expect(normalizeOrgDomain("seekermusic")).toBeNull(); + }); + + it("rejects strings with spaces", () => { + expect(normalizeOrgDomain("seeker music.com")).toBeNull(); + }); + + it("rejects strings with slashes", () => { + expect(normalizeOrgDomain("seekermusic.com/path")).toBeNull(); + }); + + it("rejects full email addresses", () => { + expect(normalizeOrgDomain("sam@seekermusic.com")).toBeNull(); + }); + + it("rejects empty string", () => { + expect(normalizeOrgDomain("")).toBeNull(); + }); + + it("rejects a bare @", () => { + expect(normalizeOrgDomain("@")).toBeNull(); + }); + + it("rejects leading or trailing dots", () => { + expect(normalizeOrgDomain(".seekermusic.com")).toBeNull(); + expect(normalizeOrgDomain("seekermusic.com.")).toBeNull(); + }); + }); +}); diff --git a/lib/organizations/__tests__/removeOrgDomainHandler.test.ts b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts new file mode 100644 index 000000000..dfe911dac --- /dev/null +++ b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { removeOrgDomainHandler } from "../removeOrgDomainHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "../canManageOrganization"; +import { deleteOrganizationDomain } from "@/lib/supabase/organization_domains/deleteOrganizationDomain"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +vi.mock("@/lib/supabase/organization_domains/deleteOrganizationDomain", () => ({ + deleteOrganizationDomain: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +function makeRequest(query = `?organization_id=${ORG_ID}&domain=@SeekerMusic.COM`) { + return new NextRequest(`http://x/api/organizations/domains${query}`, { method: "DELETE" }); +} + +describe("removeOrgDomainHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + vi.mocked(deleteOrganizationDomain).mockResolvedValue(true); + }); + + describe("successful cases", () => { + it("deletes the normalized domain mapping", async () => { + const response = await removeOrgDomainHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "success" }); + expect(deleteOrganizationDomain).toHaveBeenCalledWith({ + domain: "seekermusic.com", + organizationId: ORG_ID, + }); + }); + }); + + describe("error cases", () => { + it("returns the auth error response when unauthenticated", async () => { + const authError = NextResponse.json( + { status: "error", message: "unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const response = await removeOrgDomainHandler(makeRequest()); + + expect(response.status).toBe(401); + expect(deleteOrganizationDomain).not.toHaveBeenCalled(); + }); + + it("returns 400 when domain is missing", async () => { + const response = await removeOrgDomainHandler(makeRequest(`?organization_id=${ORG_ID}`)); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.status).toBe("error"); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const response = await removeOrgDomainHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + expect(deleteOrganizationDomain).not.toHaveBeenCalled(); + }); + + it("returns 500 when the delete fails", async () => { + vi.mocked(deleteOrganizationDomain).mockResolvedValue(false); + + const response = await removeOrgDomainHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts b/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts new file mode 100644 index 000000000..0adc00c92 --- /dev/null +++ b/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAddOrgDomainBody } from "../validateAddOrgDomainBody"; + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +describe("validateAddOrgDomainBody", () => { + describe("valid bodies", () => { + it("returns the validated body with a normalized domain", () => { + const result = validateAddOrgDomainBody({ + organizationId: ORG_ID, + domain: " @SeekerMusic.COM ", + }); + + expect(result).toEqual({ organizationId: ORG_ID, domain: "seekermusic.com" }); + }); + }); + + describe("invalid bodies", () => { + it("returns 400 when organizationId is missing", async () => { + const result = validateAddOrgDomainBody({ domain: "seekermusic.com" }); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organizationId"); + } + }); + + it("returns 400 when organizationId is not a UUID", async () => { + const result = validateAddOrgDomainBody({ organizationId: "org-1", domain: "a.com" }); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when domain is missing", async () => { + const result = validateAddOrgDomainBody({ organizationId: ORG_ID }); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.message).toContain("domain"); + } + }); + + it("returns 400 when domain is not a plausible bare domain", async () => { + const result = validateAddOrgDomainBody({ + organizationId: ORG_ID, + domain: "not a domain", + }); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("domain"); + } + }); + + it("returns 400 for a full email address", async () => { + const result = validateAddOrgDomainBody({ + organizationId: ORG_ID, + domain: "sam@seekermusic.com", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); +}); diff --git a/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts b/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts new file mode 100644 index 000000000..784d48c37 --- /dev/null +++ b/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetOrgDomainsQuery } from "../validateGetOrgDomainsQuery"; + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +describe("validateGetOrgDomainsQuery", () => { + it("returns the validated query when organization_id is a UUID", () => { + const result = validateGetOrgDomainsQuery( + new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}`), + ); + + expect(result).toEqual({ organization_id: ORG_ID }); + }); + + it("returns 400 when organization_id is missing", async () => { + const result = validateGetOrgDomainsQuery( + new NextRequest("http://x/api/organizations/domains"), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organization_id"); + } + }); + + it("returns 400 when organization_id is not a UUID", async () => { + const result = validateGetOrgDomainsQuery( + new NextRequest("http://x/api/organizations/domains?organization_id=org-1"), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); +}); diff --git a/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts b/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts new file mode 100644 index 000000000..b27d7fd85 --- /dev/null +++ b/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateRemoveOrgDomainQuery } from "../validateRemoveOrgDomainQuery"; + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +describe("validateRemoveOrgDomainQuery", () => { + it("returns the validated query with a normalized domain", () => { + const result = validateRemoveOrgDomainQuery( + new NextRequest( + `http://x/api/organizations/domains?organization_id=${ORG_ID}&domain=@SeekerMusic.COM`, + ), + ); + + expect(result).toEqual({ organization_id: ORG_ID, domain: "seekermusic.com" }); + }); + + it("returns 400 when organization_id is missing", async () => { + const result = validateRemoveOrgDomainQuery( + new NextRequest("http://x/api/organizations/domains?domain=seekermusic.com"), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organization_id"); + } + }); + + it("returns 400 when domain is missing", async () => { + const result = validateRemoveOrgDomainQuery( + new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}`), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.message).toContain("domain"); + } + }); + + it("returns 400 when domain is not a plausible bare domain", async () => { + const result = validateRemoveOrgDomainQuery( + new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}&domain=nodot`), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); +}); diff --git a/lib/organizations/addOrgDomainHandler.ts b/lib/organizations/addOrgDomainHandler.ts new file mode 100644 index 000000000..28d499c07 --- /dev/null +++ b/lib/organizations/addOrgDomainHandler.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAddOrgDomainBody } from "@/lib/organizations/validateAddOrgDomainBody"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { selectOrganizationDomain } from "@/lib/supabase/organization_domains/selectOrganizationDomain"; +import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/insertOrganizationDomain"; + +/** + * Handler for mapping an email domain to an organization. + * A domain can belong to at most one organization; re-adding the same + * domain to the same organization is idempotent, while a domain already + * mapped to a different organization returns 409. + * + * Body parameters: + * - organizationId (required): The organization's account ID (UUID) + * - domain (required): The email domain to map (normalized to a lowercase bare domain) + * + * @param request - The request object containing the body + * @returns A NextResponse with the domain mapping + */ +export async function addOrgDomainHandler(request: NextRequest): Promise { + try { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const body = await request.json().catch(() => null); + const validated = validateAddOrgDomainBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const hasAccess = await canManageOrganization({ + accountId: auth.accountId, + organizationId: validated.organizationId, + }); + + if (!hasAccess) { + return NextResponse.json( + { status: "error", message: "Access denied to specified organization_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const existing = await selectOrganizationDomain(validated.domain); + + if (existing && existing.organization_id !== validated.organizationId) { + return NextResponse.json( + { + status: "error", + message: `Domain "${validated.domain}" is already mapped to a different organization`, + }, + { status: 409, headers: getCorsHeaders() }, + ); + } + + const row = existing ?? (await insertOrganizationDomain(validated)); + + if (!row) { + return NextResponse.json( + { status: "error", message: "Failed to map domain to organization" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + id: row.id, + domain: row.domain, + organization_id: row.organization_id, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] addOrgDomainHandler:", error); + return NextResponse.json( + { + status: "error", + message: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/organizations/canManageOrganization.ts b/lib/organizations/canManageOrganization.ts new file mode 100644 index 000000000..b924164a5 --- /dev/null +++ b/lib/organizations/canManageOrganization.ts @@ -0,0 +1,28 @@ +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; + +export interface CanManageOrganizationParams { + /** The authenticated account ID */ + accountId: string; + /** The organization being managed */ + organizationId: string; +} + +/** + * Checks if an account can manage an organization's settings + * (e.g. domain mappings). + * + * Access rules: + * - Members of the organization (or the org account itself) are allowed + * - Recoup admins are allowed for any organization + * + * @param params - The validation parameters + * @returns true if access is allowed, false otherwise + */ +export async function canManageOrganization(params: CanManageOrganizationParams): Promise { + if (await validateOrganizationAccess(params)) { + return true; + } + + return isRecoupAdmin(params.accountId); +} diff --git a/lib/organizations/getOrgDomainsHandler.ts b/lib/organizations/getOrgDomainsHandler.ts new file mode 100644 index 000000000..e27cf2aa7 --- /dev/null +++ b/lib/organizations/getOrgDomainsHandler.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateGetOrgDomainsQuery } from "@/lib/organizations/validateGetOrgDomainsQuery"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; + +/** + * Handler for listing the email domains mapped to an organization. + * The caller must be a member of the organization or a Recoup admin. + * + * Query parameters: + * - organization_id (required): The organization's account ID (UUID) + * + * @param request - The request object + * @returns A NextResponse with the organization's domain mappings + */ +export async function getOrgDomainsHandler(request: NextRequest): Promise { + try { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const query = validateGetOrgDomainsQuery(request); + if (query instanceof NextResponse) { + return query; + } + + const hasAccess = await canManageOrganization({ + accountId: auth.accountId, + organizationId: query.organization_id, + }); + + if (!hasAccess) { + return NextResponse.json( + { status: "error", message: "Access denied to specified organization_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const domains = await selectOrganizationDomains(query.organization_id); + + if (domains === null) { + return NextResponse.json( + { status: "error", message: "Failed to fetch organization domains" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "success", domains }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getOrgDomainsHandler:", error); + return NextResponse.json( + { + status: "error", + message: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/organizations/normalizeOrgDomain.ts b/lib/organizations/normalizeOrgDomain.ts new file mode 100644 index 000000000..e2b8b1ae0 --- /dev/null +++ b/lib/organizations/normalizeOrgDomain.ts @@ -0,0 +1,15 @@ +const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +/** + * Normalizes an email domain for organization domain mappings: + * lowercased, trimmed, with a single leading "@" stripped. + * + * @param input - The raw domain input (e.g. " @SeekerMusic.COM ") + * @returns The normalized bare domain (e.g. "seekermusic.com"), or null when + * the input is not a plausible bare domain (must contain at least + * one dot; no spaces, slashes, or "@"). + */ +export function normalizeOrgDomain(input: string): string | null { + const normalized = input.trim().toLowerCase().replace(/^@/, ""); + return DOMAIN_PATTERN.test(normalized) ? normalized : null; +} diff --git a/lib/organizations/removeOrgDomainHandler.ts b/lib/organizations/removeOrgDomainHandler.ts new file mode 100644 index 000000000..1771812ca --- /dev/null +++ b/lib/organizations/removeOrgDomainHandler.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateRemoveOrgDomainQuery } from "@/lib/organizations/validateRemoveOrgDomainQuery"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { deleteOrganizationDomain } from "@/lib/supabase/organization_domains/deleteOrganizationDomain"; + +/** + * Handler for removing an email domain mapping from an organization. + * This operation is idempotent - removing a mapping that does not exist succeeds. + * + * Query parameters: + * - organization_id (required): The organization's account ID (UUID) + * - domain (required): The email domain to unmap (normalized to a lowercase bare domain) + * + * @param request - The request object + * @returns A NextResponse indicating success + */ +export async function removeOrgDomainHandler(request: NextRequest): Promise { + try { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const query = validateRemoveOrgDomainQuery(request); + if (query instanceof NextResponse) { + return query; + } + + const hasAccess = await canManageOrganization({ + accountId: auth.accountId, + organizationId: query.organization_id, + }); + + if (!hasAccess) { + return NextResponse.json( + { status: "error", message: "Access denied to specified organization_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const deleted = await deleteOrganizationDomain({ + domain: query.domain, + organizationId: query.organization_id, + }); + + if (!deleted) { + return NextResponse.json( + { status: "error", message: "Failed to remove domain mapping" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[ERROR] removeOrgDomainHandler:", error); + return NextResponse.json( + { + status: "error", + message: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/organizations/validateAddOrgDomainBody.ts b/lib/organizations/validateAddOrgDomainBody.ts new file mode 100644 index 000000000..b6de4e2a8 --- /dev/null +++ b/lib/organizations/validateAddOrgDomainBody.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { normalizeOrgDomain } from "@/lib/organizations/normalizeOrgDomain"; + +export const addOrgDomainBodySchema = z.object({ + organizationId: z + .string({ message: "organizationId is required" }) + .uuid("organizationId must be a valid UUID"), + domain: z.string({ message: "domain is required" }), +}); + +export interface AddOrgDomainBody { + organizationId: string; + /** The normalized bare email domain (lowercase, no "@") */ + domain: string; +} + +function badRequest(message: string): NextResponse { + return NextResponse.json( + { status: "error", message }, + { status: 400, headers: getCorsHeaders() }, + ); +} + +/** + * Validates request body for POST /api/organizations/domains. + * Normalizes the domain (lowercase, trimmed, leading "@" stripped) and + * rejects strings that are not a plausible bare domain. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateAddOrgDomainBody(body: unknown): NextResponse | AddOrgDomainBody { + const result = addOrgDomainBodySchema.safeParse(body); + + if (!result.success) { + return badRequest(result.error.issues[0].message); + } + + const domain = normalizeOrgDomain(result.data.domain); + + if (!domain) { + return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + } + + return { organizationId: result.data.organizationId, domain }; +} diff --git a/lib/organizations/validateGetOrgDomainsQuery.ts b/lib/organizations/validateGetOrgDomainsQuery.ts new file mode 100644 index 000000000..d714048dc --- /dev/null +++ b/lib/organizations/validateGetOrgDomainsQuery.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const getOrgDomainsQuerySchema = z.object({ + organization_id: z + .string({ message: "organization_id is required" }) + .uuid("organization_id must be a valid UUID"), +}); + +export type GetOrgDomainsQuery = z.infer; + +/** + * Validates query parameters for GET /api/organizations/domains. + * + * @param request - The request object + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateGetOrgDomainsQuery( + request: NextRequest, +): NextResponse | GetOrgDomainsQuery { + const { searchParams } = new URL(request.url); + const result = getOrgDomainsQuerySchema.safeParse({ + organization_id: searchParams.get("organization_id") ?? undefined, + }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + message: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/organizations/validateRemoveOrgDomainQuery.ts b/lib/organizations/validateRemoveOrgDomainQuery.ts new file mode 100644 index 000000000..f70019ef7 --- /dev/null +++ b/lib/organizations/validateRemoveOrgDomainQuery.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { normalizeOrgDomain } from "@/lib/organizations/normalizeOrgDomain"; + +export const removeOrgDomainQuerySchema = z.object({ + organization_id: z + .string({ message: "organization_id is required" }) + .uuid("organization_id must be a valid UUID"), + domain: z.string({ message: "domain is required" }), +}); + +export interface RemoveOrgDomainQuery { + organization_id: string; + /** The normalized bare email domain (lowercase, no "@") */ + domain: string; +} + +function badRequest(message: string): NextResponse { + return NextResponse.json( + { status: "error", message }, + { status: 400, headers: getCorsHeaders() }, + ); +} + +/** + * Validates query parameters for DELETE /api/organizations/domains. + * Normalizes the domain (lowercase, trimmed, leading "@" stripped) and + * rejects strings that are not a plausible bare domain. + * + * @param request - The request object + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateRemoveOrgDomainQuery( + request: NextRequest, +): NextResponse | RemoveOrgDomainQuery { + const { searchParams } = new URL(request.url); + const result = removeOrgDomainQuerySchema.safeParse({ + organization_id: searchParams.get("organization_id") ?? undefined, + domain: searchParams.get("domain") ?? undefined, + }); + + if (!result.success) { + return badRequest(result.error.issues[0].message); + } + + const domain = normalizeOrgDomain(result.data.domain); + + if (!domain) { + return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + } + + return { organization_id: result.data.organization_id, domain }; +} diff --git a/lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts b/lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts new file mode 100644 index 000000000..437765c4a --- /dev/null +++ b/lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { deleteOrganizationDomain } from "../deleteOrganizationDomain"; + +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +describe("deleteOrganizationDomain", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes by organization_id and domain and returns true", async () => { + const eqDomainFn = vi.fn().mockResolvedValue({ error: null }); + const eqOrgFn = vi.fn().mockReturnValue({ eq: eqDomainFn }); + const deleteFn = vi.fn().mockReturnValue({ eq: eqOrgFn }); + vi.mocked(supabase.from).mockReturnValue({ delete: deleteFn } as never); + + const result = await deleteOrganizationDomain({ + domain: "seekermusic.com", + organizationId: "org-1", + }); + + expect(supabase.from).toHaveBeenCalledWith("organization_domains"); + expect(eqOrgFn).toHaveBeenCalledWith("organization_id", "org-1"); + expect(eqDomainFn).toHaveBeenCalledWith("domain", "seekermusic.com"); + expect(result).toBe(true); + }); + + it("returns false on delete error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const eqDomainFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + const eqOrgFn = vi.fn().mockReturnValue({ eq: eqDomainFn }); + const deleteFn = vi.fn().mockReturnValue({ eq: eqOrgFn }); + vi.mocked(supabase.from).mockReturnValue({ delete: deleteFn } as never); + + const result = await deleteOrganizationDomain({ + domain: "seekermusic.com", + organizationId: "org-1", + }); + + expect(result).toBe(false); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts b/lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts new file mode 100644 index 000000000..b8c72d35b --- /dev/null +++ b/lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { insertOrganizationDomain } from "../insertOrganizationDomain"; + +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +describe("insertOrganizationDomain", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("inserts a domain mapping and returns the created row", async () => { + const row = { + id: "dom-1", + domain: "seekermusic.com", + organization_id: "org-1", + created_at: "2026-01-01", + }; + const singleFn = vi.fn().mockResolvedValue({ data: row, error: null }); + const selectFn = vi.fn().mockReturnValue({ single: singleFn }); + const insertFn = vi.fn().mockReturnValue({ select: selectFn }); + vi.mocked(supabase.from).mockReturnValue({ insert: insertFn } as never); + + const result = await insertOrganizationDomain({ + domain: "seekermusic.com", + organizationId: "org-1", + }); + + expect(supabase.from).toHaveBeenCalledWith("organization_domains"); + expect(insertFn).toHaveBeenCalledWith({ + domain: "seekermusic.com", + organization_id: "org-1", + }); + expect(result).toEqual(row); + }); + + it("returns null on insert error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const singleFn = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); + const selectFn = vi.fn().mockReturnValue({ single: singleFn }); + const insertFn = vi.fn().mockReturnValue({ select: selectFn }); + vi.mocked(supabase.from).mockReturnValue({ insert: insertFn } as never); + + const result = await insertOrganizationDomain({ + domain: "seekermusic.com", + organizationId: "org-1", + }); + + expect(result).toBeNull(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts b/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts new file mode 100644 index 000000000..64b57b1f7 --- /dev/null +++ b/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectOrganizationDomain } from "../selectOrganizationDomain"; + +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +describe("selectOrganizationDomain", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("selects a domain mapping row by domain", async () => { + const row = { + id: "dom-1", + domain: "seekermusic.com", + organization_id: "org-1", + created_at: "2026-01-01", + }; + const maybeSingleFn = vi.fn().mockResolvedValue({ data: row, error: null }); + const eqFn = vi.fn().mockReturnValue({ maybeSingle: maybeSingleFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomain("seekermusic.com"); + + expect(supabase.from).toHaveBeenCalledWith("organization_domains"); + expect(eqFn).toHaveBeenCalledWith("domain", "seekermusic.com"); + expect(result).toEqual(row); + }); + + it("returns null when the domain is not mapped", async () => { + const maybeSingleFn = vi.fn().mockResolvedValue({ data: null, error: null }); + const eqFn = vi.fn().mockReturnValue({ maybeSingle: maybeSingleFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomain("unmapped.com"); + + expect(result).toBeNull(); + }); + + it("returns null on query error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const maybeSingleFn = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); + const eqFn = vi.fn().mockReturnValue({ maybeSingle: maybeSingleFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomain("seekermusic.com"); + + expect(result).toBeNull(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts b/lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts new file mode 100644 index 000000000..b4e5b38e8 --- /dev/null +++ b/lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectOrganizationDomains } from "../selectOrganizationDomains"; + +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +describe("selectOrganizationDomains", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("selects domains by organization_id", async () => { + const rows = [ + { + id: "dom-1", + domain: "seekermusic.com", + organization_id: "org-1", + created_at: "2026-01-01", + }, + ]; + const orderFn = vi.fn().mockResolvedValue({ data: rows, error: null }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomains("org-1"); + + expect(supabase.from).toHaveBeenCalledWith("organization_domains"); + expect(eqFn).toHaveBeenCalledWith("organization_id", "org-1"); + expect(result).toEqual(rows); + }); + + it("returns [] when the organization has no domains", async () => { + const orderFn = vi.fn().mockResolvedValue({ data: [], error: null }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomains("org-1"); + + expect(result).toEqual([]); + }); + + it("returns null on query error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const orderFn = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + const result = await selectOrganizationDomains("org-1"); + + expect(result).toBeNull(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/organization_domains/deleteOrganizationDomain.ts b/lib/supabase/organization_domains/deleteOrganizationDomain.ts new file mode 100644 index 000000000..4fc34f9e5 --- /dev/null +++ b/lib/supabase/organization_domains/deleteOrganizationDomain.ts @@ -0,0 +1,31 @@ +import supabase from "../serverClient"; + +/** + * Delete a domain mapping from an organization. + * Idempotent - deleting a mapping that does not exist succeeds. + * + * @param params - The delete parameters + * @param params.domain - The normalized email domain (e.g. "seekermusic.com") + * @param params.organizationId - The organization's account ID + * @returns true on success, false on error + */ +export async function deleteOrganizationDomain({ + domain, + organizationId, +}: { + domain: string; + organizationId: string; +}): Promise { + const { error } = await supabase + .from("organization_domains") + .delete() + .eq("organization_id", organizationId) + .eq("domain", domain); + + if (error) { + console.error("Error deleting organization_domain:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/organization_domains/insertOrganizationDomain.ts b/lib/supabase/organization_domains/insertOrganizationDomain.ts new file mode 100644 index 000000000..aa1e46e18 --- /dev/null +++ b/lib/supabase/organization_domains/insertOrganizationDomain.ts @@ -0,0 +1,31 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Insert a domain mapping for an organization. + * + * @param params - The insert parameters + * @param params.domain - The normalized email domain (e.g. "seekermusic.com") + * @param params.organizationId - The organization's account ID + * @returns The created row, or null on error + */ +export async function insertOrganizationDomain({ + domain, + organizationId, +}: { + domain: string; + organizationId: string; +}): Promise | null> { + const { data, error } = await supabase + .from("organization_domains") + .insert({ domain, organization_id: organizationId }) + .select() + .single(); + + if (error || !data) { + console.error("Error inserting organization_domain:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/organization_domains/selectOrganizationDomain.ts b/lib/supabase/organization_domains/selectOrganizationDomain.ts new file mode 100644 index 000000000..20aa57af7 --- /dev/null +++ b/lib/supabase/organization_domains/selectOrganizationDomain.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select a domain mapping row by domain. + * A domain can be mapped to at most one organization. + * + * @param domain - The normalized email domain (e.g. "seekermusic.com") + * @returns The domain mapping row if found, null otherwise + */ +export async function selectOrganizationDomain( + domain: string, +): Promise | null> { + const { data, error } = await supabase + .from("organization_domains") + .select("*") + .eq("domain", domain) + .maybeSingle(); + + if (error) { + console.error("Error fetching organization_domain:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/organization_domains/selectOrganizationDomains.ts b/lib/supabase/organization_domains/selectOrganizationDomains.ts new file mode 100644 index 000000000..616d8add6 --- /dev/null +++ b/lib/supabase/organization_domains/selectOrganizationDomains.ts @@ -0,0 +1,25 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select all domain mappings for an organization. + * + * @param organizationId - The organization's account ID + * @returns The domain mapping rows, or null on error + */ +export async function selectOrganizationDomains( + organizationId: string, +): Promise[] | null> { + const { data, error } = await supabase + .from("organization_domains") + .select("*") + .eq("organization_id", organizationId) + .order("created_at", { ascending: true }); + + if (error) { + console.error("Error fetching organization_domains:", error); + return null; + } + + return data || []; +} From 2b017dcad6c8c1e53bc58f19626c8734d008af72 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 3 Jul 2026 13:44:44 -0500 Subject: [PATCH 2/3] refactor(organizations): align domain endpoints with #748 review outcomes - Consolidate duplicate access helpers into one canManageOrganization (org member or Recoup admin); members validators now use it and canManageOrgMembers is deleted - Move auth + access checks into per-endpoint request validators (validateGetOrgDomainsRequest, validateAddOrgDomainRequest, validateRemoveOrgDomainRequest), absorbing the old query/body validators; handlers slim to validator -> data call -> response - Harden the three 500 catch blocks: hardcoded "Internal server error" (real error only in console.error) with leak-regression tests - Strengthen error-shape assertions (body.status + body.message) Response contract unchanged per recoupable/docs#261 (including the 409 cross-org conflict and idempotent POST/DELETE). Co-Authored-By: Claude Fable 5 --- .../__tests__/addOrgDomainHandler.test.ts | 19 +- .../__tests__/addOrgMemberHandler.test.ts | 14 +- .../__tests__/canManageOrgMembers.test.ts | 56 ------ .../__tests__/getOrgDomainsHandler.test.ts | 17 +- .../__tests__/removeOrgDomainHandler.test.ts | 17 +- .../__tests__/removeOrgMemberHandler.test.ts | 12 +- .../validateAddOrgDomainBody.test.ts | 76 -------- .../validateAddOrgDomainRequest.test.ts | 167 ++++++++++++++++++ .../validateAddOrgMemberRequest.test.ts | 16 +- .../validateGetOrgDomainsQuery.test.ts | 40 ----- .../validateGetOrgDomainsRequest.test.ts | 99 +++++++++++ .../validateRemoveOrgDomainQuery.test.ts | 55 ------ .../validateRemoveOrgDomainRequest.test.ts | 121 +++++++++++++ .../validateRemoveOrgMemberRequest.test.ts | 14 +- lib/organizations/addOrgDomainHandler.ts | 40 ++--- lib/organizations/canManageOrgMembers.ts | 28 --- lib/organizations/canManageOrganization.ts | 4 +- lib/organizations/getOrgDomainsHandler.ts | 37 +--- lib/organizations/removeOrgDomainHandler.ts | 35 +--- lib/organizations/validateAddOrgDomainBody.ts | 48 ----- .../validateAddOrgDomainRequest.ts | 94 ++++++++++ .../validateAddOrgMemberRequest.ts | 4 +- .../validateGetOrgDomainsQuery.ts | 43 ----- .../validateGetOrgDomainsRequest.ts | 85 +++++++++ .../validateRemoveOrgDomainQuery.ts | 55 ------ .../validateRemoveOrgDomainRequest.ts | 98 ++++++++++ .../validateRemoveOrgMemberRequest.ts | 4 +- 27 files changed, 774 insertions(+), 524 deletions(-) delete mode 100644 lib/organizations/__tests__/canManageOrgMembers.test.ts delete mode 100644 lib/organizations/__tests__/validateAddOrgDomainBody.test.ts create mode 100644 lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts delete mode 100644 lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts create mode 100644 lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts delete mode 100644 lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts create mode 100644 lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts delete mode 100644 lib/organizations/canManageOrgMembers.ts delete mode 100644 lib/organizations/validateAddOrgDomainBody.ts create mode 100644 lib/organizations/validateAddOrgDomainRequest.ts delete mode 100644 lib/organizations/validateGetOrgDomainsQuery.ts create mode 100644 lib/organizations/validateGetOrgDomainsRequest.ts delete mode 100644 lib/organizations/validateRemoveOrgDomainQuery.ts create mode 100644 lib/organizations/validateRemoveOrgDomainRequest.ts diff --git a/lib/organizations/__tests__/addOrgDomainHandler.test.ts b/lib/organizations/__tests__/addOrgDomainHandler.test.ts index 6f14a43f7..b4ff4296a 100644 --- a/lib/organizations/__tests__/addOrgDomainHandler.test.ts +++ b/lib/organizations/__tests__/addOrgDomainHandler.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { addOrgDomainHandler } from "../addOrgDomainHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrganization } from "../canManageOrganization"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { selectOrganizationDomain } from "@/lib/supabase/organization_domains/selectOrganizationDomain"; import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/insertOrganizationDomain"; @@ -10,7 +10,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("../canManageOrganization", () => ({ +vi.mock("@/lib/organizations/canManageOrganization", () => ({ canManageOrganization: vi.fn(), })); @@ -120,6 +120,7 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -167,6 +168,20 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + }); + + it("returns a generic 500 without leaking exception details when a dependency throws", async () => { + vi.mocked(insertOrganizationDomain).mockRejectedValue(new Error("SECRET_DB_DETAIL")); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); }); diff --git a/lib/organizations/__tests__/addOrgMemberHandler.test.ts b/lib/organizations/__tests__/addOrgMemberHandler.test.ts index 2a28c5876..a2420af15 100644 --- a/lib/organizations/__tests__/addOrgMemberHandler.test.ts +++ b/lib/organizations/__tests__/addOrgMemberHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { addOrgMemberHandler } from "../addOrgMemberHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { getOrCreateAccountByEmail } from "@/lib/accounts/getOrCreateAccountByEmail"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; import { addAccountToOrganization } from "@/lib/supabase/account_organization_ids/addAccountToOrganization"; @@ -12,8 +12,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/organizations/canManageOrgMembers", () => ({ - canManageOrgMembers: vi.fn(), +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), })); vi.mock("@/lib/accounts/getOrCreateAccountByEmail", () => ({ @@ -46,7 +46,7 @@ describe("addOrgMemberHandler", () => { orgId: null, authToken: "token", }); - vi.mocked(canManageOrgMembers).mockResolvedValue(true); + vi.mocked(canManageOrganization).mockResolvedValue(true); vi.mocked(getAccountOrganizations).mockResolvedValue([]); vi.mocked(addAccountToOrganization).mockResolvedValue("membership-1"); }); @@ -124,7 +124,7 @@ describe("addOrgMemberHandler", () => { ); expect(response.status).toBe(401); - expect(canManageOrgMembers).not.toHaveBeenCalled(); + expect(canManageOrganization).not.toHaveBeenCalled(); }); it("returns 400 when the body is invalid", async () => { @@ -148,7 +148,7 @@ describe("addOrgMemberHandler", () => { }); it("returns 403 when the caller cannot manage the organization", async () => { - vi.mocked(canManageOrgMembers).mockResolvedValue(false); + vi.mocked(canManageOrganization).mockResolvedValue(false); const response = await addOrgMemberHandler( buildRequest({ organizationId: ORG_ID, accountId: MEMBER_ID }), @@ -158,7 +158,7 @@ describe("addOrgMemberHandler", () => { const body = await response.json(); expect(body.status).toBe("error"); expect(typeof body.message).toBe("string"); - expect(canManageOrgMembers).toHaveBeenCalledWith({ + expect(canManageOrganization).toHaveBeenCalledWith({ accountId: "caller-1", organizationId: ORG_ID, }); diff --git a/lib/organizations/__tests__/canManageOrgMembers.test.ts b/lib/organizations/__tests__/canManageOrgMembers.test.ts deleted file mode 100644 index 90eb0cf5e..000000000 --- a/lib/organizations/__tests__/canManageOrgMembers.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { canManageOrgMembers } from "../canManageOrgMembers"; - -import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; -import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; - -vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ - validateOrganizationAccess: vi.fn(), -})); - -vi.mock("@/lib/organizations/isRecoupAdmin", () => ({ - isRecoupAdmin: vi.fn(), -})); - -describe("canManageOrgMembers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns true when the caller is a member of the organization", async () => { - vi.mocked(validateOrganizationAccess).mockResolvedValue(true); - - const result = await canManageOrgMembers({ - accountId: "account-1", - organizationId: "org-1", - }); - - expect(result).toBe(true); - expect(isRecoupAdmin).not.toHaveBeenCalled(); - }); - - it("returns true when the caller is not a member but is a Recoup admin", async () => { - vi.mocked(validateOrganizationAccess).mockResolvedValue(false); - vi.mocked(isRecoupAdmin).mockResolvedValue(true); - - const result = await canManageOrgMembers({ - accountId: "admin-1", - organizationId: "org-1", - }); - - expect(result).toBe(true); - expect(isRecoupAdmin).toHaveBeenCalledWith("admin-1"); - }); - - it("returns false when the caller is neither a member nor a Recoup admin", async () => { - vi.mocked(validateOrganizationAccess).mockResolvedValue(false); - vi.mocked(isRecoupAdmin).mockResolvedValue(false); - - const result = await canManageOrgMembers({ - accountId: "stranger-1", - organizationId: "org-1", - }); - - expect(result).toBe(false); - }); -}); diff --git a/lib/organizations/__tests__/getOrgDomainsHandler.test.ts b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts index 01602fae6..1c16eac8b 100644 --- a/lib/organizations/__tests__/getOrgDomainsHandler.test.ts +++ b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getOrgDomainsHandler } from "../getOrgDomainsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrganization } from "../canManageOrganization"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("../canManageOrganization", () => ({ +vi.mock("@/lib/organizations/canManageOrganization", () => ({ canManageOrganization: vi.fn(), })); @@ -89,6 +89,7 @@ describe("getOrgDomainsHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -113,6 +114,18 @@ describe("getOrgDomainsHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + }); + + it("returns a generic 500 without leaking exception details when a dependency throws", async () => { + vi.mocked(selectOrganizationDomains).mockRejectedValue(new Error("SECRET_DB_DETAIL")); + + const response = await getOrgDomainsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); }); diff --git a/lib/organizations/__tests__/removeOrgDomainHandler.test.ts b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts index dfe911dac..bc89242a0 100644 --- a/lib/organizations/__tests__/removeOrgDomainHandler.test.ts +++ b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { removeOrgDomainHandler } from "../removeOrgDomainHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrganization } from "../canManageOrganization"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { deleteOrganizationDomain } from "@/lib/supabase/organization_domains/deleteOrganizationDomain"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("../canManageOrganization", () => ({ +vi.mock("@/lib/organizations/canManageOrganization", () => ({ canManageOrganization: vi.fn(), })); @@ -69,6 +69,7 @@ describe("removeOrgDomainHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -93,6 +94,18 @@ describe("removeOrgDomainHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + }); + + it("returns a generic 500 without leaking exception details when a dependency throws", async () => { + vi.mocked(deleteOrganizationDomain).mockRejectedValue(new Error("SECRET_DB_DETAIL")); + + const response = await removeOrgDomainHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); }); diff --git a/lib/organizations/__tests__/removeOrgMemberHandler.test.ts b/lib/organizations/__tests__/removeOrgMemberHandler.test.ts index b41c0cb57..48ac4221f 100644 --- a/lib/organizations/__tests__/removeOrgMemberHandler.test.ts +++ b/lib/organizations/__tests__/removeOrgMemberHandler.test.ts @@ -3,15 +3,15 @@ import { NextRequest, NextResponse } from "next/server"; import { removeOrgMemberHandler } from "../removeOrgMemberHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { deleteAccountOrganization } from "@/lib/supabase/account_organization_ids/deleteAccountOrganization"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/organizations/canManageOrgMembers", () => ({ - canManageOrgMembers: vi.fn(), +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), })); vi.mock("@/lib/supabase/account_organization_ids/deleteAccountOrganization", () => ({ @@ -35,7 +35,7 @@ describe("removeOrgMemberHandler", () => { orgId: null, authToken: "token", }); - vi.mocked(canManageOrgMembers).mockResolvedValue(true); + vi.mocked(canManageOrganization).mockResolvedValue(true); vi.mocked(deleteAccountOrganization).mockResolvedValue(true); }); @@ -78,7 +78,7 @@ describe("removeOrgMemberHandler", () => { }); it("returns 403 when the caller cannot manage the organization", async () => { - vi.mocked(canManageOrgMembers).mockResolvedValue(false); + vi.mocked(canManageOrganization).mockResolvedValue(false); const response = await removeOrgMemberHandler( buildRequest(`?organization_id=${ORG_ID}&account_id=${MEMBER_ID}`), @@ -87,7 +87,7 @@ describe("removeOrgMemberHandler", () => { expect(response.status).toBe(403); const body = await response.json(); expect(body.status).toBe("error"); - expect(canManageOrgMembers).toHaveBeenCalledWith({ + expect(canManageOrganization).toHaveBeenCalledWith({ accountId: "caller-1", organizationId: ORG_ID, }); diff --git a/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts b/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts deleted file mode 100644 index 0adc00c92..000000000 --- a/lib/organizations/__tests__/validateAddOrgDomainBody.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { validateAddOrgDomainBody } from "../validateAddOrgDomainBody"; - -const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; - -describe("validateAddOrgDomainBody", () => { - describe("valid bodies", () => { - it("returns the validated body with a normalized domain", () => { - const result = validateAddOrgDomainBody({ - organizationId: ORG_ID, - domain: " @SeekerMusic.COM ", - }); - - expect(result).toEqual({ organizationId: ORG_ID, domain: "seekermusic.com" }); - }); - }); - - describe("invalid bodies", () => { - it("returns 400 when organizationId is missing", async () => { - const result = validateAddOrgDomainBody({ domain: "seekermusic.com" }); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.status).toBe("error"); - expect(body.message).toContain("organizationId"); - } - }); - - it("returns 400 when organizationId is not a UUID", async () => { - const result = validateAddOrgDomainBody({ organizationId: "org-1", domain: "a.com" }); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when domain is missing", async () => { - const result = validateAddOrgDomainBody({ organizationId: ORG_ID }); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.message).toContain("domain"); - } - }); - - it("returns 400 when domain is not a plausible bare domain", async () => { - const result = validateAddOrgDomainBody({ - organizationId: ORG_ID, - domain: "not a domain", - }); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.status).toBe("error"); - expect(body.message).toContain("domain"); - } - }); - - it("returns 400 for a full email address", async () => { - const result = validateAddOrgDomainBody({ - organizationId: ORG_ID, - domain: "sam@seekermusic.com", - }); - - expect(result).toBeInstanceOf(NextResponse); - }); - }); -}); diff --git a/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts b/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts new file mode 100644 index 000000000..fad127368 --- /dev/null +++ b/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateAddOrgDomainRequest } from "../validateAddOrgDomainRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +function buildRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/organizations/domains", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("validateAddOrgDomainRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "caller-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + }); + + it("returns the caller account ID and body with a normalized domain", async () => { + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: ORG_ID, domain: " @SeekerMusic.COM " }), + ); + + expect(result).toEqual({ + callerAccountId: "caller-1", + body: { organizationId: ORG_ID, domain: "seekermusic.com" }, + }); + expect(canManageOrganization).toHaveBeenCalledWith({ + accountId: "caller-1", + organizationId: ORG_ID, + }); + }); + + it("returns the auth error response when authentication fails", async () => { + const unauthorized = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(unauthorized); + + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + + expect(result).toBe(unauthorized); + expect(canManageOrganization).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const body = await result.json(); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + } + }); + + it("returns 400 when the body is not valid JSON", async () => { + const request = new NextRequest("http://localhost/api/organizations/domains", { + method: "POST", + body: "not-json", + }); + + const result = await validateAddOrgDomainRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + } + }); + + it("returns 400 when organizationId is missing", async () => { + const result = await validateAddOrgDomainRequest(buildRequest({ domain: "seekermusic.com" })); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organizationId"); + } + }); + + it("returns 400 when organizationId is not a UUID", async () => { + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: "org-1", domain: "a.com" }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + } + }); + + it("returns 400 when domain is missing", async () => { + const result = await validateAddOrgDomainRequest(buildRequest({ organizationId: ORG_ID })); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("domain"); + } + }); + + it("returns 400 when domain is not a plausible bare domain", async () => { + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: ORG_ID, domain: "not a domain" }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("domain"); + } + expect(canManageOrganization).not.toHaveBeenCalled(); + }); + + it("returns 400 for a full email address", async () => { + const result = await validateAddOrgDomainRequest( + buildRequest({ organizationId: ORG_ID, domain: "sam@seekermusic.com" }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + } + }); +}); diff --git a/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts index 85f706637..b5d61ead4 100644 --- a/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts +++ b/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts @@ -3,14 +3,14 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAddOrgMemberRequest } from "../validateAddOrgMemberRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/organizations/canManageOrgMembers", () => ({ - canManageOrgMembers: vi.fn(), +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), })); const ORG_ID = "11111111-1111-4111-8111-111111111111"; @@ -31,7 +31,7 @@ describe("validateAddOrgMemberRequest", () => { orgId: null, authToken: "token", }); - vi.mocked(canManageOrgMembers).mockResolvedValue(true); + vi.mocked(canManageOrganization).mockResolvedValue(true); }); describe("valid requests", () => { @@ -44,7 +44,7 @@ describe("validateAddOrgMemberRequest", () => { callerAccountId: "caller-1", body: { organizationId: ORG_ID, accountId: ACCOUNT_ID }, }); - expect(canManageOrgMembers).toHaveBeenCalledWith({ + expect(canManageOrganization).toHaveBeenCalledWith({ accountId: "caller-1", organizationId: ORG_ID, }); @@ -75,11 +75,11 @@ describe("validateAddOrgMemberRequest", () => { ); expect(result).toBe(unauthorized); - expect(canManageOrgMembers).not.toHaveBeenCalled(); + expect(canManageOrganization).not.toHaveBeenCalled(); }); it("returns 403 when the caller cannot manage the organization", async () => { - vi.mocked(canManageOrgMembers).mockResolvedValue(false); + vi.mocked(canManageOrganization).mockResolvedValue(false); const result = await validateAddOrgMemberRequest( buildRequest({ organizationId: ORG_ID, accountId: ACCOUNT_ID }), @@ -188,7 +188,7 @@ describe("validateAddOrgMemberRequest", () => { expect(body.status).toBe("error"); expect(typeof body.message).toBe("string"); } - expect(canManageOrgMembers).not.toHaveBeenCalled(); + expect(canManageOrganization).not.toHaveBeenCalled(); }); }); }); diff --git a/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts b/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts deleted file mode 100644 index 784d48c37..000000000 --- a/lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { validateGetOrgDomainsQuery } from "../validateGetOrgDomainsQuery"; - -const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; - -describe("validateGetOrgDomainsQuery", () => { - it("returns the validated query when organization_id is a UUID", () => { - const result = validateGetOrgDomainsQuery( - new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}`), - ); - - expect(result).toEqual({ organization_id: ORG_ID }); - }); - - it("returns 400 when organization_id is missing", async () => { - const result = validateGetOrgDomainsQuery( - new NextRequest("http://x/api/organizations/domains"), - ); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.status).toBe("error"); - expect(body.message).toContain("organization_id"); - } - }); - - it("returns 400 when organization_id is not a UUID", async () => { - const result = validateGetOrgDomainsQuery( - new NextRequest("http://x/api/organizations/domains?organization_id=org-1"), - ); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); -}); diff --git a/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts b/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts new file mode 100644 index 000000000..8d9fa9c04 --- /dev/null +++ b/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetOrgDomainsRequest } from "../validateGetOrgDomainsRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +function buildRequest(query: string): NextRequest { + return new NextRequest(`http://localhost/api/organizations/domains${query}`); +} + +describe("validateGetOrgDomainsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "caller-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + }); + + it("returns the caller account ID and query for a valid request", async () => { + const result = await validateGetOrgDomainsRequest(buildRequest(`?organization_id=${ORG_ID}`)); + + expect(result).toEqual({ + callerAccountId: "caller-1", + query: { organization_id: ORG_ID }, + }); + expect(canManageOrganization).toHaveBeenCalledWith({ + accountId: "caller-1", + organizationId: ORG_ID, + }); + }); + + it("returns the auth error response when authentication fails", async () => { + const unauthorized = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(unauthorized); + + const result = await validateGetOrgDomainsRequest(buildRequest(`?organization_id=${ORG_ID}`)); + + expect(result).toBe(unauthorized); + expect(canManageOrganization).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const result = await validateGetOrgDomainsRequest(buildRequest(`?organization_id=${ORG_ID}`)); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const body = await result.json(); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + } + }); + + it("returns 400 when organization_id is missing", async () => { + const result = await validateGetOrgDomainsRequest(buildRequest("")); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organization_id"); + } + expect(canManageOrganization).not.toHaveBeenCalled(); + }); + + it("returns 400 when organization_id is not a UUID", async () => { + const result = await validateGetOrgDomainsRequest(buildRequest("?organization_id=org-1")); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + } + }); +}); diff --git a/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts b/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts deleted file mode 100644 index b27d7fd85..000000000 --- a/lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { validateRemoveOrgDomainQuery } from "../validateRemoveOrgDomainQuery"; - -const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; - -describe("validateRemoveOrgDomainQuery", () => { - it("returns the validated query with a normalized domain", () => { - const result = validateRemoveOrgDomainQuery( - new NextRequest( - `http://x/api/organizations/domains?organization_id=${ORG_ID}&domain=@SeekerMusic.COM`, - ), - ); - - expect(result).toEqual({ organization_id: ORG_ID, domain: "seekermusic.com" }); - }); - - it("returns 400 when organization_id is missing", async () => { - const result = validateRemoveOrgDomainQuery( - new NextRequest("http://x/api/organizations/domains?domain=seekermusic.com"), - ); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.status).toBe("error"); - expect(body.message).toContain("organization_id"); - } - }); - - it("returns 400 when domain is missing", async () => { - const result = validateRemoveOrgDomainQuery( - new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}`), - ); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.message).toContain("domain"); - } - }); - - it("returns 400 when domain is not a plausible bare domain", async () => { - const result = validateRemoveOrgDomainQuery( - new NextRequest(`http://x/api/organizations/domains?organization_id=${ORG_ID}&domain=nodot`), - ); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); -}); diff --git a/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts new file mode 100644 index 000000000..468aebbd6 --- /dev/null +++ b/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateRemoveOrgDomainRequest } from "../validateRemoveOrgDomainRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), +})); + +const ORG_ID = "9f0b5f61-6f8d-4b64-92f5-0d1a5f0a1c2e"; + +function buildRequest(query: string): NextRequest { + return new NextRequest(`http://localhost/api/organizations/domains${query}`, { + method: "DELETE", + }); +} + +describe("validateRemoveOrgDomainRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "caller-1", + orgId: null, + authToken: "token", + }); + vi.mocked(canManageOrganization).mockResolvedValue(true); + }); + + it("returns the caller account ID and query with a normalized domain", async () => { + const result = await validateRemoveOrgDomainRequest( + buildRequest(`?organization_id=${ORG_ID}&domain=@SeekerMusic.COM`), + ); + + expect(result).toEqual({ + callerAccountId: "caller-1", + query: { organization_id: ORG_ID, domain: "seekermusic.com" }, + }); + expect(canManageOrganization).toHaveBeenCalledWith({ + accountId: "caller-1", + organizationId: ORG_ID, + }); + }); + + it("returns the auth error response when authentication fails", async () => { + const unauthorized = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(unauthorized); + + const result = await validateRemoveOrgDomainRequest( + buildRequest(`?organization_id=${ORG_ID}&domain=seekermusic.com`), + ); + + expect(result).toBe(unauthorized); + expect(canManageOrganization).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller cannot manage the organization", async () => { + vi.mocked(canManageOrganization).mockResolvedValue(false); + + const result = await validateRemoveOrgDomainRequest( + buildRequest(`?organization_id=${ORG_ID}&domain=seekermusic.com`), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const body = await result.json(); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified organization_id", + }); + } + }); + + it("returns 400 when organization_id is missing", async () => { + const result = await validateRemoveOrgDomainRequest(buildRequest("?domain=seekermusic.com")); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("organization_id"); + } + }); + + it("returns 400 when domain is missing", async () => { + const result = await validateRemoveOrgDomainRequest(buildRequest(`?organization_id=${ORG_ID}`)); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(body.message).toContain("domain"); + } + }); + + it("returns 400 when domain is not a plausible bare domain", async () => { + const result = await validateRemoveOrgDomainRequest( + buildRequest(`?organization_id=${ORG_ID}&domain=nodot`), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.status).toBe("error"); + expect(typeof body.message).toBe("string"); + } + expect(canManageOrganization).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts index 4a074beff..f12778830 100644 --- a/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts +++ b/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts @@ -3,14 +3,14 @@ import { NextRequest, NextResponse } from "next/server"; import { validateRemoveOrgMemberRequest } from "../validateRemoveOrgMemberRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/organizations/canManageOrgMembers", () => ({ - canManageOrgMembers: vi.fn(), +vi.mock("@/lib/organizations/canManageOrganization", () => ({ + canManageOrganization: vi.fn(), })); const ORG_ID = "11111111-1111-4111-8111-111111111111"; @@ -30,7 +30,7 @@ describe("validateRemoveOrgMemberRequest", () => { orgId: null, authToken: "token", }); - vi.mocked(canManageOrgMembers).mockResolvedValue(true); + vi.mocked(canManageOrganization).mockResolvedValue(true); }); it("returns the caller account ID and query for a valid request", async () => { @@ -42,7 +42,7 @@ describe("validateRemoveOrgMemberRequest", () => { callerAccountId: "caller-1", query: { organization_id: ORG_ID, account_id: ACCOUNT_ID }, }); - expect(canManageOrgMembers).toHaveBeenCalledWith({ + expect(canManageOrganization).toHaveBeenCalledWith({ accountId: "caller-1", organizationId: ORG_ID, }); @@ -60,11 +60,11 @@ describe("validateRemoveOrgMemberRequest", () => { ); expect(result).toBe(unauthorized); - expect(canManageOrgMembers).not.toHaveBeenCalled(); + expect(canManageOrganization).not.toHaveBeenCalled(); }); it("returns 403 when the caller cannot manage the organization", async () => { - vi.mocked(canManageOrgMembers).mockResolvedValue(false); + vi.mocked(canManageOrganization).mockResolvedValue(false); const result = await validateRemoveOrgMemberRequest( buildRequest(`?organization_id=${ORG_ID}&account_id=${ACCOUNT_ID}`), diff --git a/lib/organizations/addOrgDomainHandler.ts b/lib/organizations/addOrgDomainHandler.ts index 28d499c07..101a5cea6 100644 --- a/lib/organizations/addOrgDomainHandler.ts +++ b/lib/organizations/addOrgDomainHandler.ts @@ -1,8 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { validateAddOrgDomainBody } from "@/lib/organizations/validateAddOrgDomainBody"; -import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { validateAddOrgDomainRequest } from "@/lib/organizations/validateAddOrgDomainRequest"; import { selectOrganizationDomain } from "@/lib/supabase/organization_domains/selectOrganizationDomain"; import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/insertOrganizationDomain"; @@ -12,51 +10,35 @@ import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/in * domain to the same organization is idempotent, while a domain already * mapped to a different organization returns 409. * - * Body parameters: - * - organizationId (required): The organization's account ID (UUID) - * - domain (required): The email domain to map (normalized to a lowercase bare domain) + * Auth, body validation (including domain normalization), and access + * checks live in validateAddOrgDomainRequest. This handler performs the + * idempotent insert and shapes the response. * * @param request - The request object containing the body * @returns A NextResponse with the domain mapping */ export async function addOrgDomainHandler(request: NextRequest): Promise { try { - const auth = await validateAuthContext(request); - if (auth instanceof NextResponse) { - return auth; - } - - const body = await request.json().catch(() => null); - const validated = validateAddOrgDomainBody(body); + const validated = await validateAddOrgDomainRequest(request); if (validated instanceof NextResponse) { return validated; } - const hasAccess = await canManageOrganization({ - accountId: auth.accountId, - organizationId: validated.organizationId, - }); - - if (!hasAccess) { - return NextResponse.json( - { status: "error", message: "Access denied to specified organization_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + const { body } = validated; - const existing = await selectOrganizationDomain(validated.domain); + const existing = await selectOrganizationDomain(body.domain); - if (existing && existing.organization_id !== validated.organizationId) { + if (existing && existing.organization_id !== body.organizationId) { return NextResponse.json( { status: "error", - message: `Domain "${validated.domain}" is already mapped to a different organization`, + message: `Domain "${body.domain}" is already mapped to a different organization`, }, { status: 409, headers: getCorsHeaders() }, ); } - const row = existing ?? (await insertOrganizationDomain(validated)); + const row = existing ?? (await insertOrganizationDomain(body)); if (!row) { return NextResponse.json( @@ -79,7 +61,7 @@ export async function addOrgDomainHandler(request: NextRequest): Promise { - if (await validateOrganizationAccess(params)) { - return true; - } - - return isRecoupAdmin(params.accountId); -} diff --git a/lib/organizations/canManageOrganization.ts b/lib/organizations/canManageOrganization.ts index b924164a5..28238bfaa 100644 --- a/lib/organizations/canManageOrganization.ts +++ b/lib/organizations/canManageOrganization.ts @@ -9,8 +9,8 @@ export interface CanManageOrganizationParams { } /** - * Checks if an account can manage an organization's settings - * (e.g. domain mappings). + * Checks if an account can manage an organization + * (e.g. its membership or domain mappings). * * Access rules: * - Members of the organization (or the org account itself) are allowed diff --git a/lib/organizations/getOrgDomainsHandler.ts b/lib/organizations/getOrgDomainsHandler.ts index e27cf2aa7..a4595a0c5 100644 --- a/lib/organizations/getOrgDomainsHandler.ts +++ b/lib/organizations/getOrgDomainsHandler.ts @@ -1,45 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { validateGetOrgDomainsQuery } from "@/lib/organizations/validateGetOrgDomainsQuery"; -import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { validateGetOrgDomainsRequest } from "@/lib/organizations/validateGetOrgDomainsRequest"; import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; /** * Handler for listing the email domains mapped to an organization. - * The caller must be a member of the organization or a Recoup admin. * - * Query parameters: - * - organization_id (required): The organization's account ID (UUID) + * Auth, query validation, and access checks live in + * validateGetOrgDomainsRequest. This handler fetches the domain mappings + * and shapes the response. * * @param request - The request object * @returns A NextResponse with the organization's domain mappings */ export async function getOrgDomainsHandler(request: NextRequest): Promise { try { - const auth = await validateAuthContext(request); - if (auth instanceof NextResponse) { - return auth; + const validated = await validateGetOrgDomainsRequest(request); + if (validated instanceof NextResponse) { + return validated; } - const query = validateGetOrgDomainsQuery(request); - if (query instanceof NextResponse) { - return query; - } - - const hasAccess = await canManageOrganization({ - accountId: auth.accountId, - organizationId: query.organization_id, - }); - - if (!hasAccess) { - return NextResponse.json( - { status: "error", message: "Access denied to specified organization_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } - - const domains = await selectOrganizationDomains(query.organization_id); + const domains = await selectOrganizationDomains(validated.query.organization_id); if (domains === null) { return NextResponse.json( @@ -57,7 +38,7 @@ export async function getOrgDomainsHandler(request: NextRequest): Promise { try { - const auth = await validateAuthContext(request); - if (auth instanceof NextResponse) { - return auth; + const validated = await validateRemoveOrgDomainRequest(request); + if (validated instanceof NextResponse) { + return validated; } - const query = validateRemoveOrgDomainQuery(request); - if (query instanceof NextResponse) { - return query; - } - - const hasAccess = await canManageOrganization({ - accountId: auth.accountId, - organizationId: query.organization_id, - }); - - if (!hasAccess) { - return NextResponse.json( - { status: "error", message: "Access denied to specified organization_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + const { query } = validated; const deleted = await deleteOrganizationDomain({ domain: query.domain, @@ -58,7 +41,7 @@ export async function removeOrgDomainHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const rawBody = await request.json().catch(() => null); + const result = addOrgDomainBodySchema.safeParse(rawBody); + if (!result.success) { + return badRequest(result.error.issues[0].message); + } + + const domain = normalizeOrgDomain(result.data.domain); + if (!domain) { + return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organizationId, + }); + + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organization_id", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + callerAccountId: authResult.accountId, + body: { organizationId: result.data.organizationId, domain }, + }; +} diff --git a/lib/organizations/validateAddOrgMemberRequest.ts b/lib/organizations/validateAddOrgMemberRequest.ts index 9c5be96d6..189cb6d4d 100644 --- a/lib/organizations/validateAddOrgMemberRequest.ts +++ b/lib/organizations/validateAddOrgMemberRequest.ts @@ -2,7 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; const addOrgMemberBodySchema = z @@ -65,7 +65,7 @@ export async function validateAddOrgMemberRequest( ); } - const hasAccess = await canManageOrgMembers({ + const hasAccess = await canManageOrganization({ accountId: authResult.accountId, organizationId: result.data.organizationId, }); diff --git a/lib/organizations/validateGetOrgDomainsQuery.ts b/lib/organizations/validateGetOrgDomainsQuery.ts deleted file mode 100644 index d714048dc..000000000 --- a/lib/organizations/validateGetOrgDomainsQuery.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const getOrgDomainsQuerySchema = z.object({ - organization_id: z - .string({ message: "organization_id is required" }) - .uuid("organization_id must be a valid UUID"), -}); - -export type GetOrgDomainsQuery = z.infer; - -/** - * Validates query parameters for GET /api/organizations/domains. - * - * @param request - The request object - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. - */ -export function validateGetOrgDomainsQuery( - request: NextRequest, -): NextResponse | GetOrgDomainsQuery { - const { searchParams } = new URL(request.url); - const result = getOrgDomainsQuerySchema.safeParse({ - organization_id: searchParams.get("organization_id") ?? undefined, - }); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - message: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} diff --git a/lib/organizations/validateGetOrgDomainsRequest.ts b/lib/organizations/validateGetOrgDomainsRequest.ts new file mode 100644 index 000000000..2fdba6ccd --- /dev/null +++ b/lib/organizations/validateGetOrgDomainsRequest.ts @@ -0,0 +1,85 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { z } from "zod"; + +const getOrgDomainsQuerySchema = z.object({ + organization_id: z + .string({ message: "organization_id is required" }) + .uuid("organization_id must be a valid UUID"), +}); + +export type GetOrgDomainsQuery = z.infer; + +export interface GetOrgDomainsRequestData { + /** The authenticated caller's account ID */ + callerAccountId: string; + /** The validated query parameters */ + query: GetOrgDomainsQuery; +} + +/** + * Validates GET /api/organizations/domains requests. + * Handles authentication (x-api-key or Authorization bearer token), + * query validation, and the caller's access to manage the organization. + * + * Query parameters: + * - organization_id (required): The organization's account ID + * + * The caller must be a member of the organization or a Recoup admin. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error (400/401/403) if validation fails, + * or the caller account ID and validated query. + */ +export async function validateGetOrgDomainsRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const searchParams = request.nextUrl.searchParams; + const result = getOrgDomainsQuerySchema.safeParse({ + organization_id: searchParams.get("organization_id") ?? undefined, + }); + + if (!result.success) { + return NextResponse.json( + { + status: "error", + message: result.error.issues[0].message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organization_id, + }); + + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organization_id", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + callerAccountId: authResult.accountId, + query: result.data, + }; +} diff --git a/lib/organizations/validateRemoveOrgDomainQuery.ts b/lib/organizations/validateRemoveOrgDomainQuery.ts deleted file mode 100644 index f70019ef7..000000000 --- a/lib/organizations/validateRemoveOrgDomainQuery.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; -import { normalizeOrgDomain } from "@/lib/organizations/normalizeOrgDomain"; - -export const removeOrgDomainQuerySchema = z.object({ - organization_id: z - .string({ message: "organization_id is required" }) - .uuid("organization_id must be a valid UUID"), - domain: z.string({ message: "domain is required" }), -}); - -export interface RemoveOrgDomainQuery { - organization_id: string; - /** The normalized bare email domain (lowercase, no "@") */ - domain: string; -} - -function badRequest(message: string): NextResponse { - return NextResponse.json( - { status: "error", message }, - { status: 400, headers: getCorsHeaders() }, - ); -} - -/** - * Validates query parameters for DELETE /api/organizations/domains. - * Normalizes the domain (lowercase, trimmed, leading "@" stripped) and - * rejects strings that are not a plausible bare domain. - * - * @param request - The request object - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. - */ -export function validateRemoveOrgDomainQuery( - request: NextRequest, -): NextResponse | RemoveOrgDomainQuery { - const { searchParams } = new URL(request.url); - const result = removeOrgDomainQuerySchema.safeParse({ - organization_id: searchParams.get("organization_id") ?? undefined, - domain: searchParams.get("domain") ?? undefined, - }); - - if (!result.success) { - return badRequest(result.error.issues[0].message); - } - - const domain = normalizeOrgDomain(result.data.domain); - - if (!domain) { - return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); - } - - return { organization_id: result.data.organization_id, domain }; -} diff --git a/lib/organizations/validateRemoveOrgDomainRequest.ts b/lib/organizations/validateRemoveOrgDomainRequest.ts new file mode 100644 index 000000000..1b1146368 --- /dev/null +++ b/lib/organizations/validateRemoveOrgDomainRequest.ts @@ -0,0 +1,98 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; +import { normalizeOrgDomain } from "@/lib/organizations/normalizeOrgDomain"; +import { z } from "zod"; + +const removeOrgDomainQuerySchema = z.object({ + organization_id: z + .string({ message: "organization_id is required" }) + .uuid("organization_id must be a valid UUID"), + domain: z.string({ message: "domain is required" }), +}); + +export interface RemoveOrgDomainQuery { + organization_id: string; + /** The normalized bare email domain (lowercase, no "@") */ + domain: string; +} + +export interface RemoveOrgDomainRequestData { + /** The authenticated caller's account ID */ + callerAccountId: string; + /** The validated query parameters */ + query: RemoveOrgDomainQuery; +} + +function badRequest(message: string): NextResponse { + return NextResponse.json( + { status: "error", message }, + { status: 400, headers: getCorsHeaders() }, + ); +} + +/** + * Validates DELETE /api/organizations/domains requests. + * Handles authentication (x-api-key or Authorization bearer token), + * query validation, and the caller's access to manage the organization. + * Normalizes the domain (lowercase, trimmed, leading "@" stripped) and + * rejects strings that are not a plausible bare domain. + * + * Query parameters: + * - organization_id (required): The organization's account ID + * - domain (required): The email domain to unmap (e.g. "seekermusic.com") + * + * The caller must be a member of the organization or a Recoup admin. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error (400/401/403) if validation fails, + * or the caller account ID and validated query. + */ +export async function validateRemoveOrgDomainRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const searchParams = request.nextUrl.searchParams; + const result = removeOrgDomainQuerySchema.safeParse({ + organization_id: searchParams.get("organization_id") ?? undefined, + domain: searchParams.get("domain") ?? undefined, + }); + + if (!result.success) { + return badRequest(result.error.issues[0].message); + } + + const domain = normalizeOrgDomain(result.data.domain); + if (!domain) { + return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organization_id, + }); + + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organization_id", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + callerAccountId: authResult.accountId, + query: { organization_id: result.data.organization_id, domain }, + }; +} diff --git a/lib/organizations/validateRemoveOrgMemberRequest.ts b/lib/organizations/validateRemoveOrgMemberRequest.ts index 5bfeb3dca..2a89dd66d 100644 --- a/lib/organizations/validateRemoveOrgMemberRequest.ts +++ b/lib/organizations/validateRemoveOrgMemberRequest.ts @@ -2,7 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; const removeOrgMemberQuerySchema = z.object({ @@ -66,7 +66,7 @@ export async function validateRemoveOrgMemberRequest( ); } - const hasAccess = await canManageOrgMembers({ + const hasAccess = await canManageOrganization({ accountId: authResult.accountId, organizationId: result.data.organization_id, }); From b10879e9067148335b1af2e88d571be050ff8dc6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 3 Jul 2026 14:03:38 -0500 Subject: [PATCH 3/3] refactor(organizations): converge members+domains errors on shared errorResponse envelope - Replace hand-rolled { status: "error", message } bodies with the shared lib/networking/errorResponse helper across the organizations members and domains validators + handlers, so the whole surface emits one { status: "error", error } envelope (matching validateAuthContext 401s). Error body key changes from "message" to "error" on these endpoints; status codes and success shapes are unchanged. - selectOrganizationDomain now throws on Supabase query errors instead of returning null, so addOrgDomainHandler can no longer mistake a transient failure for "domain not mapped" and double-map a domain; the handler's try/catch turns the throw into the hardened 500 (new test covers it). Co-Authored-By: Claude Fable 5 --- .../__tests__/addOrgDomainHandler.test.ts | 25 +++++++++++--- .../__tests__/addOrgMemberHandler.test.ts | 8 ++--- .../__tests__/getOrgDomainsHandler.test.ts | 8 ++--- .../__tests__/removeOrgDomainHandler.test.ts | 8 ++--- .../__tests__/removeOrgMemberHandler.test.ts | 6 ++-- .../validateAddOrgDomainRequest.test.ts | 14 ++++---- .../validateAddOrgMemberRequest.test.ts | 16 ++++----- .../validateGetOrgDomainsRequest.test.ts | 6 ++-- .../validateRemoveOrgDomainRequest.test.ts | 8 ++--- .../validateRemoveOrgMemberRequest.test.ts | 10 +++--- lib/organizations/addOrgDomainHandler.ts | 23 ++++--------- lib/organizations/addOrgMemberHandler.ts | 34 +++---------------- lib/organizations/getOrgDomainsHandler.ts | 14 ++------ lib/organizations/removeOrgDomainHandler.ts | 14 ++------ lib/organizations/removeOrgMemberHandler.ts | 23 ++----------- .../validateAddOrgDomainRequest.ts | 24 +++---------- .../validateAddOrgMemberRequest.ts | 25 ++------------ .../validateGetOrgDomainsRequest.ts | 24 ++----------- .../validateRemoveOrgDomainRequest.ts | 24 +++---------- .../validateRemoveOrgMemberRequest.ts | 25 ++------------ .../selectOrganizationDomain.test.ts | 8 ++--- .../selectOrganizationDomain.ts | 6 ++-- 22 files changed, 106 insertions(+), 247 deletions(-) diff --git a/lib/organizations/__tests__/addOrgDomainHandler.test.ts b/lib/organizations/__tests__/addOrgDomainHandler.test.ts index b4ff4296a..2bc3601cc 100644 --- a/lib/organizations/__tests__/addOrgDomainHandler.test.ts +++ b/lib/organizations/__tests__/addOrgDomainHandler.test.ts @@ -120,7 +120,7 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -134,7 +134,7 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(403); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); expect(insertOrganizationDomain).not.toHaveBeenCalled(); }); @@ -154,7 +154,7 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(409); expect(body.status).toBe("error"); - expect(body.message).toContain("already mapped"); + expect(body.error).toContain("already mapped"); expect(insertOrganizationDomain).not.toHaveBeenCalled(); }); @@ -168,7 +168,22 @@ describe("addOrgDomainHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); + }); + + it("returns 500 and does not insert when the existing-mapping lookup fails", async () => { + vi.mocked(selectOrganizationDomain).mockRejectedValue( + new Error("Failed to fetch organization_domain: boom"), + ); + + const response = await addOrgDomainHandler( + makeRequest({ organizationId: ORG_ID, domain: "seekermusic.com" }), + ); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ status: "error", error: "Internal server error" }); + expect(insertOrganizationDomain).not.toHaveBeenCalled(); }); it("returns a generic 500 without leaking exception details when a dependency throws", async () => { @@ -180,7 +195,7 @@ describe("addOrgDomainHandler", () => { const body = await response.json(); expect(response.status).toBe(500); - expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(body).toEqual({ status: "error", error: "Internal server error" }); expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); diff --git a/lib/organizations/__tests__/addOrgMemberHandler.test.ts b/lib/organizations/__tests__/addOrgMemberHandler.test.ts index a2420af15..7aa5d55fd 100644 --- a/lib/organizations/__tests__/addOrgMemberHandler.test.ts +++ b/lib/organizations/__tests__/addOrgMemberHandler.test.ts @@ -133,7 +133,7 @@ describe("addOrgMemberHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns 400 when the body is not valid JSON", async () => { @@ -157,7 +157,7 @@ describe("addOrgMemberHandler", () => { expect(response.status).toBe(403); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); expect(canManageOrganization).toHaveBeenCalledWith({ accountId: "caller-1", organizationId: ORG_ID, @@ -186,7 +186,7 @@ describe("addOrgMemberHandler", () => { expect(response.status).toBe(500); const body = await response.json(); expect(body.status).toBe("error"); - expect(body.message).toBe("Failed to add member to organization"); + expect(body.error).toBe("Failed to add member to organization"); }); it("returns a generic 500 without leaking exception details when a dependency throws", async () => { @@ -198,7 +198,7 @@ describe("addOrgMemberHandler", () => { expect(response.status).toBe(500); const body = await response.json(); - expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(body).toEqual({ status: "error", error: "Internal server error" }); expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); diff --git a/lib/organizations/__tests__/getOrgDomainsHandler.test.ts b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts index 1c16eac8b..39e2df6b5 100644 --- a/lib/organizations/__tests__/getOrgDomainsHandler.test.ts +++ b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts @@ -89,7 +89,7 @@ describe("getOrgDomainsHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -101,7 +101,7 @@ describe("getOrgDomainsHandler", () => { expect(response.status).toBe(403); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); expect(selectOrganizationDomains).not.toHaveBeenCalled(); }); @@ -114,7 +114,7 @@ describe("getOrgDomainsHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns a generic 500 without leaking exception details when a dependency throws", async () => { @@ -124,7 +124,7 @@ describe("getOrgDomainsHandler", () => { const body = await response.json(); expect(response.status).toBe(500); - expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(body).toEqual({ status: "error", error: "Internal server error" }); expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); diff --git a/lib/organizations/__tests__/removeOrgDomainHandler.test.ts b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts index bc89242a0..4b12e904c 100644 --- a/lib/organizations/__tests__/removeOrgDomainHandler.test.ts +++ b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts @@ -69,7 +69,7 @@ describe("removeOrgDomainHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -81,7 +81,7 @@ describe("removeOrgDomainHandler", () => { expect(response.status).toBe(403); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); expect(deleteOrganizationDomain).not.toHaveBeenCalled(); }); @@ -94,7 +94,7 @@ describe("removeOrgDomainHandler", () => { expect(response.status).toBe(500); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns a generic 500 without leaking exception details when a dependency throws", async () => { @@ -104,7 +104,7 @@ describe("removeOrgDomainHandler", () => { const body = await response.json(); expect(response.status).toBe(500); - expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(body).toEqual({ status: "error", error: "Internal server error" }); expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); diff --git a/lib/organizations/__tests__/removeOrgMemberHandler.test.ts b/lib/organizations/__tests__/removeOrgMemberHandler.test.ts index 48ac4221f..e6cb6b87d 100644 --- a/lib/organizations/__tests__/removeOrgMemberHandler.test.ts +++ b/lib/organizations/__tests__/removeOrgMemberHandler.test.ts @@ -74,7 +74,7 @@ describe("removeOrgMemberHandler", () => { expect(response.status).toBe(400); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); }); it("returns 403 when the caller cannot manage the organization", async () => { @@ -104,7 +104,7 @@ describe("removeOrgMemberHandler", () => { expect(response.status).toBe(500); const body = await response.json(); expect(body.status).toBe("error"); - expect(body.message).toBe("Failed to remove member from organization"); + expect(body.error).toBe("Failed to remove member from organization"); }); it("returns a generic 500 without leaking exception details when a dependency throws", async () => { @@ -116,7 +116,7 @@ describe("removeOrgMemberHandler", () => { expect(response.status).toBe(500); const body = await response.json(); - expect(body).toEqual({ status: "error", message: "Internal server error" }); + expect(body).toEqual({ status: "error", error: "Internal server error" }); expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); }); }); diff --git a/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts b/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts index fad127368..1f073bc4a 100644 --- a/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts +++ b/lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts @@ -76,7 +76,7 @@ describe("validateAddOrgDomainRequest", () => { const body = await result.json(); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); } }); @@ -94,7 +94,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -106,7 +106,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("organizationId"); + expect(body.error).toContain("organizationId"); } }); @@ -120,7 +120,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -132,7 +132,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("domain"); + expect(body.error).toContain("domain"); } }); @@ -146,7 +146,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("domain"); + expect(body.error).toContain("domain"); } expect(canManageOrganization).not.toHaveBeenCalled(); }); @@ -161,7 +161,7 @@ describe("validateAddOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); }); diff --git a/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts index b5d61ead4..8ed982bee 100644 --- a/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts +++ b/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts @@ -90,7 +90,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(403); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); }); @@ -104,7 +104,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -118,7 +118,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -132,7 +132,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -146,7 +146,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -163,7 +163,7 @@ describe("validateAddOrgMemberRequest", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); const body = await result.json(); - expect(body.message).toContain("exactly one"); + expect(body.error).toContain("exactly one"); } }); @@ -174,7 +174,7 @@ describe("validateAddOrgMemberRequest", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); const body = await result.json(); - expect(body.message).toContain("exactly one"); + expect(body.error).toContain("exactly one"); } }); @@ -186,7 +186,7 @@ describe("validateAddOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } expect(canManageOrganization).not.toHaveBeenCalled(); }); diff --git a/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts b/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts index 8d9fa9c04..669c34f88 100644 --- a/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts +++ b/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts @@ -67,7 +67,7 @@ describe("validateGetOrgDomainsRequest", () => { const body = await result.json(); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); } }); @@ -80,7 +80,7 @@ describe("validateGetOrgDomainsRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("organization_id"); + expect(body.error).toContain("organization_id"); } expect(canManageOrganization).not.toHaveBeenCalled(); }); @@ -93,7 +93,7 @@ describe("validateGetOrgDomainsRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); }); diff --git a/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts index 468aebbd6..127e2f34f 100644 --- a/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts +++ b/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts @@ -75,7 +75,7 @@ describe("validateRemoveOrgDomainRequest", () => { const body = await result.json(); expect(body).toEqual({ status: "error", - message: "Access denied to specified organization_id", + error: "Access denied to specified organization_id", }); } }); @@ -88,7 +88,7 @@ describe("validateRemoveOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("organization_id"); + expect(body.error).toContain("organization_id"); } }); @@ -100,7 +100,7 @@ describe("validateRemoveOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(body.message).toContain("domain"); + expect(body.error).toContain("domain"); } }); @@ -114,7 +114,7 @@ describe("validateRemoveOrgDomainRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } expect(canManageOrganization).not.toHaveBeenCalled(); }); diff --git a/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts index f12778830..c59a3f1b0 100644 --- a/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts +++ b/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts @@ -75,7 +75,7 @@ describe("validateRemoveOrgMemberRequest", () => { expect(result.status).toBe(403); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -87,7 +87,7 @@ describe("validateRemoveOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -99,7 +99,7 @@ describe("validateRemoveOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -113,7 +113,7 @@ describe("validateRemoveOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); @@ -127,7 +127,7 @@ describe("validateRemoveOrgMemberRequest", () => { expect(result.status).toBe(400); const body = await result.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); + expect(typeof body.error).toBe("string"); } }); }); diff --git a/lib/organizations/addOrgDomainHandler.ts b/lib/organizations/addOrgDomainHandler.ts index 101a5cea6..8bd8200ee 100644 --- a/lib/organizations/addOrgDomainHandler.ts +++ b/lib/organizations/addOrgDomainHandler.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAddOrgDomainRequest } from "@/lib/organizations/validateAddOrgDomainRequest"; import { selectOrganizationDomain } from "@/lib/supabase/organization_domains/selectOrganizationDomain"; import { insertOrganizationDomain } from "@/lib/supabase/organization_domains/insertOrganizationDomain"; @@ -29,22 +30,16 @@ export async function addOrgDomainHandler(request: NextRequest): Promise null); const result = addOrgDomainBodySchema.safeParse(rawBody); if (!result.success) { - return badRequest(result.error.issues[0].message); + return errorResponse(result.error.issues[0].message, 400); } const domain = normalizeOrgDomain(result.data.domain); if (!domain) { - return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + return errorResponse('domain must be a bare email domain (e.g. "seekermusic.com")', 400); } const hasAccess = await canManageOrganization({ @@ -75,16 +68,7 @@ export async function validateAddOrgDomainRequest( }); if (!hasAccess) { - return NextResponse.json( - { - status: "error", - message: "Access denied to specified organization_id", - }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); + return errorResponse("Access denied to specified organization_id", 403); } return { diff --git a/lib/organizations/validateAddOrgMemberRequest.ts b/lib/organizations/validateAddOrgMemberRequest.ts index 189cb6d4d..3a9bcd562 100644 --- a/lib/organizations/validateAddOrgMemberRequest.ts +++ b/lib/organizations/validateAddOrgMemberRequest.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; @@ -52,17 +52,7 @@ export async function validateAddOrgMemberRequest( const rawBody = await request.json().catch(() => null); const result = addOrgMemberBodySchema.safeParse(rawBody); if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - message: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); + return errorResponse(result.error.issues[0].message, 400); } const hasAccess = await canManageOrganization({ @@ -71,16 +61,7 @@ export async function validateAddOrgMemberRequest( }); if (!hasAccess) { - return NextResponse.json( - { - status: "error", - message: "Caller is not a member of the organization", - }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); + return errorResponse("Caller is not a member of the organization", 403); } return { diff --git a/lib/organizations/validateGetOrgDomainsRequest.ts b/lib/organizations/validateGetOrgDomainsRequest.ts index 2fdba6ccd..146a1a528 100644 --- a/lib/organizations/validateGetOrgDomainsRequest.ts +++ b/lib/organizations/validateGetOrgDomainsRequest.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; @@ -48,16 +48,7 @@ export async function validateGetOrgDomainsRequest( }); if (!result.success) { - return NextResponse.json( - { - status: "error", - message: result.error.issues[0].message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); + return errorResponse(result.error.issues[0].message, 400); } const hasAccess = await canManageOrganization({ @@ -66,16 +57,7 @@ export async function validateGetOrgDomainsRequest( }); if (!hasAccess) { - return NextResponse.json( - { - status: "error", - message: "Access denied to specified organization_id", - }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); + return errorResponse("Access denied to specified organization_id", 403); } return { diff --git a/lib/organizations/validateRemoveOrgDomainRequest.ts b/lib/organizations/validateRemoveOrgDomainRequest.ts index 1b1146368..49f2d7c15 100644 --- a/lib/organizations/validateRemoveOrgDomainRequest.ts +++ b/lib/organizations/validateRemoveOrgDomainRequest.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { normalizeOrgDomain } from "@/lib/organizations/normalizeOrgDomain"; @@ -26,13 +26,6 @@ export interface RemoveOrgDomainRequestData { query: RemoveOrgDomainQuery; } -function badRequest(message: string): NextResponse { - return NextResponse.json( - { status: "error", message }, - { status: 400, headers: getCorsHeaders() }, - ); -} - /** * Validates DELETE /api/organizations/domains requests. * Handles authentication (x-api-key or Authorization bearer token), @@ -65,12 +58,12 @@ export async function validateRemoveOrgDomainRequest( }); if (!result.success) { - return badRequest(result.error.issues[0].message); + return errorResponse(result.error.issues[0].message, 400); } const domain = normalizeOrgDomain(result.data.domain); if (!domain) { - return badRequest('domain must be a bare email domain (e.g. "seekermusic.com")'); + return errorResponse('domain must be a bare email domain (e.g. "seekermusic.com")', 400); } const hasAccess = await canManageOrganization({ @@ -79,16 +72,7 @@ export async function validateRemoveOrgDomainRequest( }); if (!hasAccess) { - return NextResponse.json( - { - status: "error", - message: "Access denied to specified organization_id", - }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); + return errorResponse("Access denied to specified organization_id", 403); } return { diff --git a/lib/organizations/validateRemoveOrgMemberRequest.ts b/lib/organizations/validateRemoveOrgMemberRequest.ts index 2a89dd66d..0b2e6b400 100644 --- a/lib/organizations/validateRemoveOrgMemberRequest.ts +++ b/lib/organizations/validateRemoveOrgMemberRequest.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; @@ -53,17 +53,7 @@ export async function validateRemoveOrgMemberRequest( }); if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - message: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); + return errorResponse(result.error.issues[0].message, 400); } const hasAccess = await canManageOrganization({ @@ -72,16 +62,7 @@ export async function validateRemoveOrgMemberRequest( }); if (!hasAccess) { - return NextResponse.json( - { - status: "error", - message: "Caller is not a member of the organization", - }, - { - status: 403, - headers: getCorsHeaders(), - }, - ); + return errorResponse("Caller is not a member of the organization", 403); } return { diff --git a/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts b/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts index 64b57b1f7..01a77ba9f 100644 --- a/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts +++ b/lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts @@ -47,7 +47,7 @@ describe("selectOrganizationDomain", () => { expect(result).toBeNull(); }); - it("returns null on query error", async () => { + it("throws on query error so callers cannot mistake a failed query for 'not mapped'", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const maybeSingleFn = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); const eqFn = vi.fn().mockReturnValue({ maybeSingle: maybeSingleFn }); @@ -55,9 +55,9 @@ describe("selectOrganizationDomain", () => { select: vi.fn().mockReturnValue({ eq: eqFn }), } as never); - const result = await selectOrganizationDomain("seekermusic.com"); - - expect(result).toBeNull(); + await expect(selectOrganizationDomain("seekermusic.com")).rejects.toThrow( + "Failed to fetch organization_domain", + ); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); diff --git a/lib/supabase/organization_domains/selectOrganizationDomain.ts b/lib/supabase/organization_domains/selectOrganizationDomain.ts index 20aa57af7..fa73af0d8 100644 --- a/lib/supabase/organization_domains/selectOrganizationDomain.ts +++ b/lib/supabase/organization_domains/selectOrganizationDomain.ts @@ -6,7 +6,9 @@ import type { Tables } from "@/types/database.types"; * A domain can be mapped to at most one organization. * * @param domain - The normalized email domain (e.g. "seekermusic.com") - * @returns The domain mapping row if found, null otherwise + * @returns The domain mapping row if found, null if no row exists + * @throws If the query fails, so callers never mistake a transient + * Supabase error for "domain not mapped" */ export async function selectOrganizationDomain( domain: string, @@ -19,7 +21,7 @@ export async function selectOrganizationDomain( if (error) { console.error("Error fetching organization_domain:", error); - return null; + throw new Error(`Failed to fetch organization_domain: ${error.message}`); } return data;