feat: Create UserCreationService and use in API V1 create user endpoint (#19150)
* Create `createUser` method in `userCreationService` * Refactor `UserRepository` create method to accept prisma input and remove business logic * API use `UserCreationService` * Move slugify to service * Use hashPassword instead * Type fixes in `UserCreationService` * Add `userCreationService` tests * API accept data object * Type fixes * Add user _post test * Add test for locked user * Add locked param to log * Add user repository tests * Do not return locked status * Explicitly pass `locked` prop * Fix tests when locked isn't returned * Fix tests * Pass locked prop * Edit test name * Use logger * Fix passing hashed password
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,7 @@ export class OrganizationRepository {
|
||||
email: owner.email,
|
||||
username: ownerUsernameInOrg,
|
||||
organizationId: organization.id,
|
||||
locked: false,
|
||||
creationSource,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
|
||||
username: string;
|
||||
hashedPassword?: string;
|
||||
organizationId: number | null;
|
||||
creationSource: CreationSource;
|
||||
locked: boolean;
|
||||
}
|
||||
) {
|
||||
const organizationIdValue = data.organizationId;
|
||||
const { email, username, creationSource, locked, hashedPassword, ...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 } },
|
||||
...(hashedPassword && { password: { create: { hash: 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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, ...restUser } = user;
|
||||
|
||||
return restUser;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user