Merge branch 'master' of github.com:srindom/medusa

This commit is contained in:
Sebastian Rindom
2020-02-21 13:54:00 +01:00
4 changed files with 343 additions and 1 deletions

View File

@@ -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)
}),
}

View File

@@ -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: {} },
}
}

View File

@@ -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-_.+/=]*$/
)
})
})
})

View File

@@ -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<User>} 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