Merge branch 'master' into allow-public-view-attachment-uploads

This commit is contained in:
Andrew Kingston 2025-03-11 19:23:42 +00:00 committed by GitHub
commit d5bbd475a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 285 additions and 375 deletions

View File

@ -222,9 +222,12 @@ export class DatabaseImpl implements Database {
} }
async getMultiple<T extends Document>( async getMultiple<T extends Document>(
ids: string[], ids?: string[],
opts?: { allowMissing?: boolean; excludeDocs?: boolean } opts?: { allowMissing?: boolean; excludeDocs?: boolean }
): Promise<T[]> { ): Promise<T[]> {
if (!ids || ids.length === 0) {
return []
}
// get unique // get unique
ids = [...new Set(ids)] ids = [...new Set(ids)]
const includeDocs = !opts?.excludeDocs const includeDocs = !opts?.excludeDocs
@ -249,7 +252,7 @@ export class DatabaseImpl implements Database {
if (!opts?.allowMissing && someMissing) { if (!opts?.allowMissing && someMissing) {
const missing = response.rows.filter(row => rowUnavailable(row)) const missing = response.rows.filter(row => rowUnavailable(row))
const missingIds = missing.map(row => row.key).join(", ") const missingIds = missing.map(row => row.key).join(", ")
throw new Error(`Unable to get documents: ${missingIds}`) throw new Error(`Unable to get bulk documents: ${missingIds}`)
} }
return rows.map(row => (includeDocs ? row.doc! : row.value)) return rows.map(row => (includeDocs ? row.doc! : row.value))
} }

View File

@ -52,13 +52,13 @@ export class DDInstrumentedDatabase implements Database {
} }
getMultiple<T extends Document>( getMultiple<T extends Document>(
ids: string[], ids?: string[],
opts?: { allowMissing?: boolean | undefined } | undefined opts?: { allowMissing?: boolean | undefined } | undefined
): Promise<T[]> { ): Promise<T[]> {
return tracer.trace("db.getMultiple", async span => { return tracer.trace("db.getMultiple", async span => {
span.addTags({ span.addTags({
db_name: this.name, db_name: this.name,
num_docs: ids.length, num_docs: ids?.length || 0,
allow_missing: opts?.allowMissing, allow_missing: opts?.allowMissing,
}) })
const docs = await this.db.getMultiple<T>(ids, opts) const docs = await this.db.getMultiple<T>(ids, opts)

View File

@ -26,8 +26,9 @@ import {
import { import {
getAccountHolderFromUsers, getAccountHolderFromUsers,
isAdmin, isAdmin,
isCreator, creatorsInList,
validateUniqueUser, validateUniqueUser,
isCreatorAsync,
} from "./utils" } from "./utils"
import { import {
getFirstPlatformUser, getFirstPlatformUser,
@ -261,8 +262,16 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user const change = dbUser ? 0 : 1 // no change if there is existing user
const creatorsChange =
(await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0 let creatorsChange = 0
if (dbUser) {
const [isDbUserCreator, isUserCreator] = await creatorsInList([
dbUser,
user,
])
creatorsChange = isDbUserCreator !== isUserCreator ? 1 : 0
}
return UserDB.quotas.addUsers(change, creatorsChange, async () => { return UserDB.quotas.addUsers(change, creatorsChange, async () => {
if (!opts.isAccountHolder) { if (!opts.isAccountHolder) {
await validateUniqueUser(email, tenantId) await validateUniqueUser(email, tenantId)
@ -353,7 +362,7 @@ export class UserDB {
} }
newUser.userGroups = groups || [] newUser.userGroups = groups || []
newUsers.push(newUser) newUsers.push(newUser)
if (await isCreator(newUser)) { if (await isCreatorAsync(newUser)) {
newCreators.push(newUser) newCreators.push(newUser)
} }
} }
@ -453,10 +462,8 @@ export class UserDB {
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsEval = await Promise.all(usersToDelete.map(isCreator)) const creatorsEval = await creatorsInList(usersToDelete)
const creatorsToDeleteCount = creatorsEval.filter( const creatorsToDeleteCount = creatorsEval.filter(creator => creator).length
creator => !!creator
).length
const ssoUsersToDelete: AnyDocument[] = [] const ssoUsersToDelete: AnyDocument[] = []
for (let user of usersToDelete) { for (let user of usersToDelete) {
@ -533,7 +540,7 @@ export class UserDB {
await db.remove(userId, dbUser._rev!) await db.remove(userId, dbUser._rev!)
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 const creatorsToDelete = (await isCreatorAsync(dbUser)) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete) await UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)

View File

@ -2,39 +2,39 @@ import { User, UserGroup } from "@budibase/types"
import { generator, structures } from "../../../tests" import { generator, structures } from "../../../tests"
import { DBTestConfiguration } from "../../../tests/extra" import { DBTestConfiguration } from "../../../tests/extra"
import { getGlobalDB } from "../../context" import { getGlobalDB } from "../../context"
import { isCreator } from "../utils" import { isCreatorSync, creatorsInList } from "../utils"
const config = new DBTestConfiguration() const config = new DBTestConfiguration()
describe("Users", () => { describe("Users", () => {
it("User is a creator if it is configured as a global builder", async () => { it("User is a creator if it is configured as a global builder", () => {
const user: User = structures.users.user({ builder: { global: true } }) const user: User = structures.users.user({ builder: { global: true } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it is configured as a global admin", async () => { it("User is a creator if it is configured as a global admin", () => {
const user: User = structures.users.user({ admin: { global: true } }) const user: User = structures.users.user({ admin: { global: true } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it is configured with creator permission", async () => { it("User is a creator if it is configured with creator permission", () => {
const user: User = structures.users.user({ builder: { creator: true } }) const user: User = structures.users.user({ builder: { creator: true } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it is a builder in some application", async () => { it("User is a creator if it is a builder in some application", () => {
const user: User = structures.users.user({ builder: { apps: ["app1"] } }) const user: User = structures.users.user({ builder: { apps: ["app1"] } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it has CREATOR permission in some application", async () => { it("User is a creator if it has CREATOR permission in some application", () => {
const user: User = structures.users.user({ roles: { app1: "CREATOR" } }) const user: User = structures.users.user({ roles: { app1: "CREATOR" } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it has ADMIN permission in some application", async () => { it("User is a creator if it has ADMIN permission in some application", () => {
const user: User = structures.users.user({ roles: { app1: "ADMIN" } }) const user: User = structures.users.user({ roles: { app1: "ADMIN" } })
expect(await isCreator(user)).toBe(true) expect(isCreatorSync(user, [])).toBe(true)
}) })
it("User is a creator if it remains to a group with ADMIN permissions", async () => { it("User is a creator if it remains to a group with ADMIN permissions", async () => {
@ -59,7 +59,7 @@ describe("Users", () => {
await db.put(group) await db.put(group)
for (let user of users) { for (let user of users) {
await db.put(user) await db.put(user)
const creator = await isCreator(user) const creator = (await creatorsInList([user]))[0]
expect(creator).toBe(true) expect(creator).toBe(true)
} }
}) })

View File

@ -22,7 +22,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import { isCreator } from "./utils" import { creatorsInList } from "./utils"
import { UserDB } from "./db" import { UserDB } from "./db"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
@ -305,8 +305,8 @@ export async function getCreatorCount() {
let creators = 0 let creators = 0
async function iterate(startPage?: string) { async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage }) const page = await paginatedUsers({ bookmark: startPage })
const creatorsEval = await Promise.all(page.data.map(isCreator)) const creatorsEval = await creatorsInList(page.data)
creators += creatorsEval.filter(creator => !!creator).length creators += creatorsEval.filter(creator => creator).length
if (page.hasNextPage) { if (page.hasNextPage) {
await iterate(page.nextPage) await iterate(page.nextPage)
} }

View File

@ -16,30 +16,47 @@ export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
export async function isCreator(user?: User | ContextUser) { export async function creatorsInList(
users: (User | ContextUser)[],
groups?: UserGroup[]
) {
const groupIds = [
...new Set(
users.filter(user => user.userGroups).flatMap(user => user.userGroups!)
),
]
const db = context.getGlobalDB()
groups = await db.getMultiple<UserGroup>(groupIds, { allowMissing: true })
return users.map(user => isCreatorSync(user, groups))
}
// fetches groups if no provided, but is async and shouldn't be looped with
export async function isCreatorAsync(user: User | ContextUser) {
let groups: UserGroup[] = []
if (user.userGroups) {
const db = context.getGlobalDB()
groups = await db.getMultiple<UserGroup>(user.userGroups)
}
return isCreatorSync(user, groups)
}
export function isCreatorSync(user: User | ContextUser, groups?: UserGroup[]) {
const isCreatorByUserDefinition = sdk.users.isCreator(user) const isCreatorByUserDefinition = sdk.users.isCreator(user)
if (!isCreatorByUserDefinition && user) { if (!isCreatorByUserDefinition && user) {
return await isCreatorByGroupMembership(user) return isCreatorByGroupMembership(user, groups)
} }
return isCreatorByUserDefinition return isCreatorByUserDefinition
} }
async function isCreatorByGroupMembership(user?: User | ContextUser) { function isCreatorByGroupMembership(
const userGroups = user?.userGroups || [] user: User | ContextUser,
if (userGroups.length > 0) { groups?: UserGroup[]
const db = context.getGlobalDB() ) {
const groups: UserGroup[] = [] const userGroups = groups?.filter(
for (let groupId of userGroups) { group => user.userGroups?.indexOf(group._id!) !== -1
try { )
const group = await db.get<UserGroup>(groupId) if (userGroups && userGroups.length > 0) {
groups.push(group) return userGroups.some(group =>
} catch (e: any) {
if (e.error !== "not_found") {
throw e
}
}
}
return groups.some(group =>
Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN) Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN)
) )
} }

View File

@ -1,52 +0,0 @@
import { range } from "lodash/fp"
import { structures } from "../.."
jest.mock("../../../src/context")
jest.mock("../../../src/db")
import * as context from "../../../src/context"
import * as db from "../../../src/db"
import { getCreatorCount } from "../../../src/users/users"
describe("Users", () => {
let getGlobalDBMock: jest.SpyInstance
let paginationMock: jest.SpyInstance
beforeEach(() => {
jest.resetAllMocks()
getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
paginationMock = jest.spyOn(db, "pagination")
jest.spyOn(db, "getGlobalUserParams")
})
it("retrieves the number of creators", async () => {
const getUsers = (offset: number, limit: number, creators = false) => {
const opts = creators ? { builder: { global: true } } : undefined
return range(offset, limit).map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)
getGlobalDBMock.mockImplementation(() => ({
name: "fake-db",
allDocs: () => ({
rows: [...page1Data, ...page2Data],
}),
}))
paginationMock.mockImplementationOnce(() => ({
data: page1Data,
hasNextPage: true,
nextPage: "1",
}))
paginationMock.mockImplementation(() => ({
data: page2Data,
hasNextPage: false,
nextPage: undefined,
}))
const creatorsCount = await getCreatorCount()
expect(creatorsCount).toBe(4)
expect(paginationMock).toHaveBeenCalledTimes(2)
})
})

@ -1 +1 @@
Subproject commit b28dbd549284cf450be7f25ad85aadf614d08f0b Subproject commit 2dd06c2fcb3cf10d5f16f5d8fe6cd344c8e905a5

View File

@ -1,11 +1,13 @@
const setup = require("./utilities") import { handleDataImport } from "../../controllers/table/utils"
const tableUtils = require("../../controllers/table/utils") import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import { AutoFieldSubType, FieldType, JsonFieldSubType } from "@budibase/types"
describe("run misc tests", () => { describe("run misc tests", () => {
let request = setup.getRequest() const config = new TestConfiguration()
let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(() => {
config.end()
})
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
@ -13,69 +15,67 @@ describe("run misc tests", () => {
describe("/bbtel", () => { describe("/bbtel", () => {
it("check if analytics enabled", async () => { it("check if analytics enabled", async () => {
const res = await request const { enabled } = await config.api.misc.bbtel()
.get(`/api/bbtel`) expect(enabled).toEqual(true)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(typeof res.body.enabled).toEqual("boolean")
}) })
}) })
describe("/health", () => { describe("/health", () => {
it("should confirm healthy", async () => { it("should confirm healthy", async () => {
await request.get("/health").expect(200) await config.api.misc.health()
}) })
}) })
describe("/version", () => { describe("/version", () => {
it("should confirm version", async () => { it("should confirm version", async () => {
const res = await request.get("/version").expect(200) const version = await config.api.misc.version()
const text = res.text if (version.includes("alpha")) {
if (text.includes("alpha")) { expect(version.split(".").length).toEqual(4)
expect(text.split(".").length).toEqual(4)
} else { } else {
expect(text.split(".").length).toEqual(3) expect(version.split(".").length).toEqual(3)
} }
}) })
}) })
describe("test table utilities", () => { describe("test table utilities", () => {
it("should be able to import data", async () => { it("should be able to import data", async () => {
return config.doInContext(null, async () => { return config.doInContext("", async () => {
const table = await config.createTable({ const table = await config.createTable({
name: "table", name: "table",
type: "table", type: "table",
key: "name",
schema: { schema: {
a: { a: {
type: "string", type: FieldType.STRING,
name: "a",
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
b: { b: {
type: "string", name: "b",
type: FieldType.STRING,
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
c: { c: {
type: "string", name: "c",
type: FieldType.STRING,
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
d: { d: {
type: "string", name: "d",
type: FieldType.STRING,
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
e: { e: {
name: "Auto ID", name: "Auto ID",
type: "number", type: FieldType.NUMBER,
subtype: "autoID", subtype: AutoFieldSubType.AUTO_ID,
icon: "ri-magic-line", icon: "ri-magic-line",
autocolumn: true, autocolumn: true,
constraints: { constraints: {
@ -88,9 +88,9 @@ describe("run misc tests", () => {
}, },
}, },
f: { f: {
type: "array", type: FieldType.ARRAY,
constraints: { constraints: {
type: "array", type: JsonFieldSubType.ARRAY,
presence: { presence: {
allowEmpty: true, allowEmpty: true,
}, },
@ -100,7 +100,7 @@ describe("run misc tests", () => {
sortable: false, sortable: false,
}, },
g: { g: {
type: "options", type: FieldType.OPTIONS,
constraints: { constraints: {
type: "string", type: "string",
presence: false, presence: false,
@ -118,16 +118,18 @@ describe("run misc tests", () => {
{ a: "13", b: "14", c: "15", d: "16", g: "Omega" }, { a: "13", b: "14", c: "15", d: "16", g: "Omega" },
] ]
// Shift specific row tests to the row spec // Shift specific row tests to the row spec
await tableUtils.handleDataImport(table, { await handleDataImport(table, { importRows, userId: "test" })
importRows,
user: { userId: "test" },
})
// 4 rows imported, the auto ID starts at 1 // 4 rows imported, the auto ID starts at 1
// We expect the handleDataImport function to update the lastID // We expect the handleDataImport function to update the lastID
// @ts-expect-error - fields have type FieldSchema, not specific
// subtypes.
expect(table.schema.e.lastID).toEqual(4) expect(table.schema.e.lastID).toEqual(4)
// Array/Multi - should have added a new value to the inclusion. // Array/Multi - should have added a new value to the inclusion.
// @ts-expect-error - fields have type FieldSchema, not specific
// subtypes.
expect(table.schema.f.constraints.inclusion).toEqual([ expect(table.schema.f.constraints.inclusion).toEqual([
"Four", "Four",
"One", "One",
@ -136,6 +138,8 @@ describe("run misc tests", () => {
]) ])
// Options - should have a new value in the inclusion // Options - should have a new value in the inclusion
// @ts-expect-error - fields have type FieldSchema, not specific
// subtypes.
expect(table.schema.g.constraints.inclusion).toEqual([ expect(table.schema.g.constraints.inclusion).toEqual([
"Alpha", "Alpha",
"Beta", "Beta",
@ -143,25 +147,25 @@ describe("run misc tests", () => {
"Omega", "Omega",
]) ])
const rows = await config.getRows() const rows = await config.api.row.fetch(table._id!)
expect(rows.length).toEqual(4) expect(rows.length).toEqual(4)
const rowOne = rows.find(row => row.e === 1) const rowOne = rows.find(row => row.e === 1)!
expect(rowOne.a).toEqual("1") expect(rowOne.a).toEqual("1")
expect(rowOne.f).toEqual(["One"]) expect(rowOne.f).toEqual(["One"])
expect(rowOne.g).toEqual("Alpha") expect(rowOne.g).toEqual("Alpha")
const rowTwo = rows.find(row => row.e === 2) const rowTwo = rows.find(row => row.e === 2)!
expect(rowTwo.a).toEqual("5") expect(rowTwo.a).toEqual("5")
expect(rowTwo.f).toEqual([]) expect(rowTwo.f).toEqual([])
expect(rowTwo.g).toEqual(undefined) expect(rowTwo.g).toEqual(undefined)
const rowThree = rows.find(row => row.e === 3) const rowThree = rows.find(row => row.e === 3)!
expect(rowThree.a).toEqual("9") expect(rowThree.a).toEqual("9")
expect(rowThree.f).toEqual(["Two", "Four"]) expect(rowThree.f).toEqual(["Two", "Four"])
expect(rowThree.g).toEqual(undefined) expect(rowThree.g).toEqual(undefined)
const rowFour = rows.find(row => row.e === 4) const rowFour = rows.find(row => row.e === 4)!
expect(rowFour.a).toEqual("13") expect(rowFour.a).toEqual("13")
expect(rowFour.f).toEqual(undefined) expect(rowFour.f).toEqual(undefined)
expect(rowFour.g).toEqual("Omega") expect(rowFour.g).toEqual("Omega")

View File

@ -34,7 +34,7 @@ const checkAuthorized = async (
const isCreatorApi = permType === PermissionType.CREATOR const isCreatorApi = permType === PermissionType.CREATOR
const isBuilderApi = permType === PermissionType.BUILDER const isBuilderApi = permType === PermissionType.BUILDER
const isGlobalBuilder = users.isGlobalBuilder(ctx.user) const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
const isCreator = await users.isCreator(ctx.user) const isCreator = await users.isCreatorAsync(ctx.user)
const isBuilder = appId const isBuilder = appId
? users.isBuilder(ctx.user, appId) ? users.isBuilder(ctx.user, appId)
: users.hasBuilderPermissions(ctx.user) : users.hasBuilderPermissions(ctx.user)

View File

@ -1,12 +0,0 @@
import env from "../environment"
import { Ctx } from "@budibase/types"
// if added as a middleware will stop requests unless builder is in self host mode
// or cloud is in self host
export default async (ctx: Ctx, next: any) => {
if (env.SELF_HOSTED) {
await next()
return
}
ctx.throw(400, "Endpoint unavailable in cloud hosting.")
}

View File

@ -1,75 +0,0 @@
import ensureTenantAppOwnership from "../ensureTenantAppOwnership"
import { tenancy, utils } from "@budibase/backend-core"
jest.mock("@budibase/backend-core", () => ({
...jest.requireActual("@budibase/backend-core"),
tenancy: {
getTenantId: jest.fn(),
},
utils: {
getAppIdFromCtx: jest.fn(),
},
}))
class TestConfiguration {
constructor(appId = "tenant_1") {
this.next = jest.fn()
this.throw = jest.fn()
this.middleware = ensureTenantAppOwnership
this.ctx = {
next: this.next,
throw: this.throw,
}
utils.getAppIdFromCtx.mockResolvedValue(appId)
}
async executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("Ensure Tenant Ownership Middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when appId matches tenant ID", async () => {
tenancy.getTenantId.mockReturnValue("tenant_1")
await config.executeMiddleware()
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
expect(config.next).toHaveBeenCalled()
})
it("throws when tenant appId does not match tenant ID", async () => {
const appId = "app_dev_tenant3_fce449c4d75b4e4a9c7a6980d82a3e22"
utils.getAppIdFromCtx.mockResolvedValue(appId)
tenancy.getTenantId.mockReturnValue("tenant_2")
await config.executeMiddleware()
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
expect(config.throw).toHaveBeenCalledWith(403, "Unauthorized")
})
it("throws 400 when appId is missing", async () => {
utils.getAppIdFromCtx.mockResolvedValue(null)
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(400, "appId must be provided")
})
})

View File

@ -0,0 +1,52 @@
import { UserCtx } from "@budibase/types"
import ensureTenantAppOwnership from "../ensureTenantAppOwnership"
import { context, Header, HTTPError } from "@budibase/backend-core"
function ctx(opts?: { appId: string }) {
const ctx = {
throw: (status: number, message: string) => {
throw new HTTPError(message, status)
},
path: "",
request: {
headers: {},
},
} as unknown as UserCtx
if (opts?.appId) {
ctx.request.headers[Header.APP_ID] = opts.appId
}
return ctx
}
describe("Ensure Tenant Ownership Middleware", () => {
const tenantId = "tenant1"
const appId = `app_dev_${tenantId}_fce449c4d75b4e4a9c7a6980d82a3e22`
it("calls next() when appId matches tenant ID", async () => {
await context.doInTenant(tenantId, async () => {
let called = false
await ensureTenantAppOwnership(ctx({ appId }), () => {
called = true
})
expect(called).toBe(true)
})
})
it("throws when tenant appId does not match tenant ID", async () => {
let called = false
await expect(async () => {
await context.doInTenant("tenant_2", async () => {
await ensureTenantAppOwnership(ctx({ appId }), () => {
called = true
})
})
}).rejects.toThrow("Unauthorized")
expect(called).toBe(false)
})
it("throws 400 when appId is missing", async () => {
await expect(ensureTenantAppOwnership(ctx(), () => {})).rejects.toThrow(
"appId must be provided"
)
})
})

View File

@ -1,105 +0,0 @@
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()
let 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,93 @@
import { Ctx } from "@budibase/types"
import {
paramResource,
paramSubResource,
bodyResource,
bodySubResource,
ResourceIdGetter,
} from "../resourceId"
describe("resourceId middleware", () => {
it("calls next() when there is no request object to parse", () => {
const ctx = { request: {} } as Ctx
let called = false
paramResource("main")(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toBeUndefined()
})
it("generates a resourceId middleware for context query parameters", () => {
const ctx = { request: {}, params: { main: "test" } } as unknown as Ctx
let called = false
paramResource("main")(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toEqual("test")
})
it("generates a resourceId middleware for context query sub parameters", () => {
const ctx = {
request: {},
params: { main: "main", sub: "test" },
} as unknown as Ctx
let called = false
paramSubResource("main", "sub")(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toEqual("main")
expect(ctx.subResourceId).toEqual("test")
})
it("generates a resourceId middleware for context request body", () => {
const ctx = { request: {}, body: { main: "main" } } as unknown as Ctx
let called = false
bodyResource("main")(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toEqual("main")
})
it("generates a resourceId middleware for context request body sub fields", () => {
const ctx = {
request: {},
body: { main: "main", sub: "test" },
} as unknown as Ctx
let called = false
bodySubResource("main", "sub")(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toEqual("main")
expect(ctx.subResourceId).toEqual("test")
})
it("parses resourceIds correctly for custom middlewares", () => {
const middleware = new ResourceIdGetter("body")
.mainResource("custom")
.subResource("customSub")
.build()
const ctx = {
request: {},
body: { custom: "test", customSub: "subTest" },
} as unknown as Ctx
let called = false
middleware(ctx, () => {
called = true
})
expect(called).toBe(true)
expect(ctx.resourceId).toEqual("test")
expect(ctx.subResourceId).toEqual("subTest")
})
})

View File

@ -1,43 +0,0 @@
const selfHostMiddleware = require("../selfhost").default
const env = require("../../environment")
jest.mock("../../environment")
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)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("Self host middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when SELF_HOSTED env var is set", async () => {
env.SELF_HOSTED = 1
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
})

View File

@ -19,6 +19,7 @@ import { PluginAPI } from "./plugin"
import { WebhookAPI } from "./webhook" import { WebhookAPI } from "./webhook"
import { EnvironmentAPI } from "./environment" import { EnvironmentAPI } from "./environment"
import { UserPublicAPI } from "./public/user" import { UserPublicAPI } from "./public/user"
import { MiscAPI } from "./misc"
export default class API { export default class API {
application: ApplicationAPI application: ApplicationAPI
@ -28,6 +29,7 @@ export default class API {
datasource: DatasourceAPI datasource: DatasourceAPI
environment: EnvironmentAPI environment: EnvironmentAPI
legacyView: LegacyViewAPI legacyView: LegacyViewAPI
misc: MiscAPI
permission: PermissionAPI permission: PermissionAPI
plugin: PluginAPI plugin: PluginAPI
query: QueryAPI query: QueryAPI
@ -53,6 +55,7 @@ export default class API {
this.datasource = new DatasourceAPI(config) this.datasource = new DatasourceAPI(config)
this.environment = new EnvironmentAPI(config) this.environment = new EnvironmentAPI(config)
this.legacyView = new LegacyViewAPI(config) this.legacyView = new LegacyViewAPI(config)
this.misc = new MiscAPI(config)
this.permission = new PermissionAPI(config) this.permission = new PermissionAPI(config)
this.plugin = new PluginAPI(config) this.plugin = new PluginAPI(config)
this.query = new QueryAPI(config) this.query = new QueryAPI(config)

View File

@ -0,0 +1,18 @@
import { AnalyticsEnabledResponse } from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class MiscAPI extends TestAPI {
health = async (expectations?: Expectations) => {
return await this._get<void>("/health", { expectations })
}
version = async (expectations?: Expectations) => {
return (await this._requestRaw("get", "/version", { expectations })).text
}
bbtel = async (expectations?: Expectations) => {
return await this._get<AnalyticsEnabledResponse>("/api/bbtel", {
expectations,
})
}
}

View File

@ -136,7 +136,7 @@ export interface Database {
get<T extends Document>(id?: string): Promise<T> get<T extends Document>(id?: string): Promise<T>
tryGet<T extends Document>(id?: string): Promise<T | undefined> tryGet<T extends Document>(id?: string): Promise<T | undefined>
getMultiple<T extends Document>( getMultiple<T extends Document>(
ids: string[], ids?: string[],
opts?: { allowMissing?: boolean; excludeDocs?: boolean } opts?: { allowMissing?: boolean; excludeDocs?: boolean }
): Promise<T[]> ): Promise<T[]>
remove(idOrDoc: Document): Promise<Nano.DocumentDestroyResponse> remove(idOrDoc: Document): Promise<Nano.DocumentDestroyResponse>