Files
calendar/apps/web/app/api/link/__tests__/route.test.ts
T
Volnei MunhozGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2ad24048b6 fix: use relative redirects in booking confirmation routes to fix localhost redirect issue (#25794)
* 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>
2025-12-12 19:02:54 +05:30

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");
});
});
});