2ad24048b6
* fix: use relative redirects in booking confirmation routes to fix localhost redirect issue When Cal.com runs behind a reverse proxy, request.url may return the internal server URL (localhost:3000) instead of the external URL. This causes users to be redirected to localhost after confirming bookings via email links. This fix changes the redirect URLs from using url.origin (which could be localhost) to using relative URLs via new URL(path, request.url). This ensures the browser resolves the redirect against whatever domain the user actually requested, fixing the issue for self-hosted deployments behind proxies. Fixes #20358 Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * test: add tests for booking confirmation redirect URL construction Add unit tests for verify-booking-token and link API routes to ensure: - Redirect URLs preserve the request origin (not hardcoded localhost) - Redirect URLs use the correct booking path (/booking/{uid}) - Error messages are properly encoded in query params - POST handler returns correct 303 status code These tests verify the fix for #20358 where users were redirected to localhost:3000 instead of the proper production URL after confirming bookings via email links. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: add second argument to GET/POST calls in test files to fix type errors Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
|
defaultResponderForAppDir:
|
|
(handler: (req: NextRequest) => Promise<Response>) =>
|
|
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
|
|
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");
|
|
});
|
|
});
|
|
});
|