Support path variable tenancy detection, add /api/system/* tests, update no tenancy matchers to be more accurate

This commit is contained in:
Rory Powell 2022-11-11 11:10:07 +00:00
parent ada0eb79bc
commit 0bad2dd9ae
50 changed files with 765 additions and 213 deletions

View File

@ -1,15 +1,16 @@
const { getGlobalUserParams, getAllApps } = require("../db/utils") import { getGlobalUserParams, getAllApps } from "../db/utils"
const { doWithDB } = require("../db") import { doWithDB } from "../db"
const { doWithGlobalDB } = require("../tenancy") import { doWithGlobalDB } from "../tenancy"
const { StaticDatabases } = require("../db/constants") import { StaticDatabases } from "../db/constants"
import { App, Tenants, User } from "@budibase/types"
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
const removeTenantFromInfoDB = async tenantId => { const removeTenantFromInfoDB = async (tenantId: string) => {
try { try {
await doWithDB(PLATFORM_INFO_DB, async infoDb => { await doWithDB(PLATFORM_INFO_DB, async (infoDb: any) => {
let tenants = await infoDb.get(TENANT_DOC) const tenants = (await infoDb.get(TENANT_DOC)) as Tenants
tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId) tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId)
await infoDb.put(tenants) await infoDb.put(tenants)
@ -20,14 +21,14 @@ const removeTenantFromInfoDB = async tenantId => {
} }
} }
exports.removeUserFromInfoDB = async dbUser => { export const removeUserFromInfoDB = async (dbUser: User) => {
await doWithDB(PLATFORM_INFO_DB, async infoDb => { await doWithDB(PLATFORM_INFO_DB, async (infoDb: any) => {
const keys = [dbUser._id, dbUser.email] const keys = [dbUser._id, dbUser.email]
const userDocs = await infoDb.allDocs({ const userDocs = await infoDb.allDocs({
keys, keys,
include_docs: true, include_docs: true,
}) })
const toDelete = userDocs.rows.map(row => { const toDelete = userDocs.rows.map((row: any) => {
return { return {
...row.doc, ...row.doc,
_deleted: true, _deleted: true,
@ -37,18 +38,18 @@ exports.removeUserFromInfoDB = async dbUser => {
}) })
} }
const removeUsersFromInfoDB = async tenantId => { const removeUsersFromInfoDB = async (tenantId: string) => {
return doWithGlobalDB(tenantId, async db => { return doWithGlobalDB(tenantId, async (db: any) => {
try { try {
const allUsers = await db.allDocs( const allUsers = await db.allDocs(
getGlobalUserParams(null, { getGlobalUserParams(null, {
include_docs: true, include_docs: true,
}) })
) )
await doWithDB(PLATFORM_INFO_DB, async infoDb => { await doWithDB(PLATFORM_INFO_DB, async (infoDb: any) => {
const allEmails = allUsers.rows.map(row => row.doc.email) const allEmails = allUsers.rows.map((row: any) => row.doc.email)
// get the id docs // get the id docs
let keys = allUsers.rows.map(row => row.id) let keys = allUsers.rows.map((row: any) => row.id)
// and the email docs // and the email docs
keys = keys.concat(allEmails) keys = keys.concat(allEmails)
// retrieve the docs and delete them // retrieve the docs and delete them
@ -56,7 +57,7 @@ const removeUsersFromInfoDB = async tenantId => {
keys, keys,
include_docs: true, include_docs: true,
}) })
const toDelete = userDocs.rows.map(row => { const toDelete = userDocs.rows.map((row: any) => {
return { return {
...row.doc, ...row.doc,
_deleted: true, _deleted: true,
@ -71,8 +72,8 @@ const removeUsersFromInfoDB = async tenantId => {
}) })
} }
const removeGlobalDB = async tenantId => { const removeGlobalDB = async (tenantId: string) => {
return doWithGlobalDB(tenantId, async db => { return doWithGlobalDB(tenantId, async (db: any) => {
try { try {
await db.destroy() await db.destroy()
} catch (err) { } catch (err) {
@ -82,11 +83,11 @@ const removeGlobalDB = async tenantId => {
}) })
} }
const removeTenantApps = async tenantId => { const removeTenantApps = async (tenantId: string) => {
try { try {
const apps = await getAllApps({ all: true }) const apps = (await getAllApps({ all: true })) as App[]
const destroyPromises = apps.map(app => const destroyPromises = apps.map(app =>
doWithDB(app.appId, db => db.destroy()) doWithDB(app.appId, (db: any) => db.destroy())
) )
await Promise.allSettled(destroyPromises) await Promise.allSettled(destroyPromises)
} catch (err) { } catch (err) {
@ -96,7 +97,7 @@ const removeTenantApps = async tenantId => {
} }
// can't live in tenancy package due to circular dependency on db/utils // can't live in tenancy package due to circular dependency on db/utils
exports.deleteTenant = async tenantId => { export const deleteTenant = async (tenantId: string) => {
await removeTenantFromInfoDB(tenantId) await removeTenantFromInfoDB(tenantId)
await removeUsersFromInfoDB(tenantId) await removeUsersFromInfoDB(tenantId)
await removeGlobalDB(tenantId) await removeGlobalDB(tenantId)

View File

@ -11,7 +11,7 @@ import env from "./environment"
import tenancy from "./tenancy" import tenancy from "./tenancy"
import featureFlags from "./featureFlags" import featureFlags from "./featureFlags"
import * as sessions from "./security/sessions" import * as sessions from "./security/sessions"
import deprovisioning from "./context/deprovision" import * as deprovisioning from "./context/deprovision"
import auth from "./auth" import auth from "./auth"
import constants from "./constants" import constants from "./constants"
import * as dbConstants from "./db/constants" import * as dbConstants from "./db/constants"

View File

@ -1,27 +1,35 @@
import { BBContext, EndpointMatcher, RegexMatcher } from "@budibase/types"
const PARAM_REGEX = /\/:(.*?)(\/.*)?$/g const PARAM_REGEX = /\/:(.*?)(\/.*)?$/g
exports.buildMatcherRegex = patterns => { export const buildMatcherRegex = (
patterns: EndpointMatcher[]
): RegexMatcher[] => {
if (!patterns) { if (!patterns) {
return [] return []
} }
return patterns.map(pattern => { return patterns.map(pattern => {
const isObj = typeof pattern === "object" && pattern.route let route = pattern.route
const method = isObj ? pattern.method : "GET" const method = pattern.method
const strict = pattern.strict ? pattern.strict : false const strict = pattern.strict ? pattern.strict : false
let route = isObj ? pattern.route : pattern
// if there is a param in the route
// use a wildcard pattern
const matches = route.match(PARAM_REGEX) const matches = route.match(PARAM_REGEX)
if (matches) { if (matches) {
for (let match of matches) { for (let match of matches) {
const pattern = "/.*" + (match.endsWith("/") ? "/" : "") const suffix = match.endsWith("/") ? "/" : ""
const pattern = "/.*" + suffix
route = route.replace(match, pattern) route = route.replace(match, pattern)
console.log(route)
} }
} }
return { regex: new RegExp(route), method, strict, route } return { regex: new RegExp(route), method, strict, route }
}) })
} }
exports.matches = (ctx, options) => { export const matches = (ctx: BBContext, options: RegexMatcher[]) => {
return options.find(({ regex, method, strict, route }) => { return options.find(({ regex, method, strict, route }) => {
let urlMatch let urlMatch
if (strict) { if (strict) {

View File

@ -1,5 +1,6 @@
import { doInTenant, getTenantIDFromCtx } from "../tenancy" import { doInTenant, getTenantIDFromCtx } from "../tenancy"
import { buildMatcherRegex, matches } from "./matchers" import { buildMatcherRegex, matches } from "./matchers"
import { Headers } from "../constants"
import { import {
BBContext, BBContext,
EndpointMatcher, EndpointMatcher,
@ -9,7 +10,7 @@ import {
const tenancy = ( const tenancy = (
allowQueryStringPatterns: EndpointMatcher[], allowQueryStringPatterns: EndpointMatcher[],
noTenancyPatterns: EndpointMatcher, noTenancyPatterns: EndpointMatcher[],
opts = { noTenancyRequired: false } opts = { noTenancyRequired: false }
) => { ) => {
const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns) const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns)
@ -28,6 +29,7 @@ const tenancy = (
} }
const tenantId = getTenantIDFromCtx(ctx, tenantOpts) const tenantId = getTenantIDFromCtx(ctx, tenantOpts)
ctx.set(Headers.TENANT_ID, tenantId as string)
return doInTenant(tenantId, next) return doInTenant(tenantId, next)
} }
} }

View File

@ -214,6 +214,7 @@ export const getTenantIDFromCtx = (
// e.g. tenant.budibase.app or tenant.local.com // e.g. tenant.budibase.app or tenant.local.com
const requestHost = ctx.host const requestHost = ctx.host
// parse the tenant id from the difference // parse the tenant id from the difference
if (requestHost.includes(platformHost)) {
const tenantId = requestHost.substring( const tenantId = requestHost.substring(
0, 0,
requestHost.indexOf(`.${platformHost}`) requestHost.indexOf(`.${platformHost}`)
@ -222,6 +223,24 @@ export const getTenantIDFromCtx = (
return tenantId return tenantId
} }
} }
}
if (isAllowed(TenantResolutionStrategy.PATH)) {
// params - have to parse manually due to koa-router not run yet
const match = ctx.matched.find(
(m: any) => !!m.paramNames.find((p: any) => p.name === "tenantId")
)
if (match) {
const params = match.params(
ctx.originalUrl,
match.captures(ctx.originalUrl),
{}
)
if (params.tenantId) {
return params.tenantId
}
}
}
if (!opts.allowNoTenant) { if (!opts.allowNoTenant) {
ctx.throw(403, "Tenant id not set") ctx.throw(403, "Tenant id not set")

View File

@ -8,7 +8,12 @@ import userCache from "./cache/user"
import { getSessionsForUser, invalidateSessions } from "./security/sessions" import { getSessionsForUser, invalidateSessions } from "./security/sessions"
import * as events from "./events" import * as events from "./events"
import tenancy from "./tenancy" import tenancy from "./tenancy"
import { App, BBContext, TenantResolutionStrategy } from "@budibase/types" import {
App,
BBContext,
PlatformLogoutOpts,
TenantResolutionStrategy,
} from "@budibase/types"
import { SetOption } from "cookies" import { SetOption } from "cookies"
const APP_PREFIX = DocumentType.APP + SEPARATOR const APP_PREFIX = DocumentType.APP + SEPARATOR
@ -189,12 +194,6 @@ export const getBuildersCount = async () => {
return builders.length return builders.length
} }
interface PlatformLogoutOpts {
ctx: BBContext
userId: string
keepActiveSession: boolean
}
/** /**
* Logs a user out from budibase. Re-used across account portal and builder. * Logs a user out from budibase. Re-used across account portal and builder.
*/ */

View File

@ -1,2 +1,3 @@
export * as mocks from "./mocks" export * as mocks from "./mocks"
export * as structures from "./structures" export * as structures from "./structures"
export { generator } from "./structures"

View File

@ -1,7 +1,9 @@
export const getAccount = jest.fn() export const getAccount = jest.fn()
export const getAccountByTenantId = jest.fn() export const getAccountByTenantId = jest.fn()
export const getStatus = jest.fn()
jest.mock("../../../src/cloud/accounts", () => ({ jest.mock("../../../src/cloud/accounts", () => ({
getAccount, getAccount,
getAccountByTenantId, getAccountByTenantId,
getStatus,
})) }))

View File

@ -1,23 +1,29 @@
import { generator, uuid } from "." import { generator, uuid } from "."
import { AuthType, CloudAccount, Hosting } from "@budibase/types"
import * as db from "../../../src/db/utils" import * as db from "../../../src/db/utils"
import { Account, AuthType, CloudAccount, Hosting } from "@budibase/types"
export const cloudAccount = (): CloudAccount => { export const account = (): Account => {
return { return {
accountId: uuid(), accountId: uuid(),
tenantId: generator.word(),
email: generator.email(),
tenantName: generator.word(),
hosting: Hosting.SELF,
createdAt: Date.now(), createdAt: Date.now(),
verified: true, verified: true,
verificationSent: true, verificationSent: true,
tier: "", tier: "FREE", // DEPRECATED
email: generator.email(),
tenantId: generator.word(),
hosting: Hosting.CLOUD,
authType: AuthType.PASSWORD, authType: AuthType.PASSWORD,
password: generator.word(),
tenantName: generator.word(),
name: generator.name(), name: generator.name(),
size: "10+", size: "10+",
profession: "Software Engineer", profession: "Software Engineer",
}
}
export const cloudAccount = (): CloudAccount => {
return {
...account(),
hosting: Hosting.CLOUD,
budibaseUserId: db.generateGlobalUserID(), budibaseUserId: db.generateGlobalUserID(),
} }
} }

View File

@ -1,3 +1,4 @@
export * from "./info" export * from "./info"
export * from "./users" export * from "./users"
export * from "./accounts" export * from "./accounts"
export * from "./tenants"

View File

@ -0,0 +1,5 @@
import { Document } from "../document"
export interface Tenants extends Document {
tenantIds: string[]
}

View File

@ -1,3 +1,5 @@
import { BBContext } from "./koa"
export interface AuthToken { export interface AuthToken {
userId: string userId: string
tenantId: string tenantId: string
@ -25,3 +27,9 @@ export interface SessionKey {
export interface ScannedSession { export interface ScannedSession {
value: Session value: Session
} }
export interface PlatformLogoutOpts {
ctx: BBContext
userId: string
keepActiveSession: boolean
}

View File

@ -1,4 +1,22 @@
export interface EndpointMatcher { export interface EndpointMatcher {
/**
* The HTTP Path. e.g. /api/things/:thingId
*/
route: string route: string
/**
* The HTTP Verb. e.g. GET, POST, etc.
* ALL is also accepted to cover all verbs.
*/
method: string method: string
/**
* The route must match exactly - not just begins with
*/
strict?: boolean
}
export interface RegexMatcher {
regex: RegExp
method: string
strict: boolean
route: string
} }

View File

@ -9,4 +9,5 @@ export enum TenantResolutionStrategy {
HEADER = "header", HEADER = "header",
QUERY = "query", QUERY = "query",
SUBDOMAIN = "subdomain", SUBDOMAIN = "subdomain",
PATH = "path",
} }

View File

@ -1,10 +1,13 @@
const env = require("../src/environment") const env = require("../src/environment")
env._set("SELF_HOSTED", "1") env._set("SELF_HOSTED", "0")
env._set("NODE_ENV", "jest") env._set("NODE_ENV", "jest")
env._set("JWT_SECRET", "test-jwtsecret") env._set("JWT_SECRET", "test-jwtsecret")
env._set("LOG_LEVEL", "silent") env._set("LOG_LEVEL", "silent")
env._set("MULTI_TENANCY", true) env._set("MULTI_TENANCY", true)
env._set("PLATFORM_URL", "http://localhost:10000")
env._set("INTERNAL_API_KEY", "test")
env._set("DISABLE_ACCOUNT_PORTAL", false)
const { mocks } = require("@budibase/backend-core/tests") const { mocks } = require("@budibase/backend-core/tests")

View File

@ -1,6 +1,7 @@
const env = require("../../../environment") import { BBContext } from "@budibase/types"
import env from "../../../environment"
exports.fetch = async ctx => { export const fetch = async (ctx: BBContext) => {
ctx.body = { ctx.body = {
multiTenancy: !!env.MULTI_TENANCY, multiTenancy: !!env.MULTI_TENANCY,
cloud: !env.SELF_HOSTED, cloud: !env.SELF_HOSTED,

View File

@ -1,7 +1,8 @@
const accounts = require("@budibase/backend-core/accounts") import { accounts } from "@budibase/backend-core"
const env = require("../../../environment") import env from "../../../environment"
import { BBContext } from "@budibase/types"
exports.fetch = async ctx => { export const fetch = async (ctx: BBContext) => {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const status = await accounts.getStatus() const status = await accounts.getStatus()
ctx.body = status ctx.body = status

View File

@ -1,60 +1,17 @@
const { StaticDatabases, doWithDB } = require("@budibase/backend-core/db") import { BBContext } from "@budibase/types"
const { getTenantId } = require("@budibase/backend-core/tenancy") import { deprovisioning } from "@budibase/backend-core"
const { deleteTenant } = require("@budibase/backend-core/deprovision")
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
export const exists = async (ctx: any) => { const _delete = async (ctx: BBContext) => {
const tenantId = ctx.request.params const user = ctx.user!
ctx.body = { const tenantId = ctx.params.tenantId
exists: await doWithDB(
StaticDatabases.PLATFORM_INFO.name,
async (db: any) => {
let exists = false
try {
const tenantsDoc = await db.get(
StaticDatabases.PLATFORM_INFO.docs.tenants
)
if (tenantsDoc) {
exists = tenantsDoc.tenantIds.indexOf(tenantId) !== -1
}
} catch (err) {
// if error it doesn't exist
}
return exists
}
),
}
}
export const fetch = async (ctx: any) => { if (tenantId !== user.tenantId) {
ctx.body = await doWithDB( ctx.throw(403, "Tenant ID does not match current user")
StaticDatabases.PLATFORM_INFO.name,
async (db: any) => {
let tenants = []
try {
const tenantsDoc = await db.get(
StaticDatabases.PLATFORM_INFO.docs.tenants
)
if (tenantsDoc) {
tenants = tenantsDoc.tenantIds
}
} catch (err) {
// if error it doesn't exist
}
return tenants
}
)
}
const _delete = async (ctx: any) => {
const tenantId = getTenantId()
if (ctx.params.tenantId !== tenantId) {
ctx.throw(403, "Unauthorized")
} }
try { try {
await deleteTenant(tenantId) await deprovisioning.deleteTenant(tenantId)
await quotas.bustCache() await quotas.bustCache()
ctx.status = 204 ctx.status = 204
} catch (err) { } catch (err) {

View File

@ -7,11 +7,12 @@ import { errors, auth, middleware } from "@budibase/backend-core"
import { APIError } from "@budibase/types" import { APIError } from "@budibase/types"
const PUBLIC_ENDPOINTS = [ const PUBLIC_ENDPOINTS = [
// old deprecated endpoints kept for backwards compat // deprecated single tenant sso callback
{ {
route: "/api/admin/auth/google/callback", route: "/api/admin/auth/google/callback",
method: "GET", method: "GET",
}, },
// deprecated single tenant sso callback
{ {
route: "/api/admin/auth/oidc/callback", route: "/api/admin/auth/oidc/callback",
method: "GET", method: "GET",
@ -51,10 +52,12 @@ const PUBLIC_ENDPOINTS = [
route: "api/system/status", route: "api/system/status",
method: "GET", method: "GET",
}, },
// TODO: This should be an internal api
{ {
route: "/api/global/users/tenant/:id", route: "/api/global/users/tenant/:id",
method: "GET", method: "GET",
}, },
// TODO: This should be an internal api
{ {
route: "/api/system/restored", route: "/api/system/restored",
method: "POST", method: "POST",
@ -62,17 +65,37 @@ const PUBLIC_ENDPOINTS = [
] ]
const NO_TENANCY_ENDPOINTS = [ const NO_TENANCY_ENDPOINTS = [
...PUBLIC_ENDPOINTS, // system endpoints are not specific to any tenant
{ {
route: "/api/system", route: "/api/system",
method: "ALL", method: "ALL",
}, },
// tenant is determined in request body
// used for creating the tenant
{ {
route: "/api/global/users/self", route: "/api/global/users/init",
method: "POST",
},
// deprecated single tenant sso callback
{
route: "/api/admin/auth/google/callback",
method: "GET", method: "GET",
}, },
// deprecated single tenant sso callback
{ {
route: "/api/global/self", route: "/api/admin/auth/oidc/callback",
method: "GET",
},
// tenant is determined from code in redis
{
route: "/api/global/users/invite/accept",
method: "POST",
},
// global user search - no tenancy
// :id is user id
// TODO: this should really be `/api/system/users/:id`
{
route: "/api/global/users/tenant/:id",
method: "GET", method: "GET",
}, },
] ]

View File

@ -2,7 +2,6 @@ const Router = require("@koa/router")
const authController = require("../../controllers/global/auth") const authController = require("../../controllers/global/auth")
const { joiValidator } = require("@budibase/backend-core/auth") const { joiValidator } = require("@budibase/backend-core/auth")
const Joi = require("joi") const Joi = require("joi")
const { updateTenantId } = require("@budibase/backend-core/tenancy")
const router = new Router() const router = new Router()
@ -29,43 +28,28 @@ function buildResetUpdateValidation() {
}).required().unknown(false)) }).required().unknown(false))
} }
function updateTenant(ctx, next) {
if (ctx.params) {
updateTenantId(ctx.params.tenantId)
}
return next()
}
router router
.post( .post(
"/api/global/auth/:tenantId/login", "/api/global/auth/:tenantId/login",
buildAuthValidation(), buildAuthValidation(),
updateTenant,
authController.authenticate authController.authenticate
) )
.post( .post(
"/api/global/auth/:tenantId/reset", "/api/global/auth/:tenantId/reset",
buildResetValidation(), buildResetValidation(),
updateTenant,
authController.reset authController.reset
) )
.post( .post(
"/api/global/auth/:tenantId/reset/update", "/api/global/auth/:tenantId/reset/update",
buildResetUpdateValidation(), buildResetUpdateValidation(),
updateTenant,
authController.resetUpdate authController.resetUpdate
) )
.post("/api/global/auth/logout", authController.logout) .post("/api/global/auth/logout", authController.logout)
.post("/api/global/auth/init", authController.setInitInfo) .post("/api/global/auth/init", authController.setInitInfo)
.get("/api/global/auth/init", authController.getInitInfo) .get("/api/global/auth/init", authController.getInitInfo)
.get( .get("/api/global/auth/:tenantId/google", authController.googlePreAuth)
"/api/global/auth/:tenantId/google",
updateTenant,
authController.googlePreAuth
)
.get( .get(
"/api/global/auth/:tenantId/datasource/:provider", "/api/global/auth/:tenantId/datasource/:provider",
updateTenant,
authController.datasourcePreAuth authController.datasourcePreAuth
) )
// single tenancy endpoint // single tenancy endpoint
@ -75,29 +59,19 @@ router
authController.datasourceAuth authController.datasourceAuth
) )
// multi-tenancy endpoint // multi-tenancy endpoint
.get( .get("/api/global/auth/:tenantId/google/callback", authController.googleAuth)
"/api/global/auth/:tenantId/google/callback",
updateTenant,
authController.googleAuth
)
.get( .get(
"/api/global/auth/:tenantId/datasource/:provider/callback", "/api/global/auth/:tenantId/datasource/:provider/callback",
updateTenant,
authController.datasourceAuth authController.datasourceAuth
) )
.get( .get(
"/api/global/auth/:tenantId/oidc/configs/:configId", "/api/global/auth/:tenantId/oidc/configs/:configId",
updateTenant,
authController.oidcPreAuth authController.oidcPreAuth
) )
// single tenancy endpoint // single tenancy endpoint
.get("/api/global/auth/oidc/callback", authController.oidcAuth) .get("/api/global/auth/oidc/callback", authController.oidcAuth)
// multi-tenancy endpoint // multi-tenancy endpoint
.get( .get("/api/global/auth/:tenantId/oidc/callback", authController.oidcAuth)
"/api/global/auth/:tenantId/oidc/callback",
updateTenant,
authController.oidcAuth
)
// deprecated - used by the default system before tenancy // deprecated - used by the default system before tenancy
.get("/api/admin/auth/google/callback", authController.googleAuth) .get("/api/admin/auth/google/callback", authController.googleAuth)
.get("/api/admin/auth/oidc/callback", authController.oidcAuth) .get("/api/admin/auth/oidc/callback", authController.oidcAuth)

View File

@ -235,7 +235,7 @@ describe("configs", () => {
expect(events.org.nameUpdated).toBeCalledTimes(1) expect(events.org.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).toBeCalledTimes(1) expect(events.org.platformURLUpdated).toBeCalledTimes(1)
config.modeAccount() config.modeCloud()
}) })
}) })
@ -257,7 +257,7 @@ describe("configs", () => {
expect(events.org.nameUpdated).toBeCalledTimes(1) expect(events.org.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).toBeCalledTimes(1) expect(events.org.platformURLUpdated).toBeCalledTimes(1)
config.modeAccount() config.modeCloud()
}) })
}) })
}) })

View File

@ -0,0 +1,28 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/global/license", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("POST /api/global/license/activate", () => {})
describe("POST /api/global/license/refresh", () => {})
describe("GET /api/global/license/info", () => {})
describe("DELETE /api/global/license/info", () => {})
describe("GET /api/global/license/usage", () => {})
})

View File

@ -0,0 +1,24 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/global/roles", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("GET /api/global/roles", () => {})
describe("GET /api/global/roles/:appId", () => {})
describe("DELETE /api/global/roles/:appId", () => {})
})

View File

@ -0,0 +1,32 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/global/template", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("GET /api/global/template/definitions", () => {})
describe("POST /api/global/template", () => {})
describe("GET /api/global/template", () => {})
describe("GET /api/global/template/:type", () => {})
describe("GET /api/global/template/:ownerId", () => {})
describe("GET /api/global/template/:id", () => {})
describe("DELETE /api/global/template/:id/:rev", () => {})
})

View File

@ -0,0 +1,26 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/global/workspaces", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("GET /api/global/workspaces", () => {})
describe("DELETE /api/global/workspaces/:id", () => {})
describe("GET /api/global/workspaces", () => {})
describe("GET /api/global/workspaces/:id", () => {})
})

View File

@ -1,8 +0,0 @@
const Router = require("@koa/router")
const controller = require("../../controllers/system/environment")
const router = new Router()
router.get("/api/system/environment", controller.fetch)
module.exports = router

View File

@ -0,0 +1,8 @@
import Router from "@koa/router"
import * as controller from "../../controllers/system/environment"
const router = new Router()
router.get("/api/system/environment", controller.fetch)
export default router

View File

@ -1,8 +0,0 @@
const Router = require("@koa/router")
const controller = require("../../controllers/system/status")
const router = new Router()
router.get("/api/system/status", controller.fetch)
module.exports = router

View File

@ -0,0 +1,8 @@
import Router from "@koa/router"
import * as controller from "../../controllers/system/status"
const router = new Router()
router.get("/api/system/status", controller.fetch)
export default router

View File

@ -1,12 +0,0 @@
const Router = require("@koa/router")
const controller = require("../../controllers/system/tenants")
const { adminOnly } = require("@budibase/backend-core/auth")
const router = new Router()
router
.get("/api/system/tenants/:tenantId/exists", controller.exists)
.get("/api/system/tenants", adminOnly, controller.fetch)
.delete("/api/system/tenants/:tenantId", adminOnly, controller.delete)
module.exports = router

View File

@ -0,0 +1,13 @@
import Router from "@koa/router"
import * as controller from "../../controllers/system/tenants"
import { middleware } from "@budibase/backend-core"
const router = new Router()
router.delete(
"/api/system/tenants/:tenantId",
middleware.adminOnly,
controller.delete
)
export default router

View File

@ -0,0 +1,30 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/system/environment", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("GET /api/system/environment", () => {
it("returns the expected environment", async () => {
const env = await api.environment.getEnvironment()
expect(env.body).toEqual({
cloud: true,
disableAccountPortal: false,
isDev: false,
multiTenancy: true,
})
})
})
})

View File

@ -0,0 +1,64 @@
const migrateFn = jest.fn()
import { TestConfiguration, API } from "../../../../tests"
jest.mock("../../../../migrations", () => {
return {
...jest.requireActual("../../../../migrations"),
migrate: migrateFn,
}
})
describe("/api/system/migrations", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("POST /api/system/migrations/run", () => {
it("fails with no internal api key", async () => {
const res = await api.migrations.runMigrations({
headers: {},
status: 403,
})
expect(res.text).toBe("Unauthorized - no public worker access")
expect(migrateFn).toBeCalledTimes(0)
})
it("runs migrations", async () => {
const res = await api.migrations.runMigrations()
expect(res.text).toBe("OK")
expect(migrateFn).toBeCalledTimes(1)
})
})
describe("DELETE /api/system/migrations/definitions", () => {
it("fails with no internal api key", async () => {
const res = await api.migrations.getMigrationDefinitions({
headers: {},
status: 403,
})
expect(res.text).toBe("Unauthorized - no public worker access")
})
it("returns definitions", async () => {
const res = await api.migrations.getMigrationDefinitions()
expect(res.body).toEqual([
{
name: "global_info_sync_users",
type: "global",
},
])
})
})
})

View File

@ -0,0 +1,37 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/system/restore", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("POST /api/global/restore", () => {
it("doesn't allow restore in cloud", async () => {
const res = await api.restore.restored({ status: 405 })
expect(res.body).toEqual({
message: "This operation is not allowed in cloud.",
status: 405,
})
})
it("restores in self host", async () => {
config.modeSelf()
const res = await api.restore.restored()
expect(res.body).toEqual({
message: "System prepared after restore.",
})
config.modeCloud()
})
})
})

View File

@ -0,0 +1,49 @@
import { TestConfiguration, API } from "../../../../tests"
import { accounts } from "@budibase/backend-core"
import { mocks } from "@budibase/backend-core/tests"
describe("/api/system/status", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("GET /api/system/status", () => {
it("returns status in self host", async () => {
config.modeSelf()
const res = await api.status.getStatus()
expect(res.body).toEqual({
health: {
passing: true,
},
})
expect(accounts.getStatus).toBeCalledTimes(0)
config.modeCloud()
})
it("returns status in cloud", async () => {
const value = {
health: {
passing: false,
},
}
mocks.accounts.getStatus.mockReturnValueOnce(value)
const res = await api.status.getStatus()
expect(accounts.getStatus).toBeCalledTimes(1)
expect(res.body).toEqual(value)
})
})
})

View File

@ -0,0 +1,32 @@
import { TestConfiguration, API } from "../../../../tests"
describe("/api/global/workspaces", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
describe("DELETE /api/system/tenants/:tenantId", () => {
it("allows deleting the current tenant", async () => {
const user = await config.createTenant()
await config.createSession(user)
const res = await api.tenants.delete(user.tenantId, {
headers: config.authHeaders(user),
})
})
it("rejects deleting another tenant", () => {})
it("requires admin", () => {})
})
})

View File

@ -0,0 +1,74 @@
import { TestConfiguration, API, structures } from "../../tests"
import { constants } from "@budibase/backend-core"
describe("tenancy middleware", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.clearAllMocks()
})
it("should get tenant id from user", async () => {
const user = await config.createTenant()
await config.createSession(user)
const res = await api.self.getSelf(user)
expect(res.headers[constants.Headers.TENANT_ID]).toBe(user.tenantId)
})
it("should get tenant id from header", async () => {
const tenantId = structures.uuid()
const headers = {
[constants.Headers.TENANT_ID]: tenantId,
}
const res = await config.request
.get(`/api/global/configs/checklist`)
.set(headers)
expect(res.headers[constants.Headers.TENANT_ID]).toBe(tenantId)
})
it("should get tenant id from query param", async () => {
const tenantId = structures.uuid()
const res = await config.request.get(
`/api/global/configs/checklist?tenantId=${tenantId}`
)
expect(res.headers[constants.Headers.TENANT_ID]).toBe(tenantId)
})
it("should get tenant id from subdomain", async () => {
const tenantId = structures.uuid()
const headers = {
host: `${tenantId}.localhost:10000`,
}
const res = await config.request
.get(`/api/global/configs/checklist`)
.set(headers)
expect(res.headers[constants.Headers.TENANT_ID]).toBe(tenantId)
})
it("should get tenant id from path variable", async () => {
const user = await config.createTenant()
const res = await config.request
.post(`/api/global/auth/${user.tenantId}/login`)
.send({
username: user.email,
password: user.password,
})
expect(res.headers[constants.Headers.TENANT_ID]).toBe(user.tenantId)
})
it("should throw when no tenant id is found", async () => {
const res = await config.request.get(`/api/global/configs/checklist`)
expect(res.status).toBe(403)
expect(res.text).toBe("Tenant id not set")
expect(res.headers[constants.Headers.TENANT_ID]).toBe(undefined)
})
})

View File

@ -2,6 +2,7 @@ import "./mocks"
import dbConfig from "../db" import dbConfig from "../db"
dbConfig.init() dbConfig.init()
import env from "../environment" import env from "../environment"
import { env as coreEnv } from "@budibase/backend-core"
import controllers from "./controllers" import controllers from "./controllers"
const supertest = require("supertest") const supertest = require("supertest")
import { Configs } from "../constants" import { Configs } from "../constants"
@ -12,13 +13,13 @@ import {
Headers, Headers,
sessions, sessions,
auth, auth,
constants,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { TENANT_ID, TENANT_1, CSRF_TOKEN } from "./structures" import structures, { TENANT_ID, TENANT_1, CSRF_TOKEN } from "./structures"
import structures from "./structures"
import { CreateUserResponse, User, AuthToken } from "@budibase/types" import { CreateUserResponse, User, AuthToken } from "@budibase/types"
enum Mode { enum Mode {
ACCOUNT = "account", CLOUD = "cloud",
SELF = "self", SELF = "self",
} }
@ -31,11 +32,11 @@ class TestConfiguration {
constructor( constructor(
opts: { openServer: boolean; mode: Mode } = { opts: { openServer: boolean; mode: Mode } = {
openServer: true, openServer: true,
mode: Mode.ACCOUNT, mode: Mode.CLOUD,
} }
) { ) {
if (opts.mode === Mode.ACCOUNT) { if (opts.mode === Mode.CLOUD) {
this.modeAccount() this.modeCloud()
} else if (opts.mode === Mode.SELF) { } else if (opts.mode === Mode.SELF) {
this.modeSelf() this.modeSelf()
} }
@ -54,20 +55,24 @@ class TestConfiguration {
// MODES // MODES
modeAccount = () => { setMultiTenancy = (value: boolean) => {
env.SELF_HOSTED = false env._set("MULTI_TENANCY", value)
// @ts-ignore coreEnv._set("MULTI_TENANCY", value)
env.MULTI_TENANCY = true }
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = false setSelfHosted = (value: boolean) => {
env._set("SELF_HOSTED", value)
coreEnv._set("SELF_HOSTED", value)
}
modeCloud = () => {
this.setSelfHosted(false)
this.setMultiTenancy(true)
} }
modeSelf = () => { modeSelf = () => {
env.SELF_HOSTED = true this.setSelfHosted(true)
// @ts-ignore this.setMultiTenancy(false)
env.MULTI_TENANCY = false
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = true
} }
// UTILS // UTILS
@ -114,6 +119,16 @@ class TestConfiguration {
// TENANCY // TENANCY
createTenant = async (tenantId?: string): Promise<User> => {
// create user / new tenant
if (!tenantId) {
tenantId = structures.uuid()
}
return tenancy.doInTenant(tenantId, async () => {
return this.createUser()
})
}
getTenantId() { getTenantId() {
try { try {
return tenancy.getTenantId() return tenancy.getTenantId()
@ -179,6 +194,10 @@ class TestConfiguration {
} }
} }
internalAPIHeaders() {
return { [constants.Headers.API_KEY]: env.INTERNAL_API_KEY }
}
async getUser(email: string): Promise<User> { async getUser(email: string): Promise<User> {
return tenancy.doInTenant(this.getTenantId(), () => { return tenancy.doInTenant(this.getTenantId(), () => {
return users.getGlobalUserByEmail(email) return users.getGlobalUserByEmail(email)

View File

@ -0,0 +1,16 @@
import TestConfiguration from "../TestConfiguration"
export interface TestAPIOpts {
headers?: any
status?: number
}
export abstract class TestAPI {
config: TestConfiguration
request: any
protected constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
}

View File

@ -0,0 +1,18 @@
import TestConfiguration from "../TestConfiguration"
export class EnvironmentAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
getEnvironment = () => {
return this.request
.get(`/api/system/environment`)
.expect("Content-Type", /json/)
.expect(200)
}
}

View File

@ -5,6 +5,11 @@ import { ConfigAPI } from "./configs"
import { EmailAPI } from "./email" import { EmailAPI } from "./email"
import { SelfAPI } from "./self" import { SelfAPI } from "./self"
import { UserAPI } from "./users" import { UserAPI } from "./users"
import { EnvironmentAPI } from "./environment"
import { MigrationAPI } from "./migrations"
import { StatusAPI } from "./status"
import { RestoreAPI } from "./restore"
import { TenantAPI } from "./tenants"
export default class API { export default class API {
accounts: AccountAPI accounts: AccountAPI
@ -13,6 +18,11 @@ export default class API {
emails: EmailAPI emails: EmailAPI
self: SelfAPI self: SelfAPI
users: UserAPI users: UserAPI
environment: EnvironmentAPI
migrations: MigrationAPI
status: StatusAPI
restore: RestoreAPI
tenants: TenantAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.accounts = new AccountAPI(config) this.accounts = new AccountAPI(config)
@ -21,5 +31,10 @@ export default class API {
this.emails = new EmailAPI(config) this.emails = new EmailAPI(config)
this.self = new SelfAPI(config) this.self = new SelfAPI(config)
this.users = new UserAPI(config) this.users = new UserAPI(config)
this.environment = new EnvironmentAPI(config)
this.migrations = new MigrationAPI(config)
this.status = new StatusAPI(config)
this.restore = new RestoreAPI(config)
this.tenants = new TenantAPI(config)
} }
} }

View File

@ -0,0 +1,22 @@
import TestConfiguration from "../TestConfiguration"
import { TestAPI, TestAPIOpts } from "./base"
export class MigrationAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
runMigrations = (opts?: TestAPIOpts) => {
return this.request
.post(`/api/system/migrations/run`)
.set(opts?.headers ? opts.headers : this.config.internalAPIHeaders())
.expect(opts?.status ? opts.status : 200)
}
getMigrationDefinitions = (opts?: TestAPIOpts) => {
return this.request
.get(`/api/system/migrations/definitions`)
.set(opts?.headers ? opts.headers : this.config.internalAPIHeaders())
.expect(opts?.status ? opts.status : 200)
}
}

View File

@ -0,0 +1,14 @@
import TestConfiguration from "../TestConfiguration"
import { TestAPI, TestAPIOpts } from "./base"
export class RestoreAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
restored = (opts?: TestAPIOpts) => {
return this.request
.post(`/api/system/restored`)
.expect(opts?.status ? opts.status : 200)
}
}

View File

@ -18,4 +18,12 @@ export class SelfAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
getSelf = (user: User) => {
return this.request
.get(`/api/global/self`)
.set(this.config.authHeaders(user))
.expect("Content-Type", /json/)
.expect(200)
}
} }

View File

@ -0,0 +1,15 @@
import TestConfiguration from "../TestConfiguration"
export class StatusAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
getStatus = () => {
return this.request.get(`/api/system/status`).expect(200)
}
}

View File

@ -0,0 +1,15 @@
import TestConfiguration from "../TestConfiguration"
import { TestAPI, TestAPIOpts } from "./base"
export class TenantAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
delete = (tenantId: string, opts?: TestAPIOpts) => {
return this.request
.delete(`/api/system/tenants/${tenantId}`)
.set(opts?.headers)
.expect(opts?.status ? opts.status : 200)
}
}

View File

@ -1,10 +1,14 @@
import { generator } from "@budibase/backend-core/tests"
import TestConfiguration from "./TestConfiguration" import TestConfiguration from "./TestConfiguration"
import structures from "./structures" import structures from "./structures"
import mocks from "./mocks" import mocks from "./mocks"
import API from "./api" import API from "./api"
import { v4 as uuid } from "uuid"
const pkg = { const pkg = {
structures, structures,
generator,
uuid,
TENANT_1: structures.TENANT_1, TENANT_1: structures.TENANT_1,
mocks, mocks,
TestConfiguration, TestConfiguration,

View File

@ -1,24 +0,0 @@
import { Account, AuthType, Hosting, CloudAccount } from "@budibase/types"
import { v4 as uuid } from "uuid"
import { utils } from "@budibase/backend-core"
export const account = (): Account => {
return {
email: `${uuid()}@test.com`,
tenantId: utils.newid(),
hosting: Hosting.SELF,
authType: AuthType.SSO,
accountId: uuid(),
createdAt: Date.now(),
verified: true,
verificationSent: true,
tier: "FREE",
}
}
export const cloudAccount = (): CloudAccount => {
return {
...account(),
budibaseUserId: uuid(),
}
}

View File

@ -1,16 +1,18 @@
import { structures } from "@budibase/backend-core/tests"
import configs from "./configs" import configs from "./configs"
import * as users from "./users" import * as users from "./users"
import * as groups from "./groups" import * as groups from "./groups"
import * as accounts from "./accounts" import { v4 as uuid } from "uuid"
const TENANT_ID = "default" const TENANT_ID = "default"
const TENANT_1 = "tenant1" const TENANT_1 = "tenant1"
const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306" const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306"
const pkg = { const pkg = {
...structures,
uuid,
configs, configs,
users, users,
accounts,
TENANT_ID, TENANT_ID,
TENANT_1, TENANT_1,
CSRF_TOKEN, CSRF_TOKEN,

View File

@ -5,6 +5,7 @@ import { v4 as uuid } from "uuid"
export const newEmail = () => { export const newEmail = () => {
return `${uuid()}@test.com` return `${uuid()}@test.com`
} }
export const user = (userProps?: any): User => { export const user = (userProps?: any): User => {
return { return {
email: newEmail(), email: newEmail(),