diff --git a/apps/web/app/api/link/__tests__/route.test.ts b/apps/web/app/api/link/__tests__/route.test.ts new file mode 100644 index 0000000000..9e3f3b309f --- /dev/null +++ b/apps/web/app/api/link/__tests__/route.test.ts @@ -0,0 +1,208 @@ +import type { NextRequest } from "next/server"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("app/api/defaultResponderForAppDir", () => ({ + defaultResponderForAppDir: + (handler: (req: NextRequest) => Promise) => + (req: NextRequest, _context: { params: Promise> }) => + handler(req), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), + cookies: vi.fn().mockResolvedValue({ getAll: () => [] }), +})); + +vi.mock("next/server", () => ({ + NextResponse: { + redirect: vi.fn((url: string | URL, init?: { status?: number }) => { + const location = typeof url === "string" ? url : url.toString(); + return { + status: init?.status ?? 302, + headers: { + get: (name: string) => (name.toLowerCase() === "location" ? location : null), + }, + } as unknown as Response; + }), + }, +})); + +vi.mock("@calcom/lib/crypto", () => ({ + symmetricDecrypt: vi.fn().mockReturnValue( + JSON.stringify({ + bookingUid: "test-booking-uid", + userId: 1, + }) + ), +})); + +vi.mock("@calcom/prisma", () => ({ + default: { + booking: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: 1, + uid: "test-booking-uid", + recurringEventId: null, + }), + }, + user: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: 1, + locale: "en", + }), + }, + }, +})); + +vi.mock("@calcom/trpc/server/createContext", () => ({ + createContext: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({ + bookingsRouter: {}, +})); + +vi.mock("@calcom/trpc/server/trpc", () => ({ + createCallerFactory: vi.fn().mockReturnValue(() => ({ + confirm: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock("@lib/buildLegacyCtx", () => ({ + buildLegacyRequest: vi.fn().mockReturnValue({}), +})); + +// Import after mocks are set up +import { GET } from "../route"; + +const createMockRequest = (url: string): NextRequest => { + const urlObj = new URL(url); + return { + method: "GET", + url, + nextUrl: { + searchParams: urlObj.searchParams, + }, + } as unknown as NextRequest; +}; + +describe("link route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("GET handler - redirect URL construction", () => { + it("should redirect to booking page with the same origin as the request", async () => { + const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token"; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); + }); + + it("should preserve custom domain origin in redirect URL", async () => { + const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token"; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://custom-domain.company.com"); + expect(location).not.toContain("localhost"); + }); + + it("should preserve self-hosted domain origin in redirect URL", async () => { + const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token"; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://calcom.internal.company.net"); + expect(location).not.toContain("localhost"); + }); + + it("should construct redirect URLs relative to the request URL for various origins", async () => { + const testOrigins = [ + "https://app.cal.com", + "https://acme.cal.com", + "https://calcom.company.internal", + "http://192.168.1.100:3000", + ]; + + for (const origin of testOrigins) { + vi.clearAllMocks(); + const baseUrl = `${origin}/api/link?action=accept&token=encrypted-token`; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe(origin); + expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); + } + }); + }); + + describe("GET handler - error handling", () => { + it("should redirect with error message when TRPC throws an error", async () => { + const { TRPCError } = await import("@trpc/server"); + + // Mock createCallerFactory to throw a TRPCError + const { createCallerFactory } = await import("@calcom/trpc/server/trpc"); + vi.mocked(createCallerFactory).mockReturnValue(() => ({ + confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })), + })); + + const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token"; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/test-booking-uid"); + expect(redirectUrl.searchParams.get("error")).toBe("Custom error"); + }); + + it("should preserve origin in error redirect URL", async () => { + const { TRPCError } = await import("@trpc/server"); + + // Mock createCallerFactory to throw a TRPCError + const { createCallerFactory } = await import("@calcom/trpc/server/trpc"); + vi.mocked(createCallerFactory).mockReturnValue(() => ({ + confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })), + })); + + const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token"; + const req = createMockRequest(baseUrl); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://self-hosted.company.org"); + expect(location).not.toContain("localhost"); + }); + }); +}); diff --git a/apps/web/app/api/link/route.ts b/apps/web/app/api/link/route.ts index 5dc486e0c4..d105ee3b28 100644 --- a/apps/web/app/api/link/route.ts +++ b/apps/web/app/api/link/route.ts @@ -59,7 +59,6 @@ const createSessionGetter = (userId: number) => async () => { }; async function handler(request: NextRequest) { - const url = new URL(request.url); const searchParams = request.nextUrl.searchParams; const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries())); @@ -120,10 +119,10 @@ async function handler(request: NextRequest) { } catch (e) { let message = "Error confirming booking"; if (e instanceof TRPCError) message = (e as TRPCError).message; - return NextResponse.redirect(`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`); + return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)); } - return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`); + return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url)); } export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/verify-booking-token/__tests__/route.test.ts b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts new file mode 100644 index 0000000000..d97d34caf5 --- /dev/null +++ b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts @@ -0,0 +1,222 @@ +import type { NextRequest } from "next/server"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("app/api/defaultResponderForAppDir", () => ({ + defaultResponderForAppDir: + (handler: (req: NextRequest) => Promise) => + (req: NextRequest, _context: { params: Promise> }) => + handler(req), +})); + +vi.mock("app/api/parseRequestData", () => ({ + parseRequestData: vi.fn().mockResolvedValue({}), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), + cookies: vi.fn().mockResolvedValue({ getAll: () => [] }), +})); + +vi.mock("next/server", () => ({ + NextResponse: { + redirect: vi.fn((url: string | URL, init?: { status?: number }) => { + const location = typeof url === "string" ? url : url.toString(); + return { + status: init?.status ?? 302, + headers: { + get: (name: string) => (name.toLowerCase() === "location" ? location : null), + }, + } as unknown as Response; + }), + }, +})); + +vi.mock("@calcom/prisma", () => ({ + default: { + booking: { + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + findUniqueOrThrow: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/trpc/server/createContext", () => ({ + createContext: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({ + bookingsRouter: {}, +})); + +vi.mock("@calcom/trpc/server/trpc", () => ({ + createCallerFactory: vi.fn().mockReturnValue(() => ({ + confirm: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock("@lib/buildLegacyCtx", () => ({ + buildLegacyRequest: vi.fn().mockReturnValue({}), +})); + +// Import after mocks are set up +import { GET, POST } from "../route"; + +const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => { + const urlObj = new URL(url); + return { + method, + url, + nextUrl: { + searchParams: urlObj.searchParams, + }, + } as unknown as NextRequest; +}; + +describe("verify-booking-token route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("GET handler", () => { + it("should redirect to booking page with error when action is reject (requires POST)", async () => { + const baseUrl = + "https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42"; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/abc123"); + expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method"); + }); + + it("should redirect with error when query params are invalid (missing action)", async () => { + const baseUrl = + "https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42"; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/abc123"); + expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); + }); + + it("should redirect with error when query params are invalid (missing token)", async () => { + const baseUrl = + "https://app.example.com/api/verify-booking-token?action=accept&bookingUid=abc123&userId=42"; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/abc123"); + expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); + }); + + it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => { + const baseUrl = + "https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1"; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://custom-domain.example.org"); + expect(location).not.toContain("localhost"); + }); + + it("should handle empty bookingUid in error case", async () => { + const baseUrl = "https://app.example.com/api/verify-booking-token?token=test-token&userId=42"; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.pathname).toBe("/booking/"); + expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); + }); + }); + + describe("POST handler", () => { + it("should redirect with status 303 when query params are invalid", async () => { + const baseUrl = + "https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42"; + const req = createMockRequest(baseUrl, "POST"); + + const res = await POST(req, { params: Promise.resolve({}) }); + + expect(res.status).toBe(303); + + const location = res.headers.get("location"); + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://app.example.com"); + expect(redirectUrl.pathname).toBe("/booking/abc123"); + expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking"); + }); + + it("should preserve the request origin in POST redirect URL", async () => { + const baseUrl = + "https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1"; + const req = createMockRequest(baseUrl, "POST"); + + const res = await POST(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe("https://self-hosted.company.com"); + expect(location).not.toContain("localhost"); + }); + }); + + describe("redirect URL construction", () => { + it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => { + const testOrigins = [ + "https://app.cal.com", + "https://acme.cal.com", + "https://calcom.company.internal", + "http://192.168.1.100:3000", + ]; + + for (const origin of testOrigins) { + vi.clearAllMocks(); + const baseUrl = `${origin}/api/verify-booking-token?action=reject&token=t&bookingUid=test-uid&userId=1`; + const req = createMockRequest(baseUrl, "GET"); + + const res = await GET(req, { params: Promise.resolve({}) }); + const location = res.headers.get("location"); + + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + + expect(redirectUrl.origin).toBe(origin); + expect(redirectUrl.pathname).toBe("/booking/test-uid"); + } + }); + }); +}); diff --git a/apps/web/app/api/verify-booking-token/route.ts b/apps/web/app/api/verify-booking-token/route.ts index 50507db0ea..642b4fc00e 100644 --- a/apps/web/app/api/verify-booking-token/route.ts +++ b/apps/web/app/api/verify-booking-token/route.ts @@ -29,7 +29,6 @@ const querySchema = z.object({ }); async function getHandler(request: NextRequest) { - const url = new URL(request.url); const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries()); try { @@ -38,21 +37,20 @@ async function getHandler(request: NextRequest) { if (action === DirectAction.REJECT) { // Rejections should use POST method return NextResponse.redirect( - `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}` + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url) ); } return await handleBookingAction(action, token, bookingUid, userId, request, undefined); - } catch (error) { + } catch { const bookingUid = queryParams.bookingUid || ""; return NextResponse.redirect( - `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}` + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url) ); } } async function postHandler(request: NextRequest) { - const url = new URL(request.url); const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries()); try { @@ -61,10 +59,10 @@ async function postHandler(request: NextRequest) { const { reason } = z.object({ reason: z.string().optional() }).parse(body || {}); return await handleBookingAction(action, token, bookingUid, userId, request, reason); - } catch (error) { + } catch { const bookingUid = queryParams.bookingUid || ""; return NextResponse.redirect( - `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url), { status: 303 } ); } @@ -78,14 +76,13 @@ async function handleBookingAction( request: NextRequest, reason?: string ) { - const url = new URL(request.url); const booking = await prisma.booking.findUnique({ where: { oneTimePassword: token }, }); if (!booking) { return NextResponse.redirect( - `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, + new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url), { status: 303 } ); } @@ -143,7 +140,7 @@ async function handleBookingAction( let message = "Error confirming booking"; if (e instanceof TRPCError) message = (e as TRPCError).message; return NextResponse.redirect( - `${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`, + new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url), { status: 303 } ); } @@ -153,7 +150,7 @@ async function handleBookingAction( data: { oneTimePassword: null }, }); - return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 }); + return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 }); } export const GET = defaultResponderForAppDir(getHandler);