Merge pull request #1266 from Budibase/middleware-tests

Middleware tests
This commit is contained in:
Martin McKeaveney 2021-03-10 15:28:11 +00:00 committed by GitHub
commit 39f5bdc184
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 669 additions and 4 deletions

View File

@ -31,6 +31,7 @@ module.exports = async (ctx, next) => {
token = ctx.cookies.get(getCookieName()) token = ctx.cookies.get(getCookieName())
authType = AuthTypes.BUILDER authType = AuthTypes.BUILDER
} }
if (!token && appId) { if (!token && appId) {
token = ctx.cookies.get(getCookieName(appId)) token = ctx.cookies.get(getCookieName(appId))
authType = AuthTypes.APP authType = AuthTypes.APP
@ -58,6 +59,7 @@ module.exports = async (ctx, next) => {
role: await getRole(appId, jwtPayload.roleId), role: await getRole(appId, jwtPayload.roleId),
} }
} catch (err) { } catch (err) {
console.log(err)
if (authType === AuthTypes.BUILDER) { if (authType === AuthTypes.BUILDER) {
clearCookie(ctx) clearCookie(ctx)
ctx.status = 200 ctx.status = 200

View File

@ -24,6 +24,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) { if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) {
return next() return next()
} }
if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) { if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) {
// api key header passed by external webhook // api key header passed by external webhook
if (await isAPIKeyValid(ctx.headers["x-api-key"])) { if (await isAPIKeyValid(ctx.headers["x-api-key"])) {
@ -37,14 +38,14 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
return next() return next()
} }
ctx.throw(403, "API key invalid") return ctx.throw(403, "API key invalid")
} }
// don't expose builder endpoints in the cloud // don't expose builder endpoints in the cloud
if (env.CLOUD && permType === PermissionTypes.BUILDER) return if (env.CLOUD && permType === PermissionTypes.BUILDER) return
if (!ctx.user) { if (!ctx.user) {
ctx.throw(403, "No user info found") return ctx.throw(403, "No user info found")
} }
const role = ctx.user.role const role = ctx.user.role
@ -52,7 +53,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
ctx.appId, ctx.appId,
role._id role._id
) )
const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1 const isAdmin = ADMIN_ROLES.includes(role._id)
const isAuthed = ctx.auth.authenticated const isAuthed = ctx.auth.authenticated
// this may need to change in the future, right now only admins // 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) { if (isAdmin && isAuthed) {
return next() return next()
} else if (permType === PermissionTypes.BUILDER) { } else if (permType === PermissionTypes.BUILDER) {
ctx.throw(403, "Not Authorized") return ctx.throw(403, "Not Authorized")
} }
if ( if (

View File

@ -36,6 +36,8 @@ class ResourceIdGetter {
} }
} }
module.exports.ResourceIdGetter = ResourceIdGetter
module.exports.paramResource = main => { module.exports.paramResource = main => {
return new ResourceIdGetter("params").mainResource(main).build() return new ResourceIdGetter("params").mainResource(main).build()
} }

View File

@ -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",
}
`;

View File

@ -0,0 +1,126 @@
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: {
set: jest.fn(),
get: jest.fn()
},
headers: {},
params: {},
path: "",
request: {
headers: {}
},
throw: jest.fn()
}
this.next = jest.fn()
}
setHeaders(headers) {
this.ctx.headers = headers
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
afterEach() {
jest.resetAllMocks()
}
}
describe("Authenticated middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
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.setHeaders({
"x-budibase-app-id": appId
})
config.ctx.cookies.get.mockImplementation(() => "cookieAppId")
await config.executeMiddleware()
expect(config.ctx.cookies.set).toHaveBeenCalledWith(
"budibase:currentapp:local",
appId,
expect.any(Object)
)
})
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("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.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(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.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")
})
})

View File

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

View File

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

View File

@ -0,0 +1,75 @@
const selfHostMiddleware = require("../selfhost");
const env = require("../../environment")
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", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when CLOUD and SELF_HOSTED env vars are set", async () => {
env.CLOUD = 1
env.SELF_HOSTED = 1
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("throws when hostingInfo type is cloud", async () => {
config.setSelfHosted()
hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.CLOUD }))
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 () => {
config.setSelfHosted()
hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.SELF }))
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
})

View File

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

View File

@ -43,6 +43,7 @@ module.exports = async (ctx, next) => {
return return
} }
} }
// if running in builder or a self hosted cloud usage quotas should not be executed // if running in builder or a self hosted cloud usage quotas should not be executed
if (!env.CLOUD || env.SELF_HOSTED) { if (!env.CLOUD || env.SELF_HOSTED) {
return next() return next()