diff --git a/apps/api/v1/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts index 8331005acd1eb2..80fa92e2452081 100644 --- a/apps/api/v1/pages/api/users/_post.ts +++ b/apps/api/v1/pages/api/users/_post.ts @@ -2,7 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; +import { UserCreationService } from "@calcom/lib/server/service/userCreationService"; import { CreationSource } from "@calcom/prisma/enums"; import { schemaUserCreateBodyParams } from "~/lib/validations/user"; @@ -93,7 +93,9 @@ async function postHandler(req: NextApiRequest) { // If user is not ADMIN, return unauthorized. if (!isSystemWideAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" }); const data = await schemaUserCreateBodyParams.parseAsync(req.body); - const user = await prisma.user.create({ data: { ...data, creationSource: CreationSource.API_V1 } }); + const user = await UserCreationService.createUser({ + data: { ...data, creationSource: CreationSource.API_V1 }, + }); req.statusCode = 201; return { user }; } diff --git a/apps/api/v1/test/lib/users/_post.test.ts b/apps/api/v1/test/lib/users/_post.test.ts new file mode 100644 index 00000000000000..7aabe2fd03e85d --- /dev/null +++ b/apps/api/v1/test/lib/users/_post.test.ts @@ -0,0 +1,134 @@ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, test, expect, vi } from "vitest"; + +import handler from "../../../pages/api/users/_post"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: (key: string) => { + return () => key; + }, + }; +}); + +vi.stubEnv("CALCOM_LICENSE_KEY", undefined); + +describe("POST /api/users", () => { + test("should throw 401 if not system-wide admin", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + email: "test@example.com", + username: "test", + }, + }); + req.isSystemWideAdmin = false; + + await handler(req, res); + + expect(res.statusCode).toBe(401); + }); + test("should throw a 400 if no email is provided", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + username: "test", + }, + }); + req.isSystemWideAdmin = true; + + await handler(req, res); + + expect(res.statusCode).toBe(400); + }); + test("should throw a 400 if no username is provided", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + email: "test@example.com", + }, + }); + req.isSystemWideAdmin = true; + + await handler(req, res); + + expect(res.statusCode).toBe(400); + }); + test("should create user successfully", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + email: "test@example.com", + username: "test", + }, + prisma: prismock, + }); + req.isSystemWideAdmin = true; + + await handler(req, res); + + expect(res.statusCode).toBe(200); + + const userQuery = await prismock.user.findFirst({ + where: { + email: "test@example.com", + }, + }); + + expect(userQuery).toEqual( + expect.objectContaining({ + email: "test@example.com", + username: "test", + locked: false, + organizationId: null, + }) + ); + }); + + test("should auto lock user if email is in watchlist", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + email: "test@example.com", + username: "test", + }, + prisma: prismock, + }); + req.isSystemWideAdmin = true; + + await prismock.watchlist.create({ + data: { + type: "EMAIL", + value: "test@example.com", + severity: "CRITICAL", + createdById: 1, + }, + }); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + + const userQuery = await prismock.user.findFirst({ + where: { + email: "test@example.com", + }, + }); + + expect(userQuery).toEqual( + expect.objectContaining({ + email: "test@example.com", + username: "test", + locked: true, + organizationId: null, + }) + ); + }); +}); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 752e19b1f16cb7..809c91df00eb3f 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -90,6 +90,7 @@ export class OrganizationRepository { email: owner.email, username: ownerUsernameInOrg, organizationId: organization.id, + locked: false, creationSource, }); diff --git a/packages/lib/server/repository/user.test.ts b/packages/lib/server/repository/user.test.ts new file mode 100644 index 00000000000000..78886a2819fd05 --- /dev/null +++ b/packages/lib/server/repository/user.test.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import prismock from "../../../../tests/libs/__mocks__/prisma"; + +import { describe, test, vi, expect, beforeEach } from "vitest"; + +import { CreationSource } from "@calcom/prisma/enums"; + +import { UserRepository } from "./user"; + +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: (key: string) => { + return () => key; + }, + }; +}); + +describe("UserRepository", () => { + beforeEach(() => { + prismock; + }); + + describe("create", () => { + test("Should create a user without a password", async () => { + const user = await UserRepository.create({ + username: "test", + email: "test@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + + expect(user).toEqual( + expect.objectContaining({ + username: "test", + email: "test@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }) + ); + + const password = await prismock.userPassword.findUnique({ + where: { + userId: user.id, + }, + }); + + expect(password).toBeNull(); + }); + + test("If locked param is passed, user should be locked", async () => { + const user = await UserRepository.create({ + username: "test", + email: "test@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: true, + }); + + const userQuery = await prismock.user.findUnique({ + where: { + email: "test@example.com", + }, + select: { + locked: true, + }, + }); + + expect(userQuery).toEqual( + expect.objectContaining({ + locked: true, + }) + ); + }); + + test("If organizationId is passed, user should be associated with the organization", async () => { + const organizationId = 123; + const username = "test"; + + const user = await UserRepository.create({ + username, + email: "test@example.com", + organizationId, + creationSource: CreationSource.WEBAPP, + locked: true, + }); + + expect(user).toEqual( + expect.objectContaining({ + organizationId, + }) + ); + + const profile = await prismock.profile.findFirst({ + where: { + organizationId, + username, + }, + }); + + expect(profile).toEqual( + expect.objectContaining({ + organizationId, + username, + }) + ); + }); + }); +}); diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 0449d05f2d1e6c..09d77ccee652bf 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -1,7 +1,4 @@ -import { createHash } from "crypto"; - import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; -import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -16,7 +13,6 @@ import { userMetadata } from "@calcom/prisma/zod-utils"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability"; -import slugify from "../../slugify"; import { ProfileRepository } from "./profile"; import { getParsedTeam } from "./teamUtils"; @@ -581,28 +577,27 @@ export class UserRepository { }); } - static async create({ - email, - username, - organizationId, - creationSource, - }: { - email: string; - username: string; - organizationId: number | null; - creationSource: CreationSource; - }) { - console.log("create user", { email, username, organizationId }); - const password = createHash("md5").update(`${email}${process.env.CALENDSO_ENCRYPTION_KEY}`).digest("hex"); - const hashedPassword = await hashPassword(password); + static async create( + data: Omit & { + username: string; + hashedPassword?: string; + organizationId: number | null; + creationSource: CreationSource; + locked: boolean; + } + ) { + const organizationIdValue = data.organizationId; + const { email, username, creationSource, locked, ...rest } = data; + + logger.info("create user", { email, username, organizationIdValue, locked }); const t = await getTranslation("en", "common"); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); - return await prisma.user.create({ + const user = await prisma.user.create({ data: { - username: slugify(username), + username, email: email, - password: { create: { hash: hashedPassword } }, + ...(data.hashedPassword && { password: { create: { hash: data.hashedPassword } } }), // Default schedule schedules: { create: { @@ -618,19 +613,25 @@ export class UserRepository { }, }, }, - organizationId: organizationId, - profiles: organizationId + creationSource, + locked, + ...(organizationIdValue ? { - create: { - username: slugify(username), - organizationId: organizationId, - uid: ProfileRepository.generateProfileUid(), + organizationId: organizationIdValue, + profiles: { + create: { + username, + organizationId: organizationIdValue, + uid: ProfileRepository.generateProfileUid(), + }, }, } - : undefined, - creationSource, + : {}), + ...rest, }, }); + + return user; } static async getUserAdminTeams(userId: number) { return prisma.user.findFirst({ diff --git a/packages/lib/server/service/userCreationService.test.ts b/packages/lib/server/service/userCreationService.test.ts new file mode 100644 index 00000000000000..39e35fe1de3d8f --- /dev/null +++ b/packages/lib/server/service/userCreationService.test.ts @@ -0,0 +1,101 @@ +import prismock from "../../../../tests/libs/__mocks__/prisma"; + +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; +import { CreationSource } from "@calcom/prisma/enums"; + +import { UserRepository } from "../repository/user"; +import { UserCreationService } from "./userCreationService"; + +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: (key: string) => { + return () => key; + }, + }; +}); + +vi.mock("@calcom/features/auth/lib/hashPassword", () => ({ + hashPassword: vi.fn().mockResolvedValue("hashed-password"), +})); + +vi.mock("../repository/user", async () => { + return { + UserRepository: { + create: vi.fn(), + }, + }; +}); + +vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", (async) => ({ + checkIfEmailIsBlockedInWatchlistController: vi.fn(() => false), +})); + +const mockUserData = { + email: "test@example.com", + username: "test", + creationSource: CreationSource.WEBAPP, +}; + +vi.stubEnv("CALCOM_LICENSE_KEY", undefined); + +describe("UserCreationService", () => { + beforeEach(() => { + prismock; + vi.clearAllMocks(); + }); + + test("should create user", async () => { + vi.spyOn(UserRepository, "create").mockResolvedValue({ + username: "test", + locked: false, + organizationId: null, + } as any); + + const user = await UserCreationService.createUser({ data: mockUserData }); + + expect(UserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + username: "test", + locked: false, + organizationId: null, + }) + ); + + expect(user).not.toHaveProperty("locked"); + }); + + test("should lock user when email is in watchlist", async () => { + vi.mocked(checkIfEmailIsBlockedInWatchlistController).mockResolvedValue(true); + + const user = await UserCreationService.createUser({ data: mockUserData }); + + expect(UserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + locked: true, + }) + ); + + expect(user).not.toHaveProperty("locked"); + }); + + test("should hash password when provided", async () => { + const mockPassword = "password"; + vi.mocked(hashPassword).mockResolvedValue("hashed_password"); + + const user = await UserCreationService.createUser({ + data: { ...mockUserData, password: mockPassword }, + }); + + expect(hashPassword).toHaveBeenCalledWith(mockPassword); + expect(UserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + hashedPassword: "hashed_password", + }) + ); + + expect(user).not.toHaveProperty("locked"); + }); +}); diff --git a/packages/lib/server/service/userCreationService.ts b/packages/lib/server/service/userCreationService.ts new file mode 100644 index 00000000000000..5056965348511d --- /dev/null +++ b/packages/lib/server/service/userCreationService.ts @@ -0,0 +1,54 @@ +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; +import logger from "@calcom/lib/logger"; +import type { CreationSource, UserPermissionRole, IdentityProvider } from "@calcom/prisma/enums"; + +import slugify from "../../slugify"; +import { UserRepository } from "../repository/user"; + +interface CreateUserInput { + email: string; + username: string; + name?: string | null; + password?: string; + brandColor?: string; + darkBrandColor?: string; + hideBranding?: boolean; + weekStart?: string; + timeZone?: string; + theme?: string | null; + timeFormat?: number; + locale?: string; + avatar?: string; + organizationId?: number | null; + creationSource: CreationSource; + role?: UserPermissionRole; + emailVerified?: Date; + identityProvider?: IdentityProvider; +} + +const log = logger.getSubLogger({ prefix: ["[userCreationService]"] }); + +export class UserCreationService { + static async createUser({ data }: { data: CreateUserInput }) { + const { email, password, username } = data; + + const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(email); + + const hashedPassword = password ? await hashPassword(password) : null; + + const user = await UserRepository.create({ + ...data, + username: slugify(username), + ...(hashedPassword && { hashedPassword }), + organizationId: data?.organizationId ?? null, + locked: shouldLockByDefault, + }); + + log.info(`Created user: ${user.id} with locked status of ${user.locked}`); + + const { locked, ...rest } = user; + + return rest; + } +}