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 6783fd713e
commit 72562278c0
50 changed files with 765 additions and 213 deletions

View File

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

View File

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

View File

@ -1,27 +1,35 @@
import { BBContext, EndpointMatcher, RegexMatcher } from "@budibase/types"
const PARAM_REGEX = /\/:(.*?)(\/.*)?$/g
exports.buildMatcherRegex = patterns => {
export const buildMatcherRegex = (
patterns: EndpointMatcher[]
): RegexMatcher[] => {
if (!patterns) {
return []
}
return patterns.map(pattern => {
const isObj = typeof pattern === "object" && pattern.route
const method = isObj ? pattern.method : "GET"
let route = pattern.route
const method = pattern.method
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)
if (matches) {
for (let match of matches) {
const pattern = "/.*" + (match.endsWith("/") ? "/" : "")
const suffix = match.endsWith("/") ? "/" : ""
const pattern = "/.*" + suffix
route = route.replace(match, pattern)
console.log(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 }) => {
let urlMatch
if (strict) {

View File

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

View File

@ -214,12 +214,31 @@ export const getTenantIDFromCtx = (
// e.g. tenant.budibase.app or tenant.local.com
const requestHost = ctx.host
// parse the tenant id from the difference
const tenantId = requestHost.substring(
0,
requestHost.indexOf(`.${platformHost}`)
if (requestHost.includes(platformHost)) {
const tenantId = requestHost.substring(
0,
requestHost.indexOf(`.${platformHost}`)
)
if (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 (tenantId) {
return tenantId
if (match) {
const params = match.params(
ctx.originalUrl,
match.captures(ctx.originalUrl),
{}
)
if (params.tenantId) {
return params.tenantId
}
}
}

View File

@ -8,7 +8,12 @@ import userCache from "./cache/user"
import { getSessionsForUser, invalidateSessions } from "./security/sessions"
import * as events from "./events"
import tenancy from "./tenancy"
import { App, BBContext, TenantResolutionStrategy } from "@budibase/types"
import {
App,
BBContext,
PlatformLogoutOpts,
TenantResolutionStrategy,
} from "@budibase/types"
import { SetOption } from "cookies"
const APP_PREFIX = DocumentType.APP + SEPARATOR
@ -189,12 +194,6 @@ export const getBuildersCount = async () => {
return builders.length
}
interface PlatformLogoutOpts {
ctx: BBContext
userId: string
keepActiveSession: boolean
}
/**
* 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 structures from "./structures"
export { generator } from "./structures"

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from "./info"
export * from "./users"
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 {
userId: string
tenantId: string
@ -25,3 +27,9 @@ export interface SessionKey {
export interface ScannedSession {
value: Session
}
export interface PlatformLogoutOpts {
ctx: BBContext
userId: string
keepActiveSession: boolean
}

View File

@ -1,4 +1,22 @@
export interface EndpointMatcher {
/**
* The HTTP Path. e.g. /api/things/:thingId
*/
route: string
/**
* The HTTP Verb. e.g. GET, POST, etc.
* ALL is also accepted to cover all verbs.
*/
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",
QUERY = "query",
SUBDOMAIN = "subdomain",
PATH = "path",
}

View File

@ -1,10 +1,13 @@
const env = require("../src/environment")
env._set("SELF_HOSTED", "1")
env._set("SELF_HOSTED", "0")
env._set("NODE_ENV", "jest")
env._set("JWT_SECRET", "test-jwtsecret")
env._set("LOG_LEVEL", "silent")
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")

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 = {
multiTenancy: !!env.MULTI_TENANCY,
cloud: !env.SELF_HOSTED,

View File

@ -1,7 +1,8 @@
const accounts = require("@budibase/backend-core/accounts")
const env = require("../../../environment")
import { accounts } from "@budibase/backend-core"
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) {
const status = await accounts.getStatus()
ctx.body = status

View File

@ -1,60 +1,17 @@
const { StaticDatabases, doWithDB } = require("@budibase/backend-core/db")
const { getTenantId } = require("@budibase/backend-core/tenancy")
const { deleteTenant } = require("@budibase/backend-core/deprovision")
import { BBContext } from "@budibase/types"
import { deprovisioning } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
export const exists = async (ctx: any) => {
const tenantId = ctx.request.params
ctx.body = {
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
}
),
}
}
const _delete = async (ctx: BBContext) => {
const user = ctx.user!
const tenantId = ctx.params.tenantId
export const fetch = async (ctx: any) => {
ctx.body = await doWithDB(
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")
if (tenantId !== user.tenantId) {
ctx.throw(403, "Tenant ID does not match current user")
}
try {
await deleteTenant(tenantId)
await deprovisioning.deleteTenant(tenantId)
await quotas.bustCache()
ctx.status = 204
} catch (err) {

View File

@ -7,11 +7,12 @@ import { errors, auth, middleware } from "@budibase/backend-core"
import { APIError } from "@budibase/types"
const PUBLIC_ENDPOINTS = [
// old deprecated endpoints kept for backwards compat
// deprecated single tenant sso callback
{
route: "/api/admin/auth/google/callback",
method: "GET",
},
// deprecated single tenant sso callback
{
route: "/api/admin/auth/oidc/callback",
method: "GET",
@ -51,10 +52,12 @@ const PUBLIC_ENDPOINTS = [
route: "api/system/status",
method: "GET",
},
// TODO: This should be an internal api
{
route: "/api/global/users/tenant/:id",
method: "GET",
},
// TODO: This should be an internal api
{
route: "/api/system/restored",
method: "POST",
@ -62,17 +65,37 @@ const PUBLIC_ENDPOINTS = [
]
const NO_TENANCY_ENDPOINTS = [
...PUBLIC_ENDPOINTS,
// system endpoints are not specific to any tenant
{
route: "/api/system",
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",
},
// 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",
},
]

View File

@ -2,7 +2,6 @@ const Router = require("@koa/router")
const authController = require("../../controllers/global/auth")
const { joiValidator } = require("@budibase/backend-core/auth")
const Joi = require("joi")
const { updateTenantId } = require("@budibase/backend-core/tenancy")
const router = new Router()
@ -29,43 +28,28 @@ function buildResetUpdateValidation() {
}).required().unknown(false))
}
function updateTenant(ctx, next) {
if (ctx.params) {
updateTenantId(ctx.params.tenantId)
}
return next()
}
router
.post(
"/api/global/auth/:tenantId/login",
buildAuthValidation(),
updateTenant,
authController.authenticate
)
.post(
"/api/global/auth/:tenantId/reset",
buildResetValidation(),
updateTenant,
authController.reset
)
.post(
"/api/global/auth/:tenantId/reset/update",
buildResetUpdateValidation(),
updateTenant,
authController.resetUpdate
)
.post("/api/global/auth/logout", authController.logout)
.post("/api/global/auth/init", authController.setInitInfo)
.get("/api/global/auth/init", authController.getInitInfo)
.get(
"/api/global/auth/:tenantId/google",
updateTenant,
authController.googlePreAuth
)
.get("/api/global/auth/:tenantId/google", authController.googlePreAuth)
.get(
"/api/global/auth/:tenantId/datasource/:provider",
updateTenant,
authController.datasourcePreAuth
)
// single tenancy endpoint
@ -75,29 +59,19 @@ router
authController.datasourceAuth
)
// multi-tenancy endpoint
.get(
"/api/global/auth/:tenantId/google/callback",
updateTenant,
authController.googleAuth
)
.get("/api/global/auth/:tenantId/google/callback", authController.googleAuth)
.get(
"/api/global/auth/:tenantId/datasource/:provider/callback",
updateTenant,
authController.datasourceAuth
)
.get(
"/api/global/auth/:tenantId/oidc/configs/:configId",
updateTenant,
authController.oidcPreAuth
)
// single tenancy endpoint
.get("/api/global/auth/oidc/callback", authController.oidcAuth)
// multi-tenancy endpoint
.get(
"/api/global/auth/:tenantId/oidc/callback",
updateTenant,
authController.oidcAuth
)
.get("/api/global/auth/:tenantId/oidc/callback", authController.oidcAuth)
// deprecated - used by the default system before tenancy
.get("/api/admin/auth/google/callback", authController.googleAuth)
.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.logoUpdated).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.logoUpdated).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"
dbConfig.init()
import env from "../environment"
import { env as coreEnv } from "@budibase/backend-core"
import controllers from "./controllers"
const supertest = require("supertest")
import { Configs } from "../constants"
@ -12,13 +13,13 @@ import {
Headers,
sessions,
auth,
constants,
} from "@budibase/backend-core"
import { TENANT_ID, TENANT_1, CSRF_TOKEN } from "./structures"
import structures from "./structures"
import structures, { TENANT_ID, TENANT_1, CSRF_TOKEN } from "./structures"
import { CreateUserResponse, User, AuthToken } from "@budibase/types"
enum Mode {
ACCOUNT = "account",
CLOUD = "cloud",
SELF = "self",
}
@ -31,11 +32,11 @@ class TestConfiguration {
constructor(
opts: { openServer: boolean; mode: Mode } = {
openServer: true,
mode: Mode.ACCOUNT,
mode: Mode.CLOUD,
}
) {
if (opts.mode === Mode.ACCOUNT) {
this.modeAccount()
if (opts.mode === Mode.CLOUD) {
this.modeCloud()
} else if (opts.mode === Mode.SELF) {
this.modeSelf()
}
@ -54,20 +55,24 @@ class TestConfiguration {
// MODES
modeAccount = () => {
env.SELF_HOSTED = false
// @ts-ignore
env.MULTI_TENANCY = true
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = false
setMultiTenancy = (value: boolean) => {
env._set("MULTI_TENANCY", value)
coreEnv._set("MULTI_TENANCY", value)
}
setSelfHosted = (value: boolean) => {
env._set("SELF_HOSTED", value)
coreEnv._set("SELF_HOSTED", value)
}
modeCloud = () => {
this.setSelfHosted(false)
this.setMultiTenancy(true)
}
modeSelf = () => {
env.SELF_HOSTED = true
// @ts-ignore
env.MULTI_TENANCY = false
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = true
this.setSelfHosted(true)
this.setMultiTenancy(false)
}
// UTILS
@ -114,6 +119,16 @@ class TestConfiguration {
// 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() {
try {
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> {
return tenancy.doInTenant(this.getTenantId(), () => {
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 { SelfAPI } from "./self"
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 {
accounts: AccountAPI
@ -13,6 +18,11 @@ export default class API {
emails: EmailAPI
self: SelfAPI
users: UserAPI
environment: EnvironmentAPI
migrations: MigrationAPI
status: StatusAPI
restore: RestoreAPI
tenants: TenantAPI
constructor(config: TestConfiguration) {
this.accounts = new AccountAPI(config)
@ -21,5 +31,10 @@ export default class API {
this.emails = new EmailAPI(config)
this.self = new SelfAPI(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(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 structures from "./structures"
import mocks from "./mocks"
import API from "./api"
import { v4 as uuid } from "uuid"
const pkg = {
structures,
generator,
uuid,
TENANT_1: structures.TENANT_1,
mocks,
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 * as users from "./users"
import * as groups from "./groups"
import * as accounts from "./accounts"
import { v4 as uuid } from "uuid"
const TENANT_ID = "default"
const TENANT_1 = "tenant1"
const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306"
const pkg = {
...structures,
uuid,
configs,
users,
accounts,
TENANT_ID,
TENANT_1,
CSRF_TOKEN,

View File

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