diff --git a/packages/medusa/src/models/__mocks__/user.js b/packages/medusa/src/models/__mocks__/user.js new file mode 100644 index 0000000000..4704bc0ae9 --- /dev/null +++ b/packages/medusa/src/models/__mocks__/user.js @@ -0,0 +1,23 @@ +import { IdMap } from "medusa-test-utils" + +export const users = { + testUser: { + _id: IdMap.getId("test-user"), + email: "oliver@medusa.test", + passwordHash: "123456789", + }, +} + +export const UserModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("test-user")) { + return Promise.resolve(users.testUser) + } + return Promise.resolve(undefined) + }), +} diff --git a/packages/medusa/src/models/user.js b/packages/medusa/src/models/user.js index 12ad7d1b6f..c73388c5b8 100644 --- a/packages/medusa/src/models/user.js +++ b/packages/medusa/src/models/user.js @@ -7,9 +7,9 @@ import { BaseModel } from "medusa-interfaces" class UserModel extends BaseModel { static modelName = "User" static schema = { - name: { type: String, required: true }, email: { type: String, required: true }, passwordHash: { type: String, required: true }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/services/__tests__/user.js b/packages/medusa/src/services/__tests__/user.js new file mode 100644 index 0000000000..1768f44592 --- /dev/null +++ b/packages/medusa/src/services/__tests__/user.js @@ -0,0 +1,110 @@ +import mongoose from "mongoose" +import jwt from "jsonwebtoken" +import { IdMap } from "medusa-test-utils" +import UserService from "../user" +import { UserModelMock, users } from "../../models/__mocks__/user" + +describe("UserService", () => { + describe("retrieve", () => { + let result + + beforeAll(async () => { + jest.clearAllMocks() + const userService = new UserService({ + userModel: UserModelMock, + }) + result = await userService.retrieve(IdMap.getId("test-user")) + }) + + it("calls cart model functions", () => { + expect(UserModelMock.findOne).toHaveBeenCalledTimes(1) + expect(UserModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("test-user"), + }) + }) + + it("returns the user", () => { + expect(result).toEqual(users.testUser) + }) + }) + + describe("setMetadata", () => { + const userService = new UserService({ + userModel: UserModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateOne with correct params", async () => { + const id = mongoose.Types.ObjectId() + await userService.setMetadata(`${id}`, "metadata", "testMetadata") + + expect(UserModelMock.updateOne).toBeCalledTimes(1) + expect(UserModelMock.updateOne).toBeCalledWith( + { _id: `${id}` }, + { $set: { "metadata.metadata": "testMetadata" } } + ) + }) + + it("throw error on invalid key type", async () => { + const id = mongoose.Types.ObjectId() + + try { + await userService.setMetadata(`${id}`, 1234, "nono") + } catch (err) { + expect(err.message).toEqual( + "Key type is invalid. Metadata keys must be strings" + ) + } + }) + }) + + describe("setPassword", () => { + const userService = new UserService({ + userModel: UserModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateOne with correct params", async () => { + await userService.setPassword(IdMap.getId("test-user"), "123456789") + expect(UserModelMock.updateOne).toBeCalledTimes(1) + expect(UserModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("test-user") }, + { + $set: { + // Since bcrypt hashing always varies, we are testing the password + // match by using a regular expression. + password: expect.stringMatching( + /^\$2[aby]?\$[\d]+\$[./A-Za-z0-9]{53}$/ + ), + }, + } + ) + }) + }) + + describe("generateResetPasswordToken", () => { + const userService = new UserService({ + userModel: UserModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("generates a token successfully", async () => { + const token = await userService.generateResetPasswordToken( + IdMap.getId("test-user") + ) + + expect(token).toMatch( + /^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/ + ) + }) + }) +}) diff --git a/packages/medusa/src/services/user.js b/packages/medusa/src/services/user.js new file mode 100644 index 0000000000..4a40b05666 --- /dev/null +++ b/packages/medusa/src/services/user.js @@ -0,0 +1,209 @@ +import mongoose from "mongoose" +import _ from "lodash" +import bcrypt from "bcrypt" +import jwt from "jsonwebtoken" +import { Validator, MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" + +/** + * Provides layer to manipulate users. + * @implements BaseService + */ +class UserService extends BaseService { + constructor({ userModel, eventBusService }) { + super() + + /** @private @const {UserModel} */ + this.userModel_ = userModel + + /** @private @const {EventBus} */ + this.eventBus_ = eventBusService + } + + /** + * Used to validate user ids. Throws an error if the cast fails + * @param {string} rawId - the raw user id to validate. + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The userId could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Used to validate user email. + * @param {string} email - email to validate + * @return {string} the validated email + */ + validateEmail_(email) { + const schema = Validator.string() + .email() + .required() + const { value, error } = schema.validate(email) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The email is not valid" + ) + } + + return value + } + + /** + * @param {Object} selector - the query object for find + * @return {Promise} the result of the find operation + */ + list(selector) { + return this.userModel_.find(selector) + } + + /** + * Gets a user by id. + * @param {string} userId - the id of the user to get. + * @return {Promise} the user document. + */ + retrieve(userId) { + const validatedId = this.validateId_(userId) + return this.userModel_.findOne({ _id: validatedId }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Creates a user with username being validated. + * Fails if email is not a valid format. + * @param {object} user - the user to create + * @return {Promise} the result of create + */ + create(user) { + const validatedEmail = this.validateEmail_(user.email) + user.email = validatedEmail + this.userModel_.create(user).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Deletes a user from a given user id. + * @param {string} userId - the id of the user to delete. Must be + * castable as an ObjectId + * @return {Promise} the result of the delete operation. + */ + async delete(userId) { + const user = await this.retrieve(userId) + // Delete is idempotent, but we return a promise to allow then-chaining + if (!user) { + return Promise.resolve() + } + + return this.userModel_.deleteOne({ _id: user._id }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Sets a password for a user + * Fails if no user exists with userId and if the hashing of the new + * password does not work. + * @param {string} userId - the userId to set password for + * @param {string} password - the old password to set + * @returns {Promise} the result of the update operation + */ + async setPassword(userId, password) { + const user = await this.retrieve(userId) + if (!user) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `User with ${userId} was not found` + ) + } + + const hashedPassword = await bcrypt.hash(password, 10) + if (!hashedPassword) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `An error occured while hashing password` + ) + } + + return this.userModel_.updateOne( + { _id: userId }, + { $set: { password: hashedPassword } } + ) + } + + /** + * Generate a JSON Web token, that will be sent to a user, that wishes to + * reset password. + * The token will be signed with the users current password hash as a secret + * a long side a payload with userId and the expiry time for the token, which + * is always 15 minutes. + * @param {User} user - the user to reset password for + * @returns {string} the generated JSON web token + */ + async generateResetPasswordToken(userId) { + const user = await this.retrieve(userId) + if (!user) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `User with ${userId} was not found` + ) + } + const secret = user.passwordHash + const expiry = Math.floor(Date.now() / 1000) + 60 * 15 + const payload = { userId: user._id, exp: expiry } + const token = jwt.sign(payload, secret) + return token + } + + /** + * Decorates a user. + * @param {User} user - the cart to decorate. + * @param {string[]} fields - the fields to include. + * @param {string[]} expandFields - fields to expand. + * @return {User} return the decorated user. + */ + async decorate(user, fields, expandFields = []) { + const requiredFields = ["_id", "metadata"] + const decorated = _.pick(user, fields.concat(requiredFields)) + return decorated + } + + /** + * Dedicated method to set metadata for a user. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} userId - the user to apply metadata to. + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + setMetadata(userId, key, value) { + const validatedId = this.validateId_(userId) + + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + + const keyPath = `metadata.${key}` + return this.userModel_ + .updateOne({ _id: validatedId }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } +} + +export default UserService