-
Notifications
You must be signed in to change notification settings - Fork 8.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Create UserCreationService
and use in API V1 create user endpoint
#19150
base: main
Are you sure you want to change the base?
Changes from 21 commits
a5fff6d
898377b
a3a7583
852dad7
ec9bc6c
d486212
f823030
01637f4
a888781
4e0d12e
3e18a61
a679abf
cf319e9
4aa04d4
86200ec
ea94100
8e56c25
1eae797
571ae02
a6bbd69
0624b4a
527539e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: "[email protected]", | ||
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: "[email protected]", | ||
}, | ||
}); | ||
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: "[email protected]", | ||
username: "test", | ||
}, | ||
prisma: prismock, | ||
}); | ||
req.isSystemWideAdmin = true; | ||
|
||
await handler(req, res); | ||
|
||
expect(res.statusCode).toBe(200); | ||
|
||
const userQuery = await prismock.user.findFirst({ | ||
where: { | ||
email: "[email protected]", | ||
}, | ||
}); | ||
|
||
expect(userQuery).toEqual( | ||
expect.objectContaining({ | ||
email: "[email protected]", | ||
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: "[email protected]", | ||
username: "test", | ||
}, | ||
prisma: prismock, | ||
}); | ||
req.isSystemWideAdmin = true; | ||
|
||
await prismock.watchlist.create({ | ||
data: { | ||
type: "EMAIL", | ||
value: "[email protected]", | ||
severity: "CRITICAL", | ||
createdById: 1, | ||
}, | ||
}); | ||
|
||
await handler(req, res); | ||
|
||
expect(res.statusCode).toBe(200); | ||
|
||
const userQuery = await prismock.user.findFirst({ | ||
where: { | ||
email: "[email protected]", | ||
}, | ||
}); | ||
|
||
expect(userQuery).toEqual( | ||
expect.objectContaining({ | ||
email: "[email protected]", | ||
username: "test", | ||
locked: true, | ||
organizationId: null, | ||
}) | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: "[email protected]", | ||
organizationId: null, | ||
creationSource: CreationSource.WEBAPP, | ||
locked: false, | ||
}); | ||
|
||
expect(user).toEqual( | ||
expect.objectContaining({ | ||
username: "test", | ||
email: "[email protected]", | ||
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: "[email protected]", | ||
organizationId: null, | ||
creationSource: CreationSource.WEBAPP, | ||
locked: true, | ||
}); | ||
|
||
const userQuery = await prismock.user.findUnique({ | ||
where: { | ||
email: "[email protected]", | ||
}, | ||
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: "[email protected]", | ||
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, | ||
}) | ||
); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
Comment on lines
-596
to
-597
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moving this business logic to the service |
||
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, ...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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also moving this business logic to the service |
||
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: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are creating a profile but shouldn't we also create a user membership for the org? I think that org users have both membership and profile. |
||
create: { | ||
username, | ||
organizationId: organizationIdValue, | ||
uid: ProfileRepository.generateProfileUid(), | ||
}, | ||
}, | ||
} | ||
: undefined, | ||
creationSource, | ||
: {}), | ||
...rest, | ||
}, | ||
}); | ||
|
||
return user; | ||
} | ||
static async getUserAdminTeams(userId: number) { | ||
return prisma.user.findFirst({ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll check the new org owner against the watch list in this PR #19201