diff --git a/packages/features/auth/__mocks__/getServerSession.mocks.ts b/packages/features/auth/__mocks__/getServerSession.mocks.ts new file mode 100644 index 0000000000..754cbeb88f --- /dev/null +++ b/packages/features/auth/__mocks__/getServerSession.mocks.ts @@ -0,0 +1,157 @@ +import type { Mock } from "vitest"; +import { vi } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; + +import type { PrismaClient } from "@calcom/prisma"; +import type { User } from "@calcom/prisma/client"; + +// Types +type LoggerInstance = { + warn: Mock; + error: Mock; + debug: Mock; +}; + +type LoggerMock = { + default: { + getSubLogger: () => LoggerInstance; + }; +}; + +// JWT token structure used by NextAuth +interface MockToken { + sub: string; + email: string; + name: string; + exp: number; + belongsToActiveTeam: boolean; + org: null; + orgAwareUsername: string | null; + profileId: number | null; + upId: string; + impersonatedBy?: { id: number }; +} + +// Prisma mock instance +const prismaMock: DeepMockProxy = mockDeep(); + +function resetPrismaMock(): void { + mockReset(prismaMock); +} + +function createLoggerMock(): LoggerMock { + const loggerInstance: LoggerInstance = { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { + default: { + getSubLogger: (): LoggerInstance => loggerInstance, + }, + }; +} + +function createPrismaMock(): { default: DeepMockProxy } { + return { default: prismaMock }; +} + +function createLicenseKeyMock(): { + LicenseKeySingleton: { getInstance: Mock }; +} { + return { + LicenseKeySingleton: { + getInstance: vi.fn().mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(false), + }), + }, + }; +} + +function createDeploymentRepositoryMock(): { + DeploymentRepository: new (_prisma: PrismaClient) => object; +} { + return { + DeploymentRepository: class MockDeploymentRepository {}, + }; +} + +function createUserRepositoryMock(): { + UserRepository: new (_prisma: PrismaClient) => { + enrichUserWithTheProfile: (params: { user: User }) => Promise; + }; +} { + return { + UserRepository: class MockUserRepository { + enrichUserWithTheProfile({ user }: { user: User }): Promise { + return Promise.resolve({ + ...user, + profile: null, + }); + } + }, + }; +} + +function createAvatarUrlMock(): { getUserAvatarUrl: Mock } { + return { + getUserAvatarUrl: vi.fn().mockReturnValue("https://example.com/avatar.png"), + }; +} + +function createSafeStringifyMock(): { safeStringify: Mock } { + return { + safeStringify: vi.fn().mockReturnValue("{}"), + }; +} + +function createGetTokenMock(): { getToken: Mock } { + return { getToken: vi.fn() }; +} + +// Creates a mock User with only the fields used by getServerSession +function createMockUser(overrides: Partial = {}): User { + return { + id: 5, + email: "user@example.com", + name: "Test User", + username: "testuser", + emailVerified: new Date(), + completedOnboarding: true, + role: "USER", + avatarUrl: null, + locale: "en", + ...overrides, + } as User; +} + +function createMockToken(overrides: Partial = {}): MockToken { + return { + sub: "5", + email: "user@example.com", + name: "Test User", + exp: Math.floor(Date.now() / 1000) + 3600, + belongsToActiveTeam: false, + org: null, + orgAwareUsername: null, + profileId: null, + upId: "usr-5", + ...overrides, + }; +} + +export type { MockToken }; +export { + prismaMock, + resetPrismaMock, + createLoggerMock, + createPrismaMock, + createLicenseKeyMock, + createDeploymentRepositoryMock, + createUserRepositoryMock, + createAvatarUrlMock, + createSafeStringifyMock, + createGetTokenMock, + createMockUser, + createMockToken, +}; diff --git a/packages/features/auth/lib/getServerSession.test.ts b/packages/features/auth/lib/getServerSession.test.ts new file mode 100644 index 0000000000..8bc261a45b --- /dev/null +++ b/packages/features/auth/lib/getServerSession.test.ts @@ -0,0 +1,169 @@ +import type { NextApiRequest } from "next"; +import type { RequestMethod } from "node-mocks-http"; +import { createMocks } from "node-mocks-http"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Use vi.hoisted to import mocks before vi.mock hoisting +const { + prismaMock, + resetPrismaMock, + createLoggerMock, + createPrismaMock, + createLicenseKeyMock, + createDeploymentRepositoryMock, + createUserRepositoryMock, + createAvatarUrlMock, + createSafeStringifyMock, + createGetTokenMock, + createMockUser, + createMockToken, +}: typeof import("../__mocks__/getServerSession.mocks") = await vi.hoisted( + async () => await import("../__mocks__/getServerSession.mocks") +); + +vi.mock("@calcom/lib/logger", createLoggerMock); +vi.mock("@calcom/prisma", createPrismaMock); +vi.mock("@calcom/ee/common/server/LicenseKeyService", createLicenseKeyMock); +vi.mock("@calcom/features/ee/deployment/repositories/DeploymentRepository", createDeploymentRepositoryMock); +vi.mock("@calcom/features/users/repositories/UserRepository", createUserRepositoryMock); +vi.mock("@calcom/lib/getAvatarUrl", createAvatarUrlMock); +vi.mock("@calcom/lib/safeStringify", createSafeStringifyMock); +vi.mock("next-auth/jwt", createGetTokenMock); + +import { getToken } from "next-auth/jwt"; +import { getServerSession } from "./getServerSession"; + +type MockNextApiRequest = ReturnType>["req"]; + +function createMockRequest(method: RequestMethod = "GET"): MockNextApiRequest { + const { req } = createMocks({ method }); + return req; +} + +function setupGetTokenMock(tokenData: object | null): void { + vi.mocked(getToken).mockResolvedValue(tokenData as Record | null); +} + +describe("getServerSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPrismaMock(); + }); + + describe("Token Validation", () => { + it.each([ + ["no token", null], + ["no email", { sub: "5" }], + ["no sub", { email: "user@example.com" }], + ])("returns null when token is invalid: %s", async (_, token) => { + setupGetTokenMock(token); + + const result = await getServerSession({ req: createMockRequest() }); + expect(result).toBeNull(); + }); + }); + + describe("User ID Validation", () => { + it.each(["", "invalid", "0", "-1"])( + "returns null when token.sub is invalid (%s)", + async (sub) => { + setupGetTokenMock(createMockToken({ sub })); + + const result = await getServerSession({ req: createMockRequest() }); + + expect(result).toBeNull(); + expect(prismaMock.user.findUnique).not.toHaveBeenCalled(); + } + ); + }); + + describe("User Lookup", () => { + it("looks up user by ID from token.sub", async () => { + const mockUser = createMockUser({ id: 123 }); + setupGetTokenMock(createMockToken({ sub: "123" })); + prismaMock.user.findUnique.mockResolvedValue(mockUser); + + await getServerSession({ req: createMockRequest() }); + + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: 123 }, + }); + }); + + it("returns null when user not found in database", async () => { + setupGetTokenMock(createMockToken({ sub: "999" })); + prismaMock.user.findUnique.mockResolvedValue(null); + + const result = await getServerSession({ req: createMockRequest() }); + expect(result).toBeNull(); + }); + + it("returns session with correct user data", async () => { + const mockUser = createMockUser(); + setupGetTokenMock(createMockToken()); + prismaMock.user.findUnique.mockResolvedValue(mockUser); + + const result = await getServerSession({ req: createMockRequest() }); + + expect(result).toMatchObject({ + user: { + id: mockUser.id, + email: mockUser.email, + }, + }); + }); + }); + + describe("User Resolution", () => { + it("resolves user by token subject ID", async () => { + const token = createMockToken({ + sub: "999", + email: "user@example.com", + }); + + setupGetTokenMock(token); + prismaMock.user.findUnique.mockResolvedValue(null); + + await getServerSession({ req: createMockRequest() }); + + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: 999 }, + }); + }); + + it("returns user data from database lookup", async () => { + const dbUser = createMockUser({ id: 999, email: "db-user@example.com" }); + const token = createMockToken({ + sub: "999", + email: "token-email@example.com", + }); + + setupGetTokenMock(token); + prismaMock.user.findUnique.mockResolvedValue(dbUser); + + const result = await getServerSession({ req: createMockRequest() }); + + expect(result).toMatchObject({ + user: { + id: 999, + email: "db-user@example.com", + }, + }); + }); + + it("uses ID field for database queries", async () => { + const mockUser = createMockUser(); + setupGetTokenMock(createMockToken()); + prismaMock.user.findUnique.mockResolvedValue(mockUser); + + await getServerSession({ req: createMockRequest() }); + + const calls = prismaMock.user.findUnique.mock.calls; + for (const call of calls) { + const whereClause = call[0]?.where as Record; + expect(whereClause).toHaveProperty("id"); + expect(whereClause).not.toHaveProperty("email"); + } + }); + }); +}); diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 5dc14368ea..1fab94ef10 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -54,10 +54,15 @@ export async function getServerSession(options: { return cachedSession; } - const email = token.email.toLowerCase(); + const userId = token.sub ? Number(token.sub) : null; + + if (!userId || userId <= 0) { + log.warn("Invalid or missing user ID in token", { sub: token.sub }); + return null; + } const userFromDb = await prisma.user.findUnique({ - where: { email }, + where: { id: userId }, }); if (!userFromDb) {