From dc586a118581ad1b4bfe3b867fd396cdac0ebf7a Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Fri, 5 Mar 2021 14:13:43 +0000 Subject: [PATCH 1/8] middleware tests --- .../src/middleware/tests/selfhost.spec.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/server/src/middleware/tests/selfhost.spec.js diff --git a/packages/server/src/middleware/tests/selfhost.spec.js b/packages/server/src/middleware/tests/selfhost.spec.js new file mode 100644 index 0000000000..0f721bc890 --- /dev/null +++ b/packages/server/src/middleware/tests/selfhost.spec.js @@ -0,0 +1,43 @@ +const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles") +const { checkPermissionsEndpoint } = require("./utilities/TestFunctions") +const { basicUser } = require("./utilities/structures") +const setup = require("./utilities") + +describe("Self host middleware", () => { + let request = setup.getRequest() + let config = setup.getConfig() + + afterAll(setup.afterAll) + + beforeEach(async () => { + await config.init() + }) + + describe("fetch", () => { + it("returns a list of users from an instance db", async () => { + await config.createUser("brenda@brenda.com", "brendas_password") + await config.createUser("pam@pam.com", "pam_password") + const res = await request + .get(`/api/users`) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + + expect(res.body.length).toBe(2) + expect(res.body.find(u => u.email === "brenda@brenda.com")).toBeDefined() + expect(res.body.find(u => u.email === "pam@pam.com")).toBeDefined() + }) + + it("should apply authorization to endpoint", async () => { + await config.createUser("brenda@brenda.com", "brendas_password") + await checkPermissionsEndpoint({ + config, + request, + method: "GET", + url: `/api/users`, + passRole: BUILTIN_ROLE_IDS.ADMIN, + failRole: BUILTIN_ROLE_IDS.PUBLIC, + }) + }) + }) +}) From c429caf6a47d6becba61d48920a84455ea73d7c4 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Mon, 8 Mar 2021 15:46:12 +0000 Subject: [PATCH 2/8] self hosted middleware test --- .../src/middleware/tests/TestConfiguration.js | 247 ++++++++++++++++++ .../server/src/middleware/tests/authorized.js | 0 .../src/middleware/tests/selfhost.spec.js | 68 ++--- 3 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 packages/server/src/middleware/tests/TestConfiguration.js create mode 100644 packages/server/src/middleware/tests/authorized.js diff --git a/packages/server/src/middleware/tests/TestConfiguration.js b/packages/server/src/middleware/tests/TestConfiguration.js new file mode 100644 index 0000000000..8092130ce3 --- /dev/null +++ b/packages/server/src/middleware/tests/TestConfiguration.js @@ -0,0 +1,247 @@ +// const { BUILTIN_ROLE_IDS } = require("../../../../utilities/security/roles") +// const jwt = require("jsonwebtoken") +// const env = require("../../../../environment") +// const { +// basicTable, +// basicRow, +// basicRole, +// basicAutomation, +// basicDatasource, +// basicQuery, +// } = require("./structures") +// const controllers = require("./controllers") +// const supertest = require("supertest") + +// const EMAIL = "babs@babs.com" +// const PASSWORD = "babs_password" + +// class TestConfiguration { +// constructor() { +// env.PORT = 4002 +// this.server = require("../../../../app") +// // we need the request for logging in, involves cookies, hard to fake +// this.request = supertest(this.server) +// this.appId = null +// } + +// getRequest() { +// return this.request +// } + +// getAppId() { +// return this.appId +// } + +// async _req(config, params, controlFunc) { +// const request = {} +// // fake cookies, we don't need them +// request.cookies = { set: () => {}, get: () => {} } +// request.config = { jwtSecret: env.JWT_SECRET } +// request.appId = this.appId +// request.user = { appId: this.appId } +// request.request = { +// body: config, +// } +// if (params) { +// request.params = params +// } +// await controlFunc(request) +// return request.body +// } + +// async init(appName = "test_application") { +// return this.createApp(appName) +// } + +// end() { +// this.server.close() +// } + +// defaultHeaders() { +// const builderUser = { +// userId: "BUILDER", +// roleId: BUILTIN_ROLE_IDS.BUILDER, +// } +// const builderToken = jwt.sign(builderUser, env.JWT_SECRET) +// const headers = { +// Accept: "application/json", +// Cookie: [`budibase:builder:local=${builderToken}`], +// } +// if (this.appId) { +// headers["x-budibase-app-id"] = this.appId +// } +// return headers +// } + +// publicHeaders() { +// const headers = { +// Accept: "application/json", +// } +// if (this.appId) { +// headers["x-budibase-app-id"] = this.appId +// } +// return headers +// } + +// async callMiddleware() { +// this.middleware(this.ctx, next) +// return this.app +// } + +// async updateTable(config = null) { +// config = config || basicTable() +// this.table = await this._req(config, null, controllers.table.save) +// return this.table +// } + +// async createTable(config = null) { +// if (config != null && config._id) { +// delete config._id +// } +// return this.updateTable(config) +// } + +// async getTable(tableId = null) { +// tableId = tableId || this.table._id +// return this._req(null, { id: tableId }, controllers.table.find) +// } + +// async createLinkedTable() { +// if (!this.table) { +// throw "Must have created a table first." +// } +// const tableConfig = basicTable() +// tableConfig.primaryDisplay = "name" +// tableConfig.schema.link = { +// type: "link", +// fieldName: "link", +// tableId: this.table._id, +// } +// const linkedTable = await this.createTable(tableConfig) +// this.linkedTable = linkedTable +// return linkedTable +// } + +// async createAttachmentTable() { +// const table = basicTable() +// table.schema.attachment = { +// type: "attachment", +// } +// return this.createTable(table) +// } + +// async createRow(config = null) { +// if (!this.table) { +// throw "Test requires table to be configured." +// } +// config = config || basicRow(this.table._id) +// return this._req(config, { tableId: this.table._id }, controllers.row.save) +// } + +// async createRole(config = null) { +// config = config || basicRole() +// return this._req(config, null, controllers.role.save) +// } + +// async addPermission(roleId, resourceId, level = "read") { +// return this._req( +// null, +// { +// roleId, +// resourceId, +// level, +// }, +// controllers.perms.addPermission +// ) +// } + +// async createView(config) { +// if (!this.table) { +// throw "Test requires table to be configured." +// } +// const view = config || { +// map: "function(doc) { emit(doc[doc.key], doc._id); } ", +// tableId: this.table._id, +// } +// return this._req(view, null, controllers.view.save) +// } + +// async createAutomation(config) { +// config = config || basicAutomation() +// if (config._rev) { +// delete config._rev +// } +// this.automation = ( +// await this._req(config, null, controllers.automation.create) +// ).automation +// return this.automation +// } + +// async getAllAutomations() { +// return this._req(null, null, controllers.automation.fetch) +// } + +// async deleteAutomation(automation = null) { +// automation = automation || this.automation +// if (!automation) { +// return +// } +// return this._req( +// null, +// { id: automation._id, rev: automation._rev }, +// controllers.automation.destroy +// ) +// } + +// async createDatasource(config = null) { +// config = config || basicDatasource() +// this.datasource = await this._req(config, null, controllers.datasource.save) +// return this.datasource +// } + +// async createQuery(config = null) { +// if (!this.datasource && !config) { +// throw "No data source created for query." +// } +// config = config || basicQuery(this.datasource._id) +// return this._req(config, null, controllers.query.save) +// } + +// async createUser( +// email = EMAIL, +// password = PASSWORD, +// roleId = BUILTIN_ROLE_IDS.POWER +// ) { +// return this._req( +// { +// email, +// password, +// roleId, +// }, +// null, +// controllers.user.create +// ) +// } + +// async login(email, password) { +// if (!email || !password) { +// await this.createUser() +// email = EMAIL +// password = PASSWORD +// } +// const result = await this.request +// .post(`/api/authenticate`) +// .set({ +// "x-budibase-app-id": this.appId, +// }) +// .send({ email, password }) + +// // returning necessary request headers +// return { +// Accept: "application/json", +// Cookie: result.headers["set-cookie"], +// } +// } +// } + +// module.exports = TestConfiguration diff --git a/packages/server/src/middleware/tests/authorized.js b/packages/server/src/middleware/tests/authorized.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/server/src/middleware/tests/selfhost.spec.js b/packages/server/src/middleware/tests/selfhost.spec.js index 0f721bc890..9d66f44463 100644 --- a/packages/server/src/middleware/tests/selfhost.spec.js +++ b/packages/server/src/middleware/tests/selfhost.spec.js @@ -1,43 +1,43 @@ -const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles") -const { checkPermissionsEndpoint } = require("./utilities/TestFunctions") -const { basicUser } = require("./utilities/structures") -const setup = require("./utilities") +const selfHostMiddleware = require("../selfhost"); +const env = require("../../environment") +const hosting = require("../../utilities/builder/hosting") +jest.mock("../../environment") +jest.mock("../../utilities/builder/hosting") describe("Self host middleware", () => { - let request = setup.getRequest() - let config = setup.getConfig() + const next = jest.fn() + const throwMock = jest.fn() - afterAll(setup.afterAll) - - beforeEach(async () => { - await config.init() + afterEach(() => { + jest.clearAllMocks() }) - describe("fetch", () => { - it("returns a list of users from an instance db", async () => { - await config.createUser("brenda@brenda.com", "brendas_password") - await config.createUser("pam@pam.com", "pam_password") - const res = await request - .get(`/api/users`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + it("calls next() when CLOUD and SELF_HOSTED env vars are set", async () => { + env.CLOUD = 1 + env.SELF_HOSTED = 1 - expect(res.body.length).toBe(2) - expect(res.body.find(u => u.email === "brenda@brenda.com")).toBeDefined() - expect(res.body.find(u => u.email === "pam@pam.com")).toBeDefined() - }) + await selfHostMiddleware({}, next) + expect(next).toHaveBeenCalled() + }) - it("should apply authorization to endpoint", async () => { - await config.createUser("brenda@brenda.com", "brendas_password") - await checkPermissionsEndpoint({ - config, - request, - method: "GET", - url: `/api/users`, - passRole: BUILTIN_ROLE_IDS.ADMIN, - failRole: BUILTIN_ROLE_IDS.PUBLIC, - }) - }) + it("throws when hostingInfo type is cloud", async () => { + env.CLOUD = 0 + env.SELF_HOSTED = 0 + + hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.CLOUD })) + + await selfHostMiddleware({ throw: throwMock }, next) + expect(throwMock).toHaveBeenCalledWith(400, "Endpoint unavailable in cloud hosting.") + expect(next).not.toHaveBeenCalled() + }) + + it("calls the self hosting middleware to pass through to next() when the hostingInfo type is self", async () => { + env.CLOUD = 0 + env.SELF_HOSTED = 0 + + hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.SELF })) + + await selfHostMiddleware({}, next) + expect(next).toHaveBeenCalled() }) }) From 758e964977c54a418de9609cf7e358442f0769ae Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 11:27:12 +0000 Subject: [PATCH 3/8] tests for authorized middleware --- packages/server/src/middleware/authorized.js | 9 +- .../src/middleware/tests/TestConfiguration.js | 265 ++---------------- .../{authorized.js => authenticated.spec.js} | 0 .../src/middleware/tests/authorized.spec.js | 196 +++++++++++++ .../src/middleware/tests/resourceId.spec.js | 0 .../src/middleware/tests/selfhost.spec.js | 2 +- 6 files changed, 227 insertions(+), 245 deletions(-) rename packages/server/src/middleware/tests/{authorized.js => authenticated.spec.js} (100%) create mode 100644 packages/server/src/middleware/tests/authorized.spec.js create mode 100644 packages/server/src/middleware/tests/resourceId.spec.js diff --git a/packages/server/src/middleware/authorized.js b/packages/server/src/middleware/authorized.js index 7eac602f78..6d646d46fd 100644 --- a/packages/server/src/middleware/authorized.js +++ b/packages/server/src/middleware/authorized.js @@ -24,6 +24,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) { return next() } + if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) { // api key header passed by external webhook if (await isAPIKeyValid(ctx.headers["x-api-key"])) { @@ -37,14 +38,14 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { return next() } - ctx.throw(403, "API key invalid") + return ctx.throw(403, "API key invalid") } // don't expose builder endpoints in the cloud if (env.CLOUD && permType === PermissionTypes.BUILDER) return if (!ctx.user) { - ctx.throw(403, "No user info found") + return ctx.throw(403, "No user info found") } const role = ctx.user.role @@ -52,7 +53,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { ctx.appId, role._id ) - const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1 + const isAdmin = ADMIN_ROLES.includes(role._id) const isAuthed = ctx.auth.authenticated // this may need to change in the future, right now only admins @@ -61,7 +62,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { if (isAdmin && isAuthed) { return next() } else if (permType === PermissionTypes.BUILDER) { - ctx.throw(403, "Not Authorized") + return ctx.throw(403, "Not Authorized") } if ( diff --git a/packages/server/src/middleware/tests/TestConfiguration.js b/packages/server/src/middleware/tests/TestConfiguration.js index 8092130ce3..11d925e8f0 100644 --- a/packages/server/src/middleware/tests/TestConfiguration.js +++ b/packages/server/src/middleware/tests/TestConfiguration.js @@ -1,247 +1,32 @@ -// const { BUILTIN_ROLE_IDS } = require("../../../../utilities/security/roles") -// const jwt = require("jsonwebtoken") -// const env = require("../../../../environment") -// const { -// basicTable, -// basicRow, -// basicRole, -// basicAutomation, -// basicDatasource, -// basicQuery, -// } = require("./structures") -// const controllers = require("./controllers") -// const supertest = require("supertest") +let env = require("../../environment") -// const EMAIL = "babs@babs.com" -// const PASSWORD = "babs_password" +class TestConfiguration { + constructor(middleware) { + // env = config.env || {} + this.middleware = middleware + this.next = jest.fn() + this.throwMock = jest.fn() + } -// class TestConfiguration { -// constructor() { -// env.PORT = 4002 -// this.server = require("../../../../app") -// // we need the request for logging in, involves cookies, hard to fake -// this.request = supertest(this.server) -// this.appId = null -// } + callMiddleware(ctx, next) { + return this.middleware(ctx, next) + } -// getRequest() { -// return this.request -// } + clear() { + jest.clearAllMocks() + } -// getAppId() { -// return this.appId -// } + setEnv(config) { + env = config + } -// async _req(config, params, controlFunc) { -// const request = {} -// // fake cookies, we don't need them -// request.cookies = { set: () => {}, get: () => {} } -// request.config = { jwtSecret: env.JWT_SECRET } -// request.appId = this.appId -// request.user = { appId: this.appId } -// request.request = { -// body: config, -// } -// if (params) { -// request.params = params -// } -// await controlFunc(request) -// return request.body -// } + async init() { + // return this.createApp(appName) + } -// async init(appName = "test_application") { -// return this.createApp(appName) -// } + end() { + // this.server.close() + } +} -// end() { -// this.server.close() -// } - -// defaultHeaders() { -// const builderUser = { -// userId: "BUILDER", -// roleId: BUILTIN_ROLE_IDS.BUILDER, -// } -// const builderToken = jwt.sign(builderUser, env.JWT_SECRET) -// const headers = { -// Accept: "application/json", -// Cookie: [`budibase:builder:local=${builderToken}`], -// } -// if (this.appId) { -// headers["x-budibase-app-id"] = this.appId -// } -// return headers -// } - -// publicHeaders() { -// const headers = { -// Accept: "application/json", -// } -// if (this.appId) { -// headers["x-budibase-app-id"] = this.appId -// } -// return headers -// } - -// async callMiddleware() { -// this.middleware(this.ctx, next) -// return this.app -// } - -// async updateTable(config = null) { -// config = config || basicTable() -// this.table = await this._req(config, null, controllers.table.save) -// return this.table -// } - -// async createTable(config = null) { -// if (config != null && config._id) { -// delete config._id -// } -// return this.updateTable(config) -// } - -// async getTable(tableId = null) { -// tableId = tableId || this.table._id -// return this._req(null, { id: tableId }, controllers.table.find) -// } - -// async createLinkedTable() { -// if (!this.table) { -// throw "Must have created a table first." -// } -// const tableConfig = basicTable() -// tableConfig.primaryDisplay = "name" -// tableConfig.schema.link = { -// type: "link", -// fieldName: "link", -// tableId: this.table._id, -// } -// const linkedTable = await this.createTable(tableConfig) -// this.linkedTable = linkedTable -// return linkedTable -// } - -// async createAttachmentTable() { -// const table = basicTable() -// table.schema.attachment = { -// type: "attachment", -// } -// return this.createTable(table) -// } - -// async createRow(config = null) { -// if (!this.table) { -// throw "Test requires table to be configured." -// } -// config = config || basicRow(this.table._id) -// return this._req(config, { tableId: this.table._id }, controllers.row.save) -// } - -// async createRole(config = null) { -// config = config || basicRole() -// return this._req(config, null, controllers.role.save) -// } - -// async addPermission(roleId, resourceId, level = "read") { -// return this._req( -// null, -// { -// roleId, -// resourceId, -// level, -// }, -// controllers.perms.addPermission -// ) -// } - -// async createView(config) { -// if (!this.table) { -// throw "Test requires table to be configured." -// } -// const view = config || { -// map: "function(doc) { emit(doc[doc.key], doc._id); } ", -// tableId: this.table._id, -// } -// return this._req(view, null, controllers.view.save) -// } - -// async createAutomation(config) { -// config = config || basicAutomation() -// if (config._rev) { -// delete config._rev -// } -// this.automation = ( -// await this._req(config, null, controllers.automation.create) -// ).automation -// return this.automation -// } - -// async getAllAutomations() { -// return this._req(null, null, controllers.automation.fetch) -// } - -// async deleteAutomation(automation = null) { -// automation = automation || this.automation -// if (!automation) { -// return -// } -// return this._req( -// null, -// { id: automation._id, rev: automation._rev }, -// controllers.automation.destroy -// ) -// } - -// async createDatasource(config = null) { -// config = config || basicDatasource() -// this.datasource = await this._req(config, null, controllers.datasource.save) -// return this.datasource -// } - -// async createQuery(config = null) { -// if (!this.datasource && !config) { -// throw "No data source created for query." -// } -// config = config || basicQuery(this.datasource._id) -// return this._req(config, null, controllers.query.save) -// } - -// async createUser( -// email = EMAIL, -// password = PASSWORD, -// roleId = BUILTIN_ROLE_IDS.POWER -// ) { -// return this._req( -// { -// email, -// password, -// roleId, -// }, -// null, -// controllers.user.create -// ) -// } - -// async login(email, password) { -// if (!email || !password) { -// await this.createUser() -// email = EMAIL -// password = PASSWORD -// } -// const result = await this.request -// .post(`/api/authenticate`) -// .set({ -// "x-budibase-app-id": this.appId, -// }) -// .send({ email, password }) - -// // returning necessary request headers -// return { -// Accept: "application/json", -// Cookie: result.headers["set-cookie"], -// } -// } -// } - -// module.exports = TestConfiguration +module.exports = TestConfiguration diff --git a/packages/server/src/middleware/tests/authorized.js b/packages/server/src/middleware/tests/authenticated.spec.js similarity index 100% rename from packages/server/src/middleware/tests/authorized.js rename to packages/server/src/middleware/tests/authenticated.spec.js diff --git a/packages/server/src/middleware/tests/authorized.spec.js b/packages/server/src/middleware/tests/authorized.spec.js new file mode 100644 index 0000000000..d3e5e52d2d --- /dev/null +++ b/packages/server/src/middleware/tests/authorized.spec.js @@ -0,0 +1,196 @@ +const authorizedMiddleware = require("../authorized") +const env = require("../../environment") +const apiKey = require("../../utilities/security/apikey") +const { AuthTypes } = require("../../constants") +const { PermissionTypes, PermissionLevels } = require("../../utilities/security/permissions") +const { Test } = require("supertest") +jest.mock("../../environment") +jest.mock("../../utilities/security/apikey") + +class TestConfiguration { + constructor(role) { + this.middleware = authorizedMiddleware(role) + this.next = jest.fn() + this.throw = jest.fn() + this.ctx = { + headers: {}, + request: { + url: "" + }, + auth: {}, + next: this.next, + throw: this.throw + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + setUser(user) { + this.ctx.user = user + } + + setMiddlewareRequiredPermission(...perms) { + this.middleware = authorizedMiddleware(...perms) + } + + setResourceId(id) { + this.ctx.resourceId = id + } + + setAuthenticated(isAuthed) { + this.ctx.auth = { authenticated: isAuthed } + } + + setRequestUrl(url) { + this.ctx.request.url = url + } + + setCloudEnv(isCloud) { + env.CLOUD = isCloud + } + + setRequestHeaders(headers) { + this.ctx.headers = headers + } + + afterEach() { + jest.clearAllMocks() + } +} + + +describe("Authorization middleware", () => { + const next = jest.fn() + let config + + afterEach(() => { + config.afterEach() + }) + + beforeEach(() => { + config = new TestConfiguration() + }) + + it("passes the middleware for local webhooks", async () => { + config.setRequestUrl("https://something/webhooks/trigger") + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + describe("external web hook call", () => { + let ctx = {} + let middleware + + beforeEach(() => { + config = new TestConfiguration() + config.setCloudEnv(true) + config.setRequestHeaders({ + "x-api-key": "abc123", + "x-instanceid": "instance123", + }) + }) + + it("passes to next() if api key is valid", async () => { + apiKey.isAPIKeyValid.mockResolvedValueOnce(true) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.auth).toEqual({ + authenticated: AuthTypes.EXTERNAL, + apiKey: config.ctx.headers["x-api-key"], + }) + expect(config.ctx.user).toEqual({ + appId: config.ctx.headers["x-instanceid"], + }) + }) + + it("throws if api key is invalid", async () => { + apiKey.isAPIKeyValid.mockResolvedValueOnce(false) + + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "API key invalid") + }) + }) + + describe("non-webhook call", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + config.setCloudEnv(true) + config.setAuthenticated(true) + }) + + it("throws when no user data is present in context", async () => { + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "No user info found") + }) + + it("passes on to next() middleware if user is an admin", async () => { + config.setUser({ + role: { + _id: "ADMIN", + } + }) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user has only builder permissions", async () => { + config.setCloudEnv(false) + config.setMiddlewareRequiredPermission(PermissionTypes.BUILDER) + config.setUser({ + role: { + _id: "" + } + }) + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "Not Authorized") + }) + + it("passes on to next() middleware if the user has resource permission", async () => { + config.setResourceId(PermissionTypes.QUERY) + config.setUser({ + role: { + _id: "" + } + }) + config.setMiddlewareRequiredPermission(PermissionTypes.QUERY) + + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user session is not authenticated after permission checks", async () => { + config.setUser({ + role: { + _id: "" + }, + }) + config.setAuthenticated(false) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(403, "Session not authenticated") + }) + + it("throws if the user does not have base permissions to perform the operation", async () => { + config.setUser({ + role: { + _id: "" + }, + }) + config.setMiddlewareRequiredPermission(PermissionTypes.ADMIN, PermissionLevels.BASIC) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission") + }) + }) +}) diff --git a/packages/server/src/middleware/tests/resourceId.spec.js b/packages/server/src/middleware/tests/resourceId.spec.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/server/src/middleware/tests/selfhost.spec.js b/packages/server/src/middleware/tests/selfhost.spec.js index 9d66f44463..3601df89a2 100644 --- a/packages/server/src/middleware/tests/selfhost.spec.js +++ b/packages/server/src/middleware/tests/selfhost.spec.js @@ -1,6 +1,6 @@ const selfHostMiddleware = require("../selfhost"); const env = require("../../environment") -const hosting = require("../../utilities/builder/hosting") +const hosting = require("../../utilities/builder/hosting"); jest.mock("../../environment") jest.mock("../../utilities/builder/hosting") From c073b8639d9f30b1030cb922637baaa7aba3796c Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 11:33:16 +0000 Subject: [PATCH 4/8] refactor selfhost middleware tests to use TestConfiguration --- .../src/middleware/tests/TestConfiguration.js | 32 ---------- .../src/middleware/tests/selfhost.spec.js | 60 ++++++++++++++----- 2 files changed, 46 insertions(+), 46 deletions(-) delete mode 100644 packages/server/src/middleware/tests/TestConfiguration.js diff --git a/packages/server/src/middleware/tests/TestConfiguration.js b/packages/server/src/middleware/tests/TestConfiguration.js deleted file mode 100644 index 11d925e8f0..0000000000 --- a/packages/server/src/middleware/tests/TestConfiguration.js +++ /dev/null @@ -1,32 +0,0 @@ -let env = require("../../environment") - -class TestConfiguration { - constructor(middleware) { - // env = config.env || {} - this.middleware = middleware - this.next = jest.fn() - this.throwMock = jest.fn() - } - - callMiddleware(ctx, next) { - return this.middleware(ctx, next) - } - - clear() { - jest.clearAllMocks() - } - - setEnv(config) { - env = config - } - - async init() { - // return this.createApp(appName) - } - - end() { - // this.server.close() - } -} - -module.exports = TestConfiguration diff --git a/packages/server/src/middleware/tests/selfhost.spec.js b/packages/server/src/middleware/tests/selfhost.spec.js index 3601df89a2..061da17f9c 100644 --- a/packages/server/src/middleware/tests/selfhost.spec.js +++ b/packages/server/src/middleware/tests/selfhost.spec.js @@ -4,40 +4,72 @@ const hosting = require("../../utilities/builder/hosting"); jest.mock("../../environment") jest.mock("../../utilities/builder/hosting") +class TestConfiguration { + constructor() { + this.next = jest.fn() + this.throw = jest.fn() + this.middleware = selfHostMiddleware + + this.ctx = { + next: this.next, + throw: this.throw + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + setCloudHosted() { + env.CLOUD = 1 + env.SELF_HOSTED = 0 + } + + setSelfHosted() { + env.CLOUD = 0 + env.SELF_HOSTED = 1 + } + + afterEach() { + jest.clearAllMocks() + } +} + describe("Self host middleware", () => { - const next = jest.fn() - const throwMock = jest.fn() + let config + + beforeEach(() => { + config = new TestConfiguration() + }) afterEach(() => { - jest.clearAllMocks() + config.afterEach() }) it("calls next() when CLOUD and SELF_HOSTED env vars are set", async () => { env.CLOUD = 1 env.SELF_HOSTED = 1 - await selfHostMiddleware({}, next) - expect(next).toHaveBeenCalled() + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() }) it("throws when hostingInfo type is cloud", async () => { - env.CLOUD = 0 - env.SELF_HOSTED = 0 + config.setSelfHosted() hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.CLOUD })) - await selfHostMiddleware({ throw: throwMock }, next) - expect(throwMock).toHaveBeenCalledWith(400, "Endpoint unavailable in cloud hosting.") - expect(next).not.toHaveBeenCalled() + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(400, "Endpoint unavailable in cloud hosting.") + expect(config.next).not.toHaveBeenCalled() }) it("calls the self hosting middleware to pass through to next() when the hostingInfo type is self", async () => { - env.CLOUD = 0 - env.SELF_HOSTED = 0 + config.setSelfHosted() hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.SELF })) - await selfHostMiddleware({}, next) - expect(next).toHaveBeenCalled() + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() }) }) From 108f4861e36fff71454cc2bc4913c29ebe657439 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 12:39:32 +0000 Subject: [PATCH 5/8] resourceId tests --- packages/server/src/middleware/resourceId.js | 2 + .../src/middleware/tests/resourceId.spec.js | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/packages/server/src/middleware/resourceId.js b/packages/server/src/middleware/resourceId.js index 4351901dad..4216131119 100644 --- a/packages/server/src/middleware/resourceId.js +++ b/packages/server/src/middleware/resourceId.js @@ -36,6 +36,8 @@ class ResourceIdGetter { } } +module.exports.ResourceIdGetter = ResourceIdGetter + module.exports.paramResource = main => { return new ResourceIdGetter("params").mainResource(main).build() } diff --git a/packages/server/src/middleware/tests/resourceId.spec.js b/packages/server/src/middleware/tests/resourceId.spec.js index e69de29bb2..35e6e5af50 100644 --- a/packages/server/src/middleware/tests/resourceId.spec.js +++ b/packages/server/src/middleware/tests/resourceId.spec.js @@ -0,0 +1,105 @@ +const { + paramResource, + paramSubResource, + bodyResource, + bodySubResource, + ResourceIdGetter +} = require("../resourceId") + +class TestConfiguration { + constructor(middleware) { + this.middleware = middleware + this.ctx = { + request: {}, + } + this.next = jest.fn() + } + + setParams(params) { + this.ctx.params = params + } + + setBody(body) { + this.ctx.body = body + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } +} + +describe("resourceId middleware", () => { + it("calls next() when there is no request object to parse", () => { + const config = new TestConfiguration(paramResource("main")) + + config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.resourceId).toBeUndefined() + }) + + it("generates a resourceId middleware for context query parameters", () => { + const config = new TestConfiguration(paramResource("main")) + config.setParams({ + main: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context query sub parameters", () => { + const config = new TestConfiguration(paramSubResource("main", "sub")) + config.setParams({ + main: "main", + sub: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("main") + expect(config.ctx.subResourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context request body", () => { + const config = new TestConfiguration(bodyResource("main")) + config.setBody({ + main: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context request body sub fields", () => { + const config = new TestConfiguration(bodySubResource("main", "sub")) + config.setBody({ + main: "main", + sub: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("main") + expect(config.ctx.subResourceId).toEqual("test") + }) + + it("parses resourceIds correctly for custom middlewares", () => { + const middleware = new ResourceIdGetter("body") + .mainResource("custom") + .subResource("customSub") + .build() + config = new TestConfiguration(middleware) + config.setBody({ + custom: "test", + customSub: "subtest" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + expect(config.ctx.subResourceId).toEqual("subtest") + }) +}) \ No newline at end of file From 38e27b90035c16d59da86385ecb1f442cc5997f2 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 15:13:14 +0000 Subject: [PATCH 6/8] usageQuota tests --- .../src/middleware/tests/usageQuota.spec.js | 129 ++++++++++++++++++ packages/server/src/middleware/usageQuota.js | 1 + 2 files changed, 130 insertions(+) create mode 100644 packages/server/src/middleware/tests/usageQuota.spec.js diff --git a/packages/server/src/middleware/tests/usageQuota.spec.js b/packages/server/src/middleware/tests/usageQuota.spec.js new file mode 100644 index 0000000000..c76acb47d2 --- /dev/null +++ b/packages/server/src/middleware/tests/usageQuota.spec.js @@ -0,0 +1,129 @@ +const usageQuotaMiddleware = require("../usageQuota") +const usageQuota = require("../../utilities/usageQuota") +const CouchDB = require("../../db") +const env = require("../../environment") + +jest.mock("../../db"); +jest.mock("../../utilities/usageQuota") +jest.mock("../../environment") + +class TestConfiguration { + constructor() { + this.throw = jest.fn() + this.next = jest.fn() + this.middleware = usageQuotaMiddleware + this.ctx = { + throw: this.throw, + next: this.next, + user: { + appId: "test" + }, + request: { + body: {} + }, + req: { + method: "POST", + url: "/rows" + } + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + cloudHosted(bool) { + if (bool) { + env.CLOUD = 1 + this.ctx.auth = { apiKey: "test" } + } else { + env.CLOUD = 0 + } + } + + setMethod(method) { + this.ctx.req.method = method + } + + setUrl(url) { + this.ctx.req.url = url + } + + setBody(body) { + this.ctx.request.body = body + } + + setFiles(files) { + this.ctx.request.files = { file: files } + } +} + +describe("usageQuota middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + it("skips the middleware if there is no usage property or method", async () => { + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("passes through to next middleware if document already exists", async () => { + config.setBody({ + _id: "test" + }) + + CouchDB.mockImplementationOnce(() => ({ + get: async () => true + })) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.preExisting).toBe(true) + }) + + it("throws if request has _id, but the document no longer exists", async () => { + config.setBody({ + _id: "123" + }) + + CouchDB.mockImplementationOnce(() => ({ + get: async () => { + throw new Error() + } + })) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(404, `${config.ctx.request.body._id} does not exist`) + }) + + it("calculates and persists the correct usage quota for the relevant action", async () => { + config.setUrl("/rows") + config.cloudHosted(true) + + await config.executeMiddleware() + + expect(usageQuota.update).toHaveBeenCalledWith("test", "rows", 1) + expect(config.next).toHaveBeenCalled() + }) + + it("calculates the correct file size from a file upload call and adds it to quota", async () => { + config.setUrl("/upload") + config.cloudHosted(true) + config.setFiles([ + { + size: 100 + }, + { + size: 10000 + }, + ]) + await config.executeMiddleware() + + expect(usageQuota.update).toHaveBeenCalledWith("test", "storage", 10100) + expect(config.next).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/packages/server/src/middleware/usageQuota.js b/packages/server/src/middleware/usageQuota.js index e980afe678..1b809868be 100644 --- a/packages/server/src/middleware/usageQuota.js +++ b/packages/server/src/middleware/usageQuota.js @@ -43,6 +43,7 @@ module.exports = async (ctx, next) => { return } } + // if running in builder or a self hosted cloud usage quotas should not be executed if (!env.CLOUD || env.SELF_HOSTED) { return next() From 67c4a5ef6c7bad7073c1f1cff3c54fa4d5070695 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 17:04:24 +0000 Subject: [PATCH 7/8] authenticated tests --- .../middleware/tests/authenticated.spec.js | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/packages/server/src/middleware/tests/authenticated.spec.js b/packages/server/src/middleware/tests/authenticated.spec.js index e69de29bb2..799fbaf41b 100644 --- a/packages/server/src/middleware/tests/authenticated.spec.js +++ b/packages/server/src/middleware/tests/authenticated.spec.js @@ -0,0 +1,98 @@ +const { AuthTypes } = require("../../constants") +const authenticatedMiddleware = require("../authenticated") + +class TestConfiguration { + constructor(middleware) { + this.middleware = authenticatedMiddleware + this.ctx = { + auth: {}, + request: {}, + cookies: { + set: jest.fn(), + get: jest.fn() + }, + headers: {}, + params: {}, + path: "", + request: { + headers: {} + } + } + this.next = jest.fn() + } + + setHeaders(headers) { + this.ctx.headers = headers + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } +} + +describe("Authenticated middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + it("calls next() when on the builder path", async () => { + config.ctx.path = "/_builder" + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + }) + + it("sets a new cookie when the current cookie does not match the app id from context", async () => { + const appId = "app_123" + config.ctx.cookies.get.mockImplementationOnce(() => "cookieAppId") + config.setHeaders({ + "x-budibase-app-id": appId + }) + + await config.executeMiddleware() + + expect(config.ctx.cookies.set).toHaveBeenCalledWith( + "budibase:currentapp:local", + appId, + expect.any(Object) + ) + + }) + + fit("sets a BUILDER auth type when the x-budibase-type header is not 'client'", async () => { + config.ctx.cookies.get.mockImplementationOnce(() => `budibase:builder:local`) + + await config.executeMiddleware() + + expect(config.ctx.auth.authenticated).toEqual(AuthTypes.BUILDER) + }) + + it("assigns an APP auth type when the user is not in the builder", async () => { + config.setHeaders({ + "x-budibase-type": "client" + }) + config.ctx.cookies.get.mockImplementationOnce(() => `budibase:builder:local`) + + await config.executeMiddleware() + + expect(config.ctx.auth.authenticated).toEqual(AuthTypes.APP) + }) + + it("marks the user as unauthenticated when a token cannot be determined from the users cookie", async () => { + config.executeMiddleware() + expect() + }) + + it("verifies the users JWT token and sets the user information in context when successful", async () => { + config.executeMiddleware() + expect() + }) + + it("clears the cookie when there is an error authenticating in the builder", async () => { + config.executeMiddleware() + expect() + }) +}) \ No newline at end of file From 6263300a6857b2ea73f797e66fbbaa0b07c02d6e Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 9 Mar 2021 17:31:52 +0000 Subject: [PATCH 8/8] finish authenticated tests --- .../server/src/middleware/authenticated.js | 2 + .../__snapshots__/authenticated.spec.js.snap | 28 ++++++++++ .../middleware/tests/authenticated.spec.js | 56 ++++++++++++++----- 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap diff --git a/packages/server/src/middleware/authenticated.js b/packages/server/src/middleware/authenticated.js index 659baa8f6c..32ed3f63d0 100644 --- a/packages/server/src/middleware/authenticated.js +++ b/packages/server/src/middleware/authenticated.js @@ -31,6 +31,7 @@ module.exports = async (ctx, next) => { token = ctx.cookies.get(getCookieName()) authType = AuthTypes.BUILDER } + if (!token && appId) { token = ctx.cookies.get(getCookieName(appId)) authType = AuthTypes.APP @@ -58,6 +59,7 @@ module.exports = async (ctx, next) => { role: await getRole(appId, jwtPayload.roleId), } } catch (err) { + console.log(err) if (authType === AuthTypes.BUILDER) { clearCookie(ctx) ctx.status = 200 diff --git a/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap b/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap new file mode 100644 index 0000000000..1583ecb51f --- /dev/null +++ b/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Authenticated middleware sets the correct APP auth type information when the user is not in the builder 1`] = ` +Object { + "apiKey": "1234", + "appId": "budibase:app:local", + "role": Role { + "_id": "ADMIN", + "inherits": "POWER", + "name": "Admin", + "permissionId": "admin", + }, + "roleId": "ADMIN", +} +`; + +exports[`Authenticated middleware sets the correct BUILDER auth type information when the x-budibase-type header is not 'client' 1`] = ` +Object { + "apiKey": "1234", + "appId": "budibase:builder:local", + "role": Role { + "_id": "BUILDER", + "name": "Builder", + "permissionId": "admin", + }, + "roleId": "BUILDER", +} +`; diff --git a/packages/server/src/middleware/tests/authenticated.spec.js b/packages/server/src/middleware/tests/authenticated.spec.js index 799fbaf41b..bb124d2f4a 100644 --- a/packages/server/src/middleware/tests/authenticated.spec.js +++ b/packages/server/src/middleware/tests/authenticated.spec.js @@ -1,10 +1,13 @@ const { AuthTypes } = require("../../constants") const authenticatedMiddleware = require("../authenticated") +const jwt = require("jsonwebtoken") +jest.mock("jsonwebtoken") class TestConfiguration { constructor(middleware) { this.middleware = authenticatedMiddleware this.ctx = { + config: {}, auth: {}, request: {}, cookies: { @@ -16,7 +19,8 @@ class TestConfiguration { path: "", request: { headers: {} - } + }, + throw: jest.fn() } this.next = jest.fn() } @@ -28,6 +32,10 @@ class TestConfiguration { executeMiddleware() { return this.middleware(this.ctx, this.next) } + + afterEach() { + jest.resetAllMocks() + } } describe("Authenticated middleware", () => { @@ -37,6 +45,10 @@ describe("Authenticated middleware", () => { config = new TestConfiguration() }) + afterEach(() => { + config.afterEach() + }) + it("calls next() when on the builder path", async () => { config.ctx.path = "/_builder" @@ -47,10 +59,10 @@ describe("Authenticated middleware", () => { it("sets a new cookie when the current cookie does not match the app id from context", async () => { const appId = "app_123" - config.ctx.cookies.get.mockImplementationOnce(() => "cookieAppId") config.setHeaders({ "x-budibase-app-id": appId }) + config.ctx.cookies.get.mockImplementation(() => "cookieAppId") await config.executeMiddleware() @@ -62,37 +74,53 @@ describe("Authenticated middleware", () => { }) - fit("sets a BUILDER auth type when the x-budibase-type header is not 'client'", async () => { - config.ctx.cookies.get.mockImplementationOnce(() => `budibase:builder:local`) + it("sets the correct BUILDER auth type information when the x-budibase-type header is not 'client'", async () => { + config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local") + jwt.verify.mockImplementationOnce(() => ({ + apiKey: "1234", + roleId: "BUILDER" + })) await config.executeMiddleware() expect(config.ctx.auth.authenticated).toEqual(AuthTypes.BUILDER) + expect(config.ctx.user).toMatchSnapshot() }) - it("assigns an APP auth type when the user is not in the builder", async () => { + it("sets the correct APP auth type information when the user is not in the builder", async () => { config.setHeaders({ "x-budibase-type": "client" }) - config.ctx.cookies.get.mockImplementationOnce(() => `budibase:builder:local`) + config.ctx.cookies.get.mockImplementation(() => `budibase:app:local`) + jwt.verify.mockImplementationOnce(() => ({ + apiKey: "1234", + roleId: "ADMIN" + })) await config.executeMiddleware() expect(config.ctx.auth.authenticated).toEqual(AuthTypes.APP) + expect(config.ctx.user).toMatchSnapshot() }) it("marks the user as unauthenticated when a token cannot be determined from the users cookie", async () => { config.executeMiddleware() - expect() - }) - - it("verifies the users JWT token and sets the user information in context when successful", async () => { - config.executeMiddleware() - expect() + expect(config.ctx.auth.authenticated).toBe(false) + expect(config.ctx.user.role).toEqual({ + _id: "PUBLIC", + name: "Public", + permissionId: "public" + }) }) it("clears the cookie when there is an error authenticating in the builder", async () => { - config.executeMiddleware() - expect() + config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local") + jwt.verify.mockImplementationOnce(() => { + throw new Error() + }) + + await config.executeMiddleware() + + expect(config.ctx.cookies.set).toBeCalledWith("budibase:builder:local") }) }) \ No newline at end of file