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..2bc3601cc --- /dev/null +++ b/lib/organizations/__tests__/addOrgDomainHandler.test.ts @@ -0,0 +1,202 @@ +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 "@/lib/organizations/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("@/lib/organizations/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"); + expect(typeof body.error).toBe("string"); + }); + + 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", + error: "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.error).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"); + 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 () => { + 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", 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 2a28c5876..7aa5d55fd 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 () => { @@ -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 () => { @@ -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 }), @@ -157,8 +157,8 @@ describe("addOrgMemberHandler", () => { expect(response.status).toBe(403); const body = await response.json(); expect(body.status).toBe("error"); - expect(typeof body.message).toBe("string"); - expect(canManageOrgMembers).toHaveBeenCalledWith({ + 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__/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__/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..39e2df6b5 --- /dev/null +++ b/lib/organizations/__tests__/getOrgDomainsHandler.test.ts @@ -0,0 +1,131 @@ +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 "@/lib/organizations/canManageOrganization"; +import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/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"); + expect(typeof body.error).toBe("string"); + }); + + 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", + error: "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"); + expect(typeof body.error).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", error: "Internal server error" }); + expect(JSON.stringify(body)).not.toContain("SECRET_DB_DETAIL"); + }); + }); +}); 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..4b12e904c --- /dev/null +++ b/lib/organizations/__tests__/removeOrgDomainHandler.test.ts @@ -0,0 +1,111 @@ +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 "@/lib/organizations/canManageOrganization"; +import { deleteOrganizationDomain } from "@/lib/supabase/organization_domains/deleteOrganizationDomain"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/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"); + expect(typeof body.error).toBe("string"); + }); + + 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", + error: "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"); + expect(typeof body.error).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", 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 b41c0cb57..e6cb6b87d 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); }); @@ -74,11 +74,11 @@ 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 () => { - 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, }); @@ -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 new file mode 100644 index 000000000..1f073bc4a --- /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", + error: "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.error).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.error).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.error).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.error).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.error).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.error).toBe("string"); + } + }); +}); diff --git a/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts index 85f706637..8ed982bee 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 }), @@ -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,9 +186,9 @@ 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(canManageOrgMembers).not.toHaveBeenCalled(); + expect(canManageOrganization).not.toHaveBeenCalled(); }); }); }); diff --git a/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts b/lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts new file mode 100644 index 000000000..669c34f88 --- /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", + error: "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.error).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.error).toBe("string"); + } + }); +}); diff --git a/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts new file mode 100644 index 000000000..127e2f34f --- /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", + error: "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.error).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.error).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.error).toBe("string"); + } + expect(canManageOrganization).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts b/lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts index 4a074beff..c59a3f1b0 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}`), @@ -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 new file mode 100644 index 000000000..8bd8200ee --- /dev/null +++ b/lib/organizations/addOrgDomainHandler.ts @@ -0,0 +1,58 @@ +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"; + +/** + * 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. + * + * 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 validated = await validateAddOrgDomainRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { body } = validated; + + const existing = await selectOrganizationDomain(body.domain); + + if (existing && existing.organization_id !== body.organizationId) { + return errorResponse( + `Domain "${body.domain}" is already mapped to a different organization`, + 409, + ); + } + + const row = existing ?? (await insertOrganizationDomain(body)); + + if (!row) { + return errorResponse("Failed to map domain to organization", 500); + } + + 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 errorResponse("Internal server error", 500); + } +} diff --git a/lib/organizations/addOrgMemberHandler.ts b/lib/organizations/addOrgMemberHandler.ts index 1e9e51da6..2d1a6eb64 100644 --- a/lib/organizations/addOrgMemberHandler.ts +++ b/lib/organizations/addOrgMemberHandler.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateAddOrgMemberRequest } from "@/lib/organizations/validateAddOrgMemberRequest"; import { getOrCreateAccountByEmail } from "@/lib/accounts/getOrCreateAccountByEmail"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; @@ -30,16 +31,7 @@ export async function addOrgMemberHandler(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 new file mode 100644 index 000000000..28238bfaa --- /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 + * (e.g. its membership or 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..322f3ab6f --- /dev/null +++ b/lib/organizations/getOrgDomainsHandler.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateGetOrgDomainsRequest } from "@/lib/organizations/validateGetOrgDomainsRequest"; +import { selectOrganizationDomains } from "@/lib/supabase/organization_domains/selectOrganizationDomains"; + +/** + * Handler for listing the email domains mapped to an organization. + * + * 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 validated = await validateGetOrgDomainsRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const domains = await selectOrganizationDomains(validated.query.organization_id); + + if (domains === null) { + return errorResponse("Failed to fetch organization domains", 500); + } + + return NextResponse.json( + { status: "success", domains }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getOrgDomainsHandler:", error); + return errorResponse("Internal server error", 500); + } +} 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..b979af20f --- /dev/null +++ b/lib/organizations/removeOrgDomainHandler.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateRemoveOrgDomainRequest } from "@/lib/organizations/validateRemoveOrgDomainRequest"; +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. + * + * Auth, query validation (including domain normalization), and access + * checks live in validateRemoveOrgDomainRequest. This handler performs the + * idempotent delete and shapes the response. + * + * @param request - The request object + * @returns A NextResponse indicating success + */ +export async function removeOrgDomainHandler(request: NextRequest): Promise { + try { + const validated = await validateRemoveOrgDomainRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { query } = validated; + + const deleted = await deleteOrganizationDomain({ + domain: query.domain, + organizationId: query.organization_id, + }); + + if (!deleted) { + return errorResponse("Failed to remove domain mapping", 500); + } + + return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[ERROR] removeOrgDomainHandler:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/organizations/removeOrgMemberHandler.ts b/lib/organizations/removeOrgMemberHandler.ts index bf4b092b3..a465cc5b3 100644 --- a/lib/organizations/removeOrgMemberHandler.ts +++ b/lib/organizations/removeOrgMemberHandler.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateRemoveOrgMemberRequest } from "@/lib/organizations/validateRemoveOrgMemberRequest"; import { deleteAccountOrganization } from "@/lib/supabase/account_organization_ids/deleteAccountOrganization"; @@ -27,16 +28,7 @@ export async function removeOrgMemberHandler(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 errorResponse(result.error.issues[0].message, 400); + } + + const domain = normalizeOrgDomain(result.data.domain); + if (!domain) { + return errorResponse('domain must be a bare email domain (e.g. "seekermusic.com")', 400); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organizationId, + }); + + if (!hasAccess) { + return errorResponse("Access denied to specified organization_id", 403); + } + + return { + callerAccountId: authResult.accountId, + body: { organizationId: result.data.organizationId, domain }, + }; +} diff --git a/lib/organizations/validateAddOrgMemberRequest.ts b/lib/organizations/validateAddOrgMemberRequest.ts index 9c5be96d6..3a9bcd562 100644 --- a/lib/organizations/validateAddOrgMemberRequest.ts +++ b/lib/organizations/validateAddOrgMemberRequest.ts @@ -1,8 +1,8 @@ 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 { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; const addOrgMemberBodySchema = z @@ -52,35 +52,16 @@ 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 canManageOrgMembers({ + const hasAccess = await canManageOrganization({ accountId: authResult.accountId, organizationId: result.data.organizationId, }); 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 new file mode 100644 index 000000000..146a1a528 --- /dev/null +++ b/lib/organizations/validateGetOrgDomainsRequest.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +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 errorResponse(result.error.issues[0].message, 400); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organization_id, + }); + + if (!hasAccess) { + return errorResponse("Access denied to specified organization_id", 403); + } + + return { + callerAccountId: authResult.accountId, + query: result.data, + }; +} diff --git a/lib/organizations/validateRemoveOrgDomainRequest.ts b/lib/organizations/validateRemoveOrgDomainRequest.ts new file mode 100644 index 000000000..49f2d7c15 --- /dev/null +++ b/lib/organizations/validateRemoveOrgDomainRequest.ts @@ -0,0 +1,82 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +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; +} + +/** + * 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 errorResponse(result.error.issues[0].message, 400); + } + + const domain = normalizeOrgDomain(result.data.domain); + if (!domain) { + return errorResponse('domain must be a bare email domain (e.g. "seekermusic.com")', 400); + } + + const hasAccess = await canManageOrganization({ + accountId: authResult.accountId, + organizationId: result.data.organization_id, + }); + + if (!hasAccess) { + return errorResponse("Access denied to specified organization_id", 403); + } + + 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..0b2e6b400 100644 --- a/lib/organizations/validateRemoveOrgMemberRequest.ts +++ b/lib/organizations/validateRemoveOrgMemberRequest.ts @@ -1,8 +1,8 @@ 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 { canManageOrgMembers } from "@/lib/organizations/canManageOrgMembers"; +import { canManageOrganization } from "@/lib/organizations/canManageOrganization"; import { z } from "zod"; const removeOrgMemberQuerySchema = z.object({ @@ -53,35 +53,16 @@ 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 canManageOrgMembers({ + const hasAccess = await canManageOrganization({ accountId: authResult.accountId, organizationId: result.data.organization_id, }); 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__/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..01a77ba9f --- /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("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 }); + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ eq: eqFn }), + } as never); + + await expect(selectOrganizationDomain("seekermusic.com")).rejects.toThrow( + "Failed to fetch organization_domain", + ); + 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..fa73af0d8 --- /dev/null +++ b/lib/supabase/organization_domains/selectOrganizationDomain.ts @@ -0,0 +1,28 @@ +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 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, +): Promise | null> { + const { data, error } = await supabase + .from("organization_domains") + .select("*") + .eq("domain", domain) + .maybeSingle(); + + if (error) { + console.error("Error fetching organization_domain:", error); + throw new Error(`Failed to fetch organization_domain: ${error.message}`); + } + + 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 || []; +}