diff --git a/packages/medusa-interfaces/.eslintrc b/packages/medusa-interfaces/.eslintrc new file mode 100644 index 0000000000..2a889697f0 --- /dev/null +++ b/packages/medusa-interfaces/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier"], + "extends": ["prettier"], + "rules": { + "prettier/prettier": "error", + "semi": "error", + "no-unused-expressions": "true" + } +} diff --git a/packages/medusa-plugin-permissions/.prettierrc b/packages/medusa-interfaces/.prettierrc similarity index 100% rename from packages/medusa-plugin-permissions/.prettierrc rename to packages/medusa-interfaces/.prettierrc diff --git a/packages/medusa-interfaces/package.json b/packages/medusa-interfaces/package.json index 5d87cd08fe..53ccd9d4fd 100644 --- a/packages/medusa-interfaces/package.json +++ b/packages/medusa-interfaces/package.json @@ -11,7 +11,8 @@ "scripts": { "build": "babel src --out-dir dist/ --ignore **/__tests__", "prepare": "cross-env NODE_ENV=production npm run build", - "watch": "babel -w src --out-dir dist/ --ignore **/__tests__" + "watch": "babel -w src --out-dir dist/ --ignore **/__tests__", + "test": "jest" }, "author": "Sebastian Rindom", "license": "AGPL-3.0-or-later", @@ -21,8 +22,11 @@ "@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-transform-runtime": "^7.7.6", "@babel/preset-env": "^7.7.5", + "@babel/runtime": "^7.9.6", "cross-env": "^5.2.1", - "eslint": "^6.8.0" + "eslint": "^6.8.0", + "jest": "^25.5.0", + "prettier": "^1.19.1" }, "dependencies": { "mongoose": "^5.8.0" diff --git a/packages/medusa-interfaces/src/__tests__/base-service.js b/packages/medusa-interfaces/src/__tests__/base-service.js new file mode 100644 index 0000000000..76b5055c1f --- /dev/null +++ b/packages/medusa-interfaces/src/__tests__/base-service.js @@ -0,0 +1,66 @@ +import BaseService from "../base-service" + +describe("BaseService", () => { + describe("addDecorator", () => { + const baseService = new BaseService() + + it("successfully adds decorator", () => { + baseService.addDecorator(obj => { + return (obj.decorator1 = true) + }) + + expect(baseService.decorators_.length).toEqual(1) + }) + + it("throws if decorator is not a function", () => { + expect(() => baseService.addDecorator("not a function")).toThrow( + "Decorators must be of type function" + ) + }) + }) + + describe("runDecorators_", () => { + it("returns success when passwords match", async () => { + const baseService = new BaseService() + + baseService.addDecorator(obj => { + obj.decorator1 = true + return obj + }) + baseService.addDecorator(obj => { + obj.decorator2 = true + return obj + }) + + const result = await baseService.runDecorators_({ data: "initial" }) + expect(result).toEqual({ + data: "initial", + decorator1: true, + decorator2: true, + }) + }) + + it("skips failing decorator", async () => { + const baseService = new BaseService() + + baseService.addDecorator(obj => { + obj.decorator1 = true + return obj + }) + baseService.addDecorator(obj => { + return Promise.reject("fail") + }) + baseService.addDecorator(obj => { + obj.decorator3 = true + return Promise.resolve(obj) + }) + + const result = await baseService.runDecorators_({ data: "initial" }) + expect(result).toEqual({ + data: "initial", + decorator1: true, + decorator3: true, + }) + }) + }) +}) diff --git a/packages/medusa-interfaces/src/base-service.js b/packages/medusa-interfaces/src/base-service.js index 6d497f47dd..a6abcc8302 100644 --- a/packages/medusa-interfaces/src/base-service.js +++ b/packages/medusa-interfaces/src/base-service.js @@ -2,5 +2,35 @@ * Common functionality for Services * @interface */ -class BaseService {} +class BaseService { + constructor() { + this.decorators_ = [] + } + + /** + * Adds a decorator to a service. The decorator must be a function and should + * return a decorated object. + * @param {function} fn - the decorator to add to the service + */ + addDecorator(fn) { + if (typeof fn !== "function") { + throw Error("Decorators must be of type function") + } + + this.decorators_.push(fn) + } + + /** + * Runs the decorators registered on the service. The decorators are run in + * the order they have been registered in. Failing decorators will be skipped + * in order to ensure deliverability in spite of breaking code. + * @param {object} obj - the object to decorate. + * @return {object} the decorated object. + */ + runDecorators_(obj) { + return this.decorators_.reduce(async (acc, next) => { + return acc.then(res => next(res)).catch(() => acc) + }, Promise.resolve(obj)) + } +} export default BaseService diff --git a/packages/medusa-plugin-permissions/.babelrc b/packages/medusa-plugin-permissions/.babelrc deleted file mode 100644 index b48db12268..0000000000 --- a/packages/medusa-plugin-permissions/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": ["@babel/plugin-proposal-class-properties"], - "presets": ["@babel/preset-env"], - "env": { - "test": { - "plugins": ["@babel/plugin-transform-runtime"] - } - } -} diff --git a/packages/medusa-plugin-permissions/.gitignore b/packages/medusa-plugin-permissions/.gitignore deleted file mode 100644 index c93fa14dda..0000000000 --- a/packages/medusa-plugin-permissions/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -dist/ -node_modules/ -.DS_store -.env* -/*.js -!index.js -yarn.lock -yarn-error.log diff --git a/packages/medusa-plugin-permissions/package.json b/packages/medusa-plugin-permissions/package.json deleted file mode 100644 index bcd80dbf19..0000000000 --- a/packages/medusa-plugin-permissions/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "medusa-plugin-permissions", - "version": "1.0.0", - "description": "Role permission for Medusa core", - "main": "dist/index.js", - "repository": { - "type": "git", - "url": "https://github.com/medusajs/medusa", - "directory": "packages/medusa-plugin-permissions" - }, - "scripts": { - "build": "babel src --out-dir dist/ --ignore **/__tests__", - "prepare": "cross-env NODE_ENV=production npm run build", - "watch": "babel -w src --out-dir dist/ --ignore **/__tests__", - "test": "jest" - }, - "author": "Oliver Juhl", - "license": "AGPL-3.0-or-later", - "devDependencies": { - "@babel/cli": "^7.7.5", - "@babel/core": "^7.7.5", - "@babel/plugin-proposal-class-properties": "^7.7.4", - "@babel/plugin-transform-runtime": "^7.7.6", - "@babel/preset-env": "^7.7.5", - "@babel/runtime": "^7.7.6", - "cross-env": "^5.2.1", - "jest": "^24.9.0" - }, - "dependencies": { - "mongoose": "^5.8.0", - "medusa-test-utils": "^1.0.0", - "medusa-core-utils": "^1.0.0", - "medusa-interfaces": "^1.0.0" - } -} \ No newline at end of file diff --git a/packages/medusa-plugin-permissions/src/api/middlewares.js b/packages/medusa-plugin-permissions/src/api/middlewares.js deleted file mode 100644 index 1835186c67..0000000000 --- a/packages/medusa-plugin-permissions/src/api/middlewares.js +++ /dev/null @@ -1,14 +0,0 @@ -// This middleware is injected to ensure authorization of requests -// Since this middleware uses the user object on the request, this should be -// injected after authentication in the core middleware, hence we name -// the middleware postAuth. -export default postAuth = () => { - return (err, req, res, next) => { - const permissionService = req.scope.resolve("permissionService") - if (permissionService.hasPermission(req.user, req.method, req.path)) { - next() - } else { - res.status(422) - } - } -} diff --git a/packages/medusa-plugin-permissions/src/models/__mocks__/role.js b/packages/medusa-plugin-permissions/src/models/__mocks__/role.js deleted file mode 100644 index d840f21080..0000000000 --- a/packages/medusa-plugin-permissions/src/models/__mocks__/role.js +++ /dev/null @@ -1,36 +0,0 @@ -import { IdMap } from "medusa-test-utils" - -export const permissions = { - productEditorPermission: { - _id: IdMap.getId("product_editor"), - name: "product_editor", - permissions: [ - { - method: "POST", - endpoint: "/products", - }, - { - method: "GET", - endpoint: "/products", - }, - { - method: "PUT", - endpoint: "/products", - }, - ], - }, -} - -export const RoleModelMock = { - create: jest.fn().mockReturnValue(Promise.resolve()), - deleteOne: jest.fn().mockReturnValue(Promise.resolve()), - findOne: jest.fn().mockImplementation(query => { - if (query.name === "product_editor") { - return Promise.resolve(permissions.productEditorPermission) - } - return Promise.resolve(undefined) - }), - updateOne: jest.fn().mockImplementation((query, update) => { - return Promise.resolve() - }), -} diff --git a/packages/medusa-plugin-permissions/src/models/role.js b/packages/medusa-plugin-permissions/src/models/role.js deleted file mode 100644 index 0ab1148fda..0000000000 --- a/packages/medusa-plugin-permissions/src/models/role.js +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseModel } from "medusa-interfaces" - -import PermissionSchema from "./schemas/permission" - -class RoleModel extends BaseModel { - static modelName = "Role" - static schema = { - name: { type: String, required: true, unique: true }, - permissions: { type: [PermissionSchema], required: true, default: [] }, - } -} - -export default RoleModel diff --git a/packages/medusa-plugin-permissions/src/models/schemas/permission.js b/packages/medusa-plugin-permissions/src/models/schemas/permission.js deleted file mode 100644 index 48da1d9ebc..0000000000 --- a/packages/medusa-plugin-permissions/src/models/schemas/permission.js +++ /dev/null @@ -1,6 +0,0 @@ -import mongoose from "mongoose" - -export default new mongoose.Schema({ - method: { type: String }, - endpoint: { type: String }, -}) diff --git a/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js b/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js deleted file mode 100644 index aff1a3e94b..0000000000 --- a/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js +++ /dev/null @@ -1,15 +0,0 @@ -import { IdMap } from "medusa-test-utils" - -export const PermissionServiceMock = { - hasPermission: jest.fn().mockImplementation((user, method, endpoint) => { - if (user._id === IdMap.getId("test-user")) { - return Promise.resolve(true) - } - }), -} - -const mock = jest.fn().mockImplementation(() => { - return PermissionServiceMock -}) - -export default mock diff --git a/packages/medusa-plugin-permissions/src/services/__tests__/permission.js b/packages/medusa-plugin-permissions/src/services/__tests__/permission.js deleted file mode 100644 index b663d8c22f..0000000000 --- a/packages/medusa-plugin-permissions/src/services/__tests__/permission.js +++ /dev/null @@ -1,354 +0,0 @@ -import mongoose from "mongoose" -import { IdMap } from "medusa-test-utils" -import PermissionService from "../permission" -import { permissions, RoleModelMock } from "../../models/__mocks__/role" - -describe("PermissionService", () => { - describe("hasPermission", () => { - let result - let user = { - _id: IdMap.getId("test-user"), - email: "oliver@medusa.test", - passwordHash: "123456789", - metadata: { - roles: ["product_editor"], - }, - } - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - }) - - beforeAll(async () => { - jest.clearAllMocks() - result = await permissionService.hasPermission(user, "POST", "/products") - }) - - it("calls permission model functions", () => { - expect(RoleModelMock.findOne).toHaveBeenCalledTimes( - user.metadata.roles.length - ) - }) - - it("successfully grants access to user", () => { - expect(result).toEqual(true) - }) - - it("succesfully denies access to user", async () => { - const accessDenied = await permissionService.hasPermission( - user, - "CREATE", - "/orders" - ) - expect(accessDenied).toEqual(false) - }) - }) - - describe("retrieveRole", () => { - let result - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - }) - beforeAll(async () => { - jest.clearAllMocks() - - result = await permissionService.retrieveRole("product_editor") - }) - - it("calls permission model functions", () => { - expect(RoleModelMock.findOne).toHaveBeenCalledTimes(1) - }) - - it("successfully fetches product editor permissions", () => { - expect(result).toEqual(permissions.productEditorPermission) - }) - - it("throws if role with name does not exist", async () => { - try { - await permissionService.retrieveRole("product_editor") - } catch (error) { - expect(error.message).toEqual( - "test_editor does not exist. Use method createRole to create it." - ) - } - }) - }) - - describe("createRole", () => { - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - }) - - beforeAll(async () => { - jest.clearAllMocks() - - const contentEditorPermissions = [ - { - method: "POST", - endpoint: "/contents", - }, - { - method: "GET", - endpoint: "/contents", - }, - { - method: "PUT", - endpoint: "/contents", - }, - ] - - await permissionService.createRole( - "content_editor", - contentEditorPermissions - ) - }) - - it("calls permission model functions", () => { - expect(RoleModelMock.create).toHaveBeenCalledTimes(1) - expect(RoleModelMock.create).toHaveBeenCalledWith({ - name: "content_editor", - permissions: [ - { - method: "POST", - endpoint: "/contents", - }, - { - method: "GET", - endpoint: "/contents", - }, - { - method: "PUT", - endpoint: "/contents", - }, - ], - }) - }) - - it("throws if any permission is invalid", async () => { - try { - await permissionService.createRole("content_editor", [ - { - method: "POST", - endpoint: "/products", - }, - { - // Should fail since this is not a valid http request - method: "FETCH", - endpoint: "/products", - }, - ]) - } catch (err) { - expect(err.message).toEqual("Permission is not valid") - } - }) - - it("throws if role with name already exists", async () => { - try { - await permissionService.createRole("product_editor", [ - { - method: "POST", - endpoint: "/order", - }, - ]) - } catch (err) { - expect(err.message).toEqual("product_editor already exists") - } - }) - }) - - describe("grantRole", () => { - const setMetadataMock = jest.fn().mockReturnValue(Promise.resolve()) - const userRetrieveMock = jest.fn().mockImplementation((data) => { - if (data === IdMap.getId("permission-user")) { - return Promise.resolve({ - _id: IdMap.getId("permission-user"), - email: "oliver@test.dk", - metadata: { - roles: ["content_editor"], - }, - }) - } - if (data === IdMap.getId("user-without-roles")) { - return Promise.resolve({ - _id: IdMap.getId("user-without-roles"), - email: "oliver@test.dk", - metadata: {}, - }) - } - return Promise.resolve(undefined) - }) - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - userService: { - setMetadata: setMetadataMock, - retrieve: userRetrieveMock, - }, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("successfully grants role to user", async () => { - await permissionService.grantRole( - IdMap.getId("permission-user"), - "product_editor" - ) - - expect(setMetadataMock).toHaveBeenCalledTimes(1) - expect(setMetadataMock).toHaveBeenCalledWith( - IdMap.getId("permission-user"), - "roles", - ["content_editor", "product_editor"] - ) - }) - - it("sets user metadata.roles to user that does not have metadata.roles", async () => { - await permissionService.grantRole( - IdMap.getId("user-without-roles"), - "product_editor" - ) - - expect(setMetadataMock).toHaveBeenCalledTimes(1) - expect(setMetadataMock).toHaveBeenCalledWith( - IdMap.getId("user-without-roles"), - "roles", - ["product_editor"] - ) - }) - - it("throws if user already has role", async () => { - try { - await permissionService.grantRole( - IdMap.getId("permission-user"), - "product_editor" - ) - } catch (err) { - expect(err.message).toEqual("User already has role: product_editor") - } - }) - }) - - describe("addPermission", () => { - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - }) - - beforeAll(async () => { - jest.clearAllMocks() - }) - - it("successfully adds permission", async () => { - const toAdd = { - method: "POST", - endpoint: "/products", - } - await permissionService.addPermission("product_editor", toAdd) - - expect(RoleModelMock.updateOne).toHaveBeenCalledTimes(1) - expect(RoleModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("product_editor") }, - { - $push: { permissions: { method: "POST", endpoint: "/products" } }, - } - ) - }) - - it("throws if permission is not valid", async () => { - const toAdd = { - method: "POST", - endpoint: 1234, - } - - try { - await permissionService.addPermission("product_editor", toAdd) - } catch (err) { - expect(err.message).toEqual("Permission is not valid") - } - }) - }) - - describe("removePermission", () => { - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - }) - - beforeAll(async () => { - jest.clearAllMocks() - }) - - it("successfully removes permission", async () => { - const toRemove = { - method: "POST", - endpoint: "/products", - } - await permissionService.removePermission("product_editor", toRemove) - - expect(RoleModelMock.updateOne).toHaveBeenCalledTimes(1) - expect(RoleModelMock.updateOne).toHaveBeenCalledWith( - { _id: IdMap.getId("product_editor") }, - { - $pull: { permissions: { method: "POST", endpoint: "/products" } }, - } - ) - }) - - it("throws if permission is not valid", async () => { - const update = { - method: "FETCH", - endpoint: "/cart", - } - - try { - await permissionService.addPermission("product_editor", update) - } catch (err) { - expect(err.message).toEqual("Permission is not valid") - } - }) - }) - - describe("revokeRole", () => { - const setMetadataMock = jest.fn().mockReturnValue(Promise.resolve()) - const userRetrieveMock = jest.fn().mockReturnValue( - Promise.resolve({ - _id: IdMap.getId("product_editor"), - email: "oliver@test.dk", - metadata: { - roles: ["product_editor"], - }, - }) - ) - const permissionService = new PermissionService({ - roleModel: RoleModelMock, - userService: { - setMetadata: setMetadataMock, - retrieve: userRetrieveMock, - }, - }) - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("successfully revokes a role from user", async () => { - await permissionService.revokeRole( - IdMap.getId("product_editor"), - "product_editor" - ) - - expect(setMetadataMock).toHaveBeenCalledTimes(1) - expect(setMetadataMock).toHaveBeenCalledWith( - IdMap.getId("product_editor"), - "roles", - [] - ) - }) - - it("succeeds idempotently if user does not have the role to delete", async () => { - await permissionService.revokeRole( - IdMap.getId("product_editor"), - "content_editor" - ) - - expect(setMetadataMock).toHaveBeenCalledTimes(0) - }) - }) -}) diff --git a/packages/medusa-plugin-permissions/src/services/permission.js b/packages/medusa-plugin-permissions/src/services/permission.js deleted file mode 100644 index ca02e65b90..0000000000 --- a/packages/medusa-plugin-permissions/src/services/permission.js +++ /dev/null @@ -1,161 +0,0 @@ -import { BaseService } from "medusa-interfaces" -import { Validator, MedusaError } from "medusa-core-utils" - -class PermissionService extends BaseService { - constructor({ userService, roleModel }) { - super() - - /** @private @const {UserService} */ - this.userService_ = userService - - /** @private @const {RoleModel} */ - this.roleModel_ = roleModel - } - - validatePermission_(permission) { - const schema = Validator.object({ - method: Validator.string().valid( - "POST", - "GET", - "PUT", - "PATCH", - "DELETE", - "CONNECT", - "OPTIONS", - "HEAD", - "TRACE" - ), - endpoint: Validator.string(), - }) - - const { value, error } = schema.validate(permission) - - if (error) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Permission is not valid" - ) - } - - return value - } - - async retrieveRole(name) { - const role = await this.roleModel_.findOne({ name }).catch((err) => { - throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) - }) - - if (!role) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `${name} does not exist. Use method createRole to create it.` - ) - } - return role - } - - async hasPermission(user, method, endpoint) { - for (let i = 0; i < user.metadata.roles.length; i++) { - const role = user.metadata.roles[i] - const permissions = await this.retrieveRole(role) - return permissions.permissions.some((action) => { - return action.method === method && action.endpoint === endpoint - }) - } - return false - } - - async createRole(roleName, permissions) { - const validatedPermissions = permissions.map((permission) => - this.validatePermission_(permission) - ) - - return this.retrieveRole(roleName) - .then((role) => { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - `${role.name} already exists` - ) - }) - .catch((error) => { - if (error.name === MedusaError.Types.NOT_FOUND) { - return this.roleModel_.create({ - name: roleName, - permissions: validatedPermissions, - }) - } else { - throw error - } - }) - } - - async deleteRole(roleName) { - const role = await this.retrieve(roleName) - // Delete is idempotent, but we return a promise to allow then-chaining - if (!role) { - return Promise.resolve() - } - - return this.roleModel_ - .deleteOne({ - _id: role._id, - }) - .catch((err) => { - throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) - }) - } - - async addPermission(roleName, permission) { - const role = await this.retrieveRole(roleName) - const validatedPermission = this.validatePermission_(permission) - - return this.roleModel_.updateOne( - { _id: role._id }, - { $push: { permissions: validatedPermission } } - ) - } - - async removePermission(roleName, permission) { - const role = await this.retrieveRole(roleName) - const validatedPermission = this.validatePermission_(permission) - - return this.roleModel_.updateOne( - { _id: role._id }, - { $pull: { permissions: validatedPermission } } - ) - } - - async grantRole(userId, roleName) { - const role = await this.retrieveRole(roleName) - const user = await this.userService_.retrieve(userId) - - if (!user.metadata.roles) { - return this.userService_.setMetadata(userId, "roles", [roleName]) - } - - if (user.metadata.roles.includes(role.name)) { - throw new MedusaError( - MedusaError.Types.DB_ERROR, - `User already has role: ${role.name}` - ) - } - - user.metadata.roles.push(roleName) - return this.userService_.setMetadata(userId, "roles", user.metadata.roles) - } - - async revokeRole(userId, roleName) { - const user = await this.userService_.retrieve(userId) - - if (!user.metadata.roles || !user.metadata.roles.includes(roleName)) { - // revokeRole is idempotent, we return a promise to allow then-chaining - return Promise.resolve() - } - // remove role from metadata.roles - const newRoles = user.metadata.roles.filter((r) => r !== roleName) - - return this.userService_.setMetadata(userId, "roles", newRoles) - } -} - -export default PermissionService