Merge branch 'master' into budi-8742-add-a-baseurl-binding-inside-automations
This commit is contained in:
commit
0875ac817c
|
@ -3,7 +3,7 @@ name: Deploy QA
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- v3-ui
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.33.1",
|
||||
"version": "2.33.2",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
DatabaseQueryOpts,
|
||||
DBError,
|
||||
Document,
|
||||
FeatureFlag,
|
||||
isDocument,
|
||||
RowResponse,
|
||||
RowValue,
|
||||
|
@ -456,7 +457,7 @@ export class DatabaseImpl implements Database {
|
|||
|
||||
async destroy() {
|
||||
if (
|
||||
(await flags.isEnabled("SQS")) &&
|
||||
(await flags.isEnabled(FeatureFlag.SQS)) &&
|
||||
(await this.exists(SQLITE_DESIGN_DOC_ID))
|
||||
) {
|
||||
// delete the design document, then run the cleanup operation
|
||||
|
|
|
@ -54,30 +54,46 @@ function getPackageJsonFields(): {
|
|||
VERSION: string
|
||||
SERVICE_NAME: string
|
||||
} {
|
||||
function findFileInAncestors(
|
||||
fileName: string,
|
||||
currentDir: string
|
||||
): string | null {
|
||||
const filePath = `${currentDir}/${fileName}`
|
||||
if (existsSync(filePath)) {
|
||||
return filePath
|
||||
function getParentFile(file: string) {
|
||||
function findFileInAncestors(
|
||||
fileName: string,
|
||||
currentDir: string
|
||||
): string | null {
|
||||
const filePath = `${currentDir}/${fileName}`
|
||||
if (existsSync(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
const parentDir = `${currentDir}/..`
|
||||
if (parentDir === currentDir) {
|
||||
// reached root directory
|
||||
return null
|
||||
}
|
||||
|
||||
return findFileInAncestors(fileName, parentDir)
|
||||
}
|
||||
|
||||
const parentDir = `${currentDir}/..`
|
||||
if (parentDir === currentDir) {
|
||||
// reached root directory
|
||||
return null
|
||||
}
|
||||
const packageJsonFile = findFileInAncestors(file, process.cwd())
|
||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||
const parsedContent = JSON.parse(content)
|
||||
return parsedContent
|
||||
}
|
||||
|
||||
return findFileInAncestors(fileName, parentDir)
|
||||
let localVersion: string | undefined
|
||||
if (isDev() && !isTest()) {
|
||||
try {
|
||||
const lerna = getParentFile("lerna.json")
|
||||
localVersion = `${lerna.version}+local`
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
|
||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||
const parsedContent = JSON.parse(content)
|
||||
const parsedContent = getParentFile("package.json")
|
||||
return {
|
||||
VERSION: process.env.BUDIBASE_VERSION || parsedContent.version,
|
||||
VERSION:
|
||||
localVersion || process.env.BUDIBASE_VERSION || parsedContent.version,
|
||||
SERVICE_NAME: parsedContent.name,
|
||||
}
|
||||
} catch {
|
||||
|
|
|
@ -267,12 +267,13 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
|
|||
// All of the machinery in this file is to make sure that flags have their
|
||||
// default values set correctly and their types flow through the system.
|
||||
export const flags = new FlagSet({
|
||||
DEFAULT_VALUES: Flag.boolean(env.isDev()),
|
||||
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
|
||||
SQS: Flag.boolean(true),
|
||||
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.SQS]: Flag.boolean(true),
|
||||
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(env.isDev()),
|
||||
})
|
||||
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import semver from "semver"
|
||||
import { BuiltinPermissionID, PermissionLevel } from "./permissions"
|
||||
import {
|
||||
prefixRoleID,
|
||||
|
@ -7,9 +8,16 @@ import {
|
|||
doWithDB,
|
||||
} from "../db"
|
||||
import { getAppDB } from "../context"
|
||||
import { Screen, Role as RoleDoc, RoleUIMetadata } from "@budibase/types"
|
||||
import {
|
||||
Screen,
|
||||
Role as RoleDoc,
|
||||
RoleUIMetadata,
|
||||
Database,
|
||||
App,
|
||||
} from "@budibase/types"
|
||||
import cloneDeep from "lodash/fp/cloneDeep"
|
||||
import { RoleColor } from "@budibase/shared-core"
|
||||
import { RoleColor, helpers } from "@budibase/shared-core"
|
||||
import { uniqBy } from "lodash"
|
||||
|
||||
export const BUILTIN_ROLE_IDS = {
|
||||
ADMIN: "ADMIN",
|
||||
|
@ -23,14 +31,6 @@ const BUILTIN_IDS = {
|
|||
BUILDER: "BUILDER",
|
||||
}
|
||||
|
||||
// exclude internal roles like builder
|
||||
const EXTERNAL_BUILTIN_ROLE_IDS = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.POWER,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
|
||||
export const RoleIDVersion = {
|
||||
// original version, with a UUID based ID
|
||||
UUID: undefined,
|
||||
|
@ -38,12 +38,20 @@ export const RoleIDVersion = {
|
|||
NAME: "name",
|
||||
}
|
||||
|
||||
function rolesInList(roleIds: string[], ids: string | string[]) {
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter(id => roleIds.includes(id)).length === ids.length
|
||||
} else {
|
||||
return roleIds.includes(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export class Role implements RoleDoc {
|
||||
_id: string
|
||||
_rev?: string
|
||||
name: string
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
inherits?: string | string[]
|
||||
version?: string
|
||||
permissions: Record<string, PermissionLevel[]> = {}
|
||||
uiMetadata?: RoleUIMetadata
|
||||
|
@ -62,12 +70,70 @@ export class Role implements RoleDoc {
|
|||
this.version = RoleIDVersion.NAME
|
||||
}
|
||||
|
||||
addInheritance(inherits: string) {
|
||||
addInheritance(inherits?: string | string[]) {
|
||||
// make sure IDs are correct format
|
||||
if (inherits && typeof inherits === "string") {
|
||||
inherits = prefixRoleIDNoBuiltin(inherits)
|
||||
} else if (inherits && Array.isArray(inherits)) {
|
||||
inherits = inherits.map(prefixRoleIDNoBuiltin)
|
||||
}
|
||||
this.inherits = inherits
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleHierarchyTraversal {
|
||||
allRoles: RoleDoc[]
|
||||
opts?: { defaultPublic?: boolean }
|
||||
|
||||
constructor(allRoles: RoleDoc[], opts?: { defaultPublic?: boolean }) {
|
||||
this.allRoles = allRoles
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
walk(role: RoleDoc): RoleDoc[] {
|
||||
const opts = this.opts,
|
||||
allRoles = this.allRoles
|
||||
// this will be a full walked list of roles - which may contain duplicates
|
||||
let roleList: RoleDoc[] = []
|
||||
if (!role || !role._id) {
|
||||
return roleList
|
||||
}
|
||||
roleList.push(role)
|
||||
if (Array.isArray(role.inherits)) {
|
||||
for (let roleId of role.inherits) {
|
||||
const foundRole = findRole(roleId, allRoles, opts)
|
||||
if (foundRole) {
|
||||
roleList = roleList.concat(this.walk(foundRole))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const foundRoleIds: string[] = []
|
||||
let currentRole: RoleDoc | undefined = role
|
||||
while (
|
||||
currentRole &&
|
||||
currentRole.inherits &&
|
||||
!rolesInList(foundRoleIds, currentRole.inherits)
|
||||
) {
|
||||
if (Array.isArray(currentRole.inherits)) {
|
||||
return roleList.concat(this.walk(currentRole))
|
||||
} else {
|
||||
foundRoleIds.push(currentRole.inherits)
|
||||
currentRole = findRole(currentRole.inherits, allRoles, opts)
|
||||
if (currentRole) {
|
||||
roleList.push(currentRole)
|
||||
}
|
||||
}
|
||||
// loop now found - stop iterating
|
||||
if (helpers.roles.checkForRoleInheritanceLoops(roleList)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqBy(roleList, role => role._id)
|
||||
}
|
||||
}
|
||||
|
||||
const BUILTIN_ROLES = {
|
||||
ADMIN: new Role(
|
||||
BUILTIN_IDS.ADMIN,
|
||||
|
@ -126,7 +192,15 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
|
|||
}
|
||||
|
||||
export function isBuiltin(role: string) {
|
||||
return getBuiltinRole(role) !== undefined
|
||||
return Object.values(BUILTIN_ROLE_IDS).includes(role)
|
||||
}
|
||||
|
||||
export function prefixRoleIDNoBuiltin(roleId: string) {
|
||||
if (isBuiltin(roleId)) {
|
||||
return roleId
|
||||
} else {
|
||||
return prefixRoleID(roleId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuiltinRole(roleId: string): Role | undefined {
|
||||
|
@ -154,7 +228,11 @@ export function builtinRoleToNumber(id: string) {
|
|||
if (!role) {
|
||||
break
|
||||
}
|
||||
role = builtins[role.inherits!]
|
||||
if (Array.isArray(role.inherits)) {
|
||||
throw new Error("Built-in roles don't support multi-inheritance")
|
||||
} else {
|
||||
role = builtins[role.inherits!]
|
||||
}
|
||||
count++
|
||||
} while (role !== null)
|
||||
return count
|
||||
|
@ -170,12 +248,31 @@ export async function roleToNumber(id: string) {
|
|||
const hierarchy = (await getUserRoleHierarchy(id, {
|
||||
defaultPublic: true,
|
||||
})) as RoleDoc[]
|
||||
for (let role of hierarchy) {
|
||||
if (role?.inherits && isBuiltin(role.inherits)) {
|
||||
const findNumber = (role: RoleDoc): number => {
|
||||
if (!role.inherits) {
|
||||
return 0
|
||||
}
|
||||
if (Array.isArray(role.inherits)) {
|
||||
// find the built-in roles, get their number, sort it, then get the last one
|
||||
const highestBuiltin: number | undefined = role.inherits
|
||||
.map(roleId => {
|
||||
const foundRole = hierarchy.find(role => role._id === roleId)
|
||||
if (foundRole) {
|
||||
return findNumber(foundRole) + 1
|
||||
}
|
||||
})
|
||||
.filter(number => number)
|
||||
.sort()
|
||||
.pop()
|
||||
if (highestBuiltin != undefined) {
|
||||
return highestBuiltin
|
||||
}
|
||||
} else if (isBuiltin(role.inherits)) {
|
||||
return builtinRoleToNumber(role.inherits) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
return Math.max(...hierarchy.map(findNumber))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -193,6 +290,53 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
|
|||
: roleId1
|
||||
}
|
||||
|
||||
export function compareRoleIds(roleId1: string, roleId2: string) {
|
||||
// make sure both role IDs are prefixed correctly
|
||||
return prefixRoleID(roleId1) === prefixRoleID(roleId2)
|
||||
}
|
||||
|
||||
export function externalRole(role: RoleDoc): RoleDoc {
|
||||
let _id: string | undefined
|
||||
if (role._id) {
|
||||
_id = getExternalRoleID(role._id)
|
||||
}
|
||||
return {
|
||||
...role,
|
||||
_id,
|
||||
inherits: getExternalRoleIDs(role.inherits, role.version),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of roles, this will pick the role out, accounting for built ins.
|
||||
*/
|
||||
export function findRole(
|
||||
roleId: string,
|
||||
roles: RoleDoc[],
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): RoleDoc | undefined {
|
||||
// built in roles mostly come from the in-code implementation,
|
||||
// but can be extended by a doc stored about them (e.g. permissions)
|
||||
let role: RoleDoc | undefined = getBuiltinRole(roleId)
|
||||
if (!role) {
|
||||
// make sure has the prefix (if it has it then it won't be added)
|
||||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
const dbRole = roles.find(
|
||||
role => role._id && compareRoleIds(role._id, roleId)
|
||||
)
|
||||
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
}
|
||||
// combine the roles
|
||||
role = Object.assign(role || {}, dbRole)
|
||||
// finalise the ID
|
||||
if (role?._id) {
|
||||
role._id = getExternalRoleID(role._id, role.version)
|
||||
}
|
||||
return Object.keys(role).length === 0 ? undefined : role
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and
|
||||
* to check if the role inherits any others.
|
||||
|
@ -203,30 +347,28 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
|
|||
export async function getRole(
|
||||
roleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc> {
|
||||
// built in roles mostly come from the in-code implementation,
|
||||
// but can be extended by a doc stored about them (e.g. permissions)
|
||||
let role: RoleDoc | undefined = getBuiltinRole(roleId)
|
||||
if (!role) {
|
||||
// make sure has the prefix (if it has it then it won't be added)
|
||||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
try {
|
||||
const db = getAppDB()
|
||||
const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
|
||||
role = Object.assign(role || {}, dbRole)
|
||||
// finalise the ID
|
||||
role._id = getExternalRoleID(role._id!, role.version)
|
||||
} catch (err) {
|
||||
if (!isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
}
|
||||
// only throw an error if there is no role at all
|
||||
if (!role || Object.keys(role).length === 0) {
|
||||
throw err
|
||||
): Promise<RoleDoc | undefined> {
|
||||
const db = getAppDB()
|
||||
const roleList = []
|
||||
if (!isBuiltin(roleId)) {
|
||||
const role = await db.tryGet<RoleDoc>(getDBRoleID(roleId))
|
||||
if (role) {
|
||||
roleList.push(role)
|
||||
}
|
||||
}
|
||||
return role
|
||||
return findRole(roleId, roleList, opts)
|
||||
}
|
||||
|
||||
export async function saveRoles(roles: RoleDoc[]) {
|
||||
const db = getAppDB()
|
||||
await db.bulkDocs(
|
||||
roles
|
||||
.filter(role => role._id)
|
||||
.map(role => ({
|
||||
...role,
|
||||
_id: prefixRoleID(role._id!),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,24 +378,18 @@ async function getAllUserRoles(
|
|||
userRoleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc[]> {
|
||||
const allRoles = await getAllRoles()
|
||||
// admins have access to all roles
|
||||
if (userRoleId === BUILTIN_IDS.ADMIN) {
|
||||
return getAllRoles()
|
||||
return allRoles
|
||||
}
|
||||
let currentRole = await getRole(userRoleId, opts)
|
||||
let roles = currentRole ? [currentRole] : []
|
||||
let roleIds = [userRoleId]
|
||||
|
||||
// get all the inherited roles
|
||||
while (
|
||||
currentRole &&
|
||||
currentRole.inherits &&
|
||||
roleIds.indexOf(currentRole.inherits) === -1
|
||||
) {
|
||||
roleIds.push(currentRole.inherits)
|
||||
currentRole = await getRole(currentRole.inherits)
|
||||
if (currentRole) {
|
||||
roles.push(currentRole)
|
||||
}
|
||||
const foundRole = findRole(userRoleId, allRoles, opts)
|
||||
let roles: RoleDoc[] = []
|
||||
if (foundRole) {
|
||||
const traversal = new RoleHierarchyTraversal(allRoles, opts)
|
||||
roles = traversal.walk(foundRole)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
@ -319,7 +455,7 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
return internal(appDB)
|
||||
}
|
||||
async function internal(db: any) {
|
||||
async function internal(db: Database | undefined) {
|
||||
let roles: RoleDoc[] = []
|
||||
if (db) {
|
||||
const body = await db.allDocs(
|
||||
|
@ -334,8 +470,26 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
const builtinRoles = getBuiltinRoles()
|
||||
|
||||
// exclude internal roles like builder
|
||||
let externalBuiltinRoles = []
|
||||
|
||||
if (!db || (await shouldIncludePowerRole(db))) {
|
||||
externalBuiltinRoles = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.POWER,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
} else {
|
||||
externalBuiltinRoles = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
}
|
||||
|
||||
// need to combine builtin with any DB record of them (for sake of permissions)
|
||||
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
|
||||
for (let builtinRoleId of externalBuiltinRoles) {
|
||||
const builtinRole = builtinRoles[builtinRoleId]
|
||||
const dbBuiltin = roles.filter(
|
||||
dbRole =>
|
||||
|
@ -366,6 +520,18 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async function shouldIncludePowerRole(db: Database) {
|
||||
const app = await db.tryGet<App>(DocumentType.APP_METADATA)
|
||||
const creationVersion = app?.creationVersion
|
||||
if (!creationVersion || !semver.valid(creationVersion)) {
|
||||
// Old apps don't have creationVersion, so we should include it for backward compatibility
|
||||
return true
|
||||
}
|
||||
|
||||
const isGreaterThan3x = semver.gte(creationVersion, "3.0.0")
|
||||
return !isGreaterThan3x
|
||||
}
|
||||
|
||||
export class AccessController {
|
||||
userHierarchies: { [key: string]: string[] }
|
||||
constructor() {
|
||||
|
@ -390,7 +556,10 @@ export class AccessController {
|
|||
this.userHierarchies[userRoleId] = roleIds
|
||||
}
|
||||
|
||||
return roleIds?.indexOf(tryingRoleId) !== -1
|
||||
return (
|
||||
roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
async checkScreensAccess(screens: Screen[], userRoleId: string) {
|
||||
|
@ -432,7 +601,7 @@ export function getDBRoleID(roleName: string) {
|
|||
export function getExternalRoleID(roleId: string, version?: string) {
|
||||
// for built-in roles we want to remove the DB role ID element (role_)
|
||||
if (
|
||||
roleId.startsWith(DocumentType.ROLE) &&
|
||||
roleId.startsWith(`${DocumentType.ROLE}${SEPARATOR}`) &&
|
||||
(isBuiltin(roleId) || version === RoleIDVersion.NAME)
|
||||
) {
|
||||
const parts = roleId.split(SEPARATOR)
|
||||
|
@ -441,3 +610,16 @@ export function getExternalRoleID(roleId: string, version?: string) {
|
|||
}
|
||||
return roleId
|
||||
}
|
||||
|
||||
export function getExternalRoleIDs(
|
||||
roleIds: string | string[] | undefined,
|
||||
version?: string
|
||||
) {
|
||||
if (!roleIds) {
|
||||
return roleIds
|
||||
} else if (typeof roleIds === "string") {
|
||||
return getExternalRoleID(roleIds, version)
|
||||
} else {
|
||||
return roleIds.map(roleId => getExternalRoleID(roleId, version))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,12 +23,14 @@ import {
|
|||
InternalSearchFilterOperator,
|
||||
JsonFieldMetadata,
|
||||
JsonTypes,
|
||||
LogicalOperator,
|
||||
Operation,
|
||||
prefixed,
|
||||
QueryJson,
|
||||
QueryOptions,
|
||||
RangeOperator,
|
||||
RelationshipsJson,
|
||||
SearchFilterKey,
|
||||
SearchFilters,
|
||||
SortOrder,
|
||||
SqlClient,
|
||||
|
@ -96,6 +98,22 @@ function isSqs(table: Table): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
const allowEmptyRelationships: Record<SearchFilterKey, boolean> = {
|
||||
[BasicOperator.EQUAL]: false,
|
||||
[BasicOperator.NOT_EQUAL]: true,
|
||||
[BasicOperator.EMPTY]: false,
|
||||
[BasicOperator.NOT_EMPTY]: true,
|
||||
[BasicOperator.FUZZY]: false,
|
||||
[BasicOperator.STRING]: false,
|
||||
[RangeOperator.RANGE]: false,
|
||||
[ArrayOperator.CONTAINS]: false,
|
||||
[ArrayOperator.NOT_CONTAINS]: true,
|
||||
[ArrayOperator.CONTAINS_ANY]: false,
|
||||
[ArrayOperator.ONE_OF]: false,
|
||||
[LogicalOperator.AND]: false,
|
||||
[LogicalOperator.OR]: false,
|
||||
}
|
||||
|
||||
class InternalBuilder {
|
||||
private readonly client: SqlClient
|
||||
private readonly query: QueryJson
|
||||
|
@ -405,31 +423,48 @@ class InternalBuilder {
|
|||
|
||||
addRelationshipForFilter(
|
||||
query: Knex.QueryBuilder,
|
||||
allowEmptyRelationships: boolean,
|
||||
filterKey: string,
|
||||
whereCb: (query: Knex.QueryBuilder) => Knex.QueryBuilder
|
||||
whereCb: (filterKey: string, query: Knex.QueryBuilder) => Knex.QueryBuilder
|
||||
): Knex.QueryBuilder {
|
||||
const mainKnex = this.knex
|
||||
const { relationships, endpoint, tableAliases: aliases } = this.query
|
||||
const tableName = endpoint.entityId
|
||||
const fromAlias = aliases?.[tableName] || tableName
|
||||
const matches = (possibleTable: string) =>
|
||||
filterKey.startsWith(`${possibleTable}`)
|
||||
const matches = (value: string) =>
|
||||
filterKey.match(new RegExp(`^${value}\\.`))
|
||||
if (!relationships) {
|
||||
return query
|
||||
}
|
||||
for (const relationship of relationships) {
|
||||
const relatedTableName = relationship.tableName
|
||||
const toAlias = aliases?.[relatedTableName] || relatedTableName
|
||||
|
||||
const matchesTableName = matches(relatedTableName) || matches(toAlias)
|
||||
const matchesRelationName = matches(relationship.column)
|
||||
|
||||
// this is the relationship which is being filtered
|
||||
if (
|
||||
(matches(relatedTableName) || matches(toAlias)) &&
|
||||
(matchesTableName || matchesRelationName) &&
|
||||
relationship.to &&
|
||||
relationship.tableName
|
||||
) {
|
||||
let subQuery = mainKnex
|
||||
const joinTable = mainKnex
|
||||
.select(mainKnex.raw(1))
|
||||
.from({ [toAlias]: relatedTableName })
|
||||
let subQuery = joinTable.clone()
|
||||
const manyToMany = validateManyToMany(relationship)
|
||||
let updatedKey
|
||||
|
||||
if (!matchesTableName) {
|
||||
updatedKey = filterKey.replace(
|
||||
new RegExp(`^${relationship.column}.`),
|
||||
`${aliases![relationship.tableName]}.`
|
||||
)
|
||||
} else {
|
||||
updatedKey = filterKey
|
||||
}
|
||||
|
||||
if (manyToMany) {
|
||||
const throughAlias =
|
||||
aliases?.[manyToMany.through] || relationship.through
|
||||
|
@ -440,7 +475,6 @@ class InternalBuilder {
|
|||
subQuery = subQuery
|
||||
// add a join through the junction table
|
||||
.innerJoin(throughTable, function () {
|
||||
// @ts-ignore
|
||||
this.on(
|
||||
`${toAlias}.${manyToMany.toPrimary}`,
|
||||
"=",
|
||||
|
@ -460,18 +494,38 @@ class InternalBuilder {
|
|||
if (this.client === SqlClient.SQL_LITE) {
|
||||
subQuery = this.addJoinFieldCheck(subQuery, manyToMany)
|
||||
}
|
||||
|
||||
query = query.where(q => {
|
||||
q.whereExists(whereCb(updatedKey, subQuery))
|
||||
if (allowEmptyRelationships) {
|
||||
q.orWhereNotExists(
|
||||
joinTable.clone().innerJoin(throughTable, function () {
|
||||
this.on(
|
||||
`${fromAlias}.${manyToMany.fromPrimary}`,
|
||||
"=",
|
||||
`${throughAlias}.${manyToMany.from}`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const toKey = `${toAlias}.${relationship.to}`
|
||||
const foreignKey = `${fromAlias}.${relationship.from}`
|
||||
// "join" to the main table, making sure the ID matches that of the main
|
||||
subQuery = subQuery.where(
|
||||
`${toAlias}.${relationship.to}`,
|
||||
toKey,
|
||||
"=",
|
||||
mainKnex.raw(
|
||||
this.quotedIdentifier(`${fromAlias}.${relationship.from}`)
|
||||
)
|
||||
mainKnex.raw(this.quotedIdentifier(foreignKey))
|
||||
)
|
||||
|
||||
query = query.where(q => {
|
||||
q.whereExists(whereCb(updatedKey, subQuery.clone()))
|
||||
if (allowEmptyRelationships) {
|
||||
q.orWhereNotExists(subQuery)
|
||||
}
|
||||
})
|
||||
}
|
||||
query = query.whereExists(whereCb(subQuery))
|
||||
break
|
||||
}
|
||||
}
|
||||
return query
|
||||
|
@ -502,6 +556,7 @@ class InternalBuilder {
|
|||
}
|
||||
function iterate(
|
||||
structure: AnySearchFilter,
|
||||
operation: SearchFilterKey,
|
||||
fn: (
|
||||
query: Knex.QueryBuilder,
|
||||
key: string,
|
||||
|
@ -558,9 +613,14 @@ class InternalBuilder {
|
|||
if (allOr) {
|
||||
query = query.or
|
||||
}
|
||||
query = builder.addRelationshipForFilter(query, updatedKey, q => {
|
||||
return handleRelationship(q, updatedKey, value)
|
||||
})
|
||||
query = builder.addRelationshipForFilter(
|
||||
query,
|
||||
allowEmptyRelationships[operation],
|
||||
updatedKey,
|
||||
(updatedKey, q) => {
|
||||
return handleRelationship(q, updatedKey, value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -592,7 +652,7 @@ class InternalBuilder {
|
|||
return `[${value.join(",")}]`
|
||||
}
|
||||
if (this.client === SqlClient.POSTGRES) {
|
||||
iterate(mode, (q, key, value) => {
|
||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||
const wrap = any ? "" : "'"
|
||||
const op = any ? "\\?| array" : "@>"
|
||||
const fieldNames = key.split(/\./g)
|
||||
|
@ -610,7 +670,7 @@ class InternalBuilder {
|
|||
this.client === SqlClient.MARIADB
|
||||
) {
|
||||
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
|
||||
iterate(mode, (q, key, value) => {
|
||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||
return q[rawFnc](
|
||||
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
|
||||
value
|
||||
|
@ -619,7 +679,7 @@ class InternalBuilder {
|
|||
})
|
||||
} else {
|
||||
const andOr = mode === filters?.containsAny ? " OR " : " AND "
|
||||
iterate(mode, (q, key, value) => {
|
||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||
let statement = ""
|
||||
const identifier = this.quotedIdentifier(key)
|
||||
for (let i in value) {
|
||||
|
@ -673,6 +733,7 @@ class InternalBuilder {
|
|||
const fnc = allOr ? "orWhereIn" : "whereIn"
|
||||
iterate(
|
||||
filters.oneOf,
|
||||
ArrayOperator.ONE_OF,
|
||||
(q, key: string, array) => {
|
||||
if (this.client === SqlClient.ORACLE) {
|
||||
key = this.convertClobs(key)
|
||||
|
@ -697,7 +758,7 @@ class InternalBuilder {
|
|||
)
|
||||
}
|
||||
if (filters.string) {
|
||||
iterate(filters.string, (q, key, value) => {
|
||||
iterate(filters.string, BasicOperator.STRING, (q, key, value) => {
|
||||
const fnc = allOr ? "orWhere" : "where"
|
||||
// postgres supports ilike, nothing else does
|
||||
if (this.client === SqlClient.POSTGRES) {
|
||||
|
@ -712,10 +773,10 @@ class InternalBuilder {
|
|||
})
|
||||
}
|
||||
if (filters.fuzzy) {
|
||||
iterate(filters.fuzzy, like)
|
||||
iterate(filters.fuzzy, BasicOperator.FUZZY, like)
|
||||
}
|
||||
if (filters.range) {
|
||||
iterate(filters.range, (q, key, value) => {
|
||||
iterate(filters.range, RangeOperator.RANGE, (q, key, value) => {
|
||||
const isEmptyObject = (val: any) => {
|
||||
return (
|
||||
val &&
|
||||
|
@ -781,7 +842,7 @@ class InternalBuilder {
|
|||
})
|
||||
}
|
||||
if (filters.equal) {
|
||||
iterate(filters.equal, (q, key, value) => {
|
||||
iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => {
|
||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||
if (this.client === SqlClient.MS_SQL) {
|
||||
return q[fnc](
|
||||
|
@ -801,7 +862,7 @@ class InternalBuilder {
|
|||
})
|
||||
}
|
||||
if (filters.notEqual) {
|
||||
iterate(filters.notEqual, (q, key, value) => {
|
||||
iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => {
|
||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||
if (this.client === SqlClient.MS_SQL) {
|
||||
return q[fnc](
|
||||
|
@ -822,13 +883,13 @@ class InternalBuilder {
|
|||
})
|
||||
}
|
||||
if (filters.empty) {
|
||||
iterate(filters.empty, (q, key) => {
|
||||
iterate(filters.empty, BasicOperator.EMPTY, (q, key) => {
|
||||
const fnc = allOr ? "orWhereNull" : "whereNull"
|
||||
return q[fnc](key)
|
||||
})
|
||||
}
|
||||
if (filters.notEmpty) {
|
||||
iterate(filters.notEmpty, (q, key) => {
|
||||
iterate(filters.notEmpty, BasicOperator.NOT_EMPTY, (q, key) => {
|
||||
const fnc = allOr ? "orWhereNotNull" : "whereNotNull"
|
||||
return q[fnc](key)
|
||||
})
|
||||
|
@ -1224,12 +1285,10 @@ class InternalBuilder {
|
|||
})
|
||||
: undefined
|
||||
if (!throughTable) {
|
||||
// @ts-ignore
|
||||
query = query.leftJoin(toTableWithSchema, function () {
|
||||
for (let relationship of columns) {
|
||||
const from = relationship.from,
|
||||
to = relationship.to
|
||||
// @ts-ignore
|
||||
this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
|
||||
}
|
||||
})
|
||||
|
@ -1240,7 +1299,6 @@ class InternalBuilder {
|
|||
for (let relationship of columns) {
|
||||
const fromPrimary = relationship.fromPrimary
|
||||
const from = relationship.from
|
||||
// @ts-ignore
|
||||
this.orOn(
|
||||
`${fromAlias}.${fromPrimary}`,
|
||||
"=",
|
||||
|
@ -1252,7 +1310,6 @@ class InternalBuilder {
|
|||
for (let relationship of columns) {
|
||||
const toPrimary = relationship.toPrimary
|
||||
const to = relationship.to
|
||||
// @ts-ignore
|
||||
this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@ import SchemaBuilder = Knex.SchemaBuilder
|
|||
import CreateTableBuilder = Knex.CreateTableBuilder
|
||||
|
||||
function isIgnoredType(type: FieldType) {
|
||||
const ignored = [FieldType.LINK, FieldType.FORMULA]
|
||||
const ignored = [FieldType.LINK, FieldType.FORMULA, FieldType.AI]
|
||||
return ignored.indexOf(type) !== -1
|
||||
}
|
||||
|
||||
|
@ -144,6 +144,9 @@ function generateSchema(
|
|||
case FieldType.FORMULA:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.AI:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.ATTACHMENTS:
|
||||
case FieldType.ATTACHMENT_SINGLE:
|
||||
case FieldType.SIGNATURE_SINGLE:
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
AutomationEventType.ROW_DELETE,
|
||||
AutomationEventType.ROW_UPDATE,
|
||||
AutomationEventType.ROW_SAVE,
|
||||
AutomationEventType.ROW_ACTION,
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import { automationStore } from "stores/builder"
|
||||
import {
|
||||
notifications,
|
||||
|
@ -32,11 +33,12 @@
|
|||
triggerVal.stepId,
|
||||
triggerVal
|
||||
)
|
||||
await automationStore.actions.create(name, trigger)
|
||||
const automation = await automationStore.actions.create(name, trigger)
|
||||
if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
|
||||
webhookModal.show()
|
||||
}
|
||||
notifications.success(`Automation ${name} created`)
|
||||
$goto(`../automation/${automation._id}`)
|
||||
} catch (error) {
|
||||
notifications.error("Error creating automation")
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
TriggerStepID.ROW_UPDATED,
|
||||
TriggerStepID.ROW_SAVED,
|
||||
TriggerStepID.ROW_DELETED,
|
||||
TriggerStepID.ROW_ACTION,
|
||||
]
|
||||
|
||||
const rowEvents = [
|
||||
|
|
|
@ -2,6 +2,7 @@ export const TriggerStepID = {
|
|||
ROW_SAVED: "ROW_SAVED",
|
||||
ROW_UPDATED: "ROW_UPDATED",
|
||||
ROW_DELETED: "ROW_DELETED",
|
||||
ROW_ACTION: "ROW_ACTION",
|
||||
WEBHOOK: "WEBHOOK",
|
||||
APP: "APP",
|
||||
CRON: "CRON",
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{
|
||||
"name": "Layout",
|
||||
"icon": "ClassicGridView",
|
||||
"children": ["container", "section", "sidepanel", "modal"]
|
||||
"children": ["container", "sidepanel", "modal"]
|
||||
},
|
||||
{
|
||||
"name": "Data",
|
||||
|
|
|
@ -76,9 +76,7 @@
|
|||
const params = new URLSearchParams({
|
||||
open: "error",
|
||||
})
|
||||
$goto(
|
||||
`/builder/app/${appId}/settings/automation-history?${params.toString()}`
|
||||
)
|
||||
$goto(`/builder/app/${appId}/settings/automations?${params.toString()}`)
|
||||
}
|
||||
|
||||
const errorCount = errors => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import Placeholder from "./Placeholder.svelte"
|
||||
import Placeholder from "../Placeholder.svelte"
|
||||
|
||||
const { styleable, builderStore } = getContext("sdk")
|
||||
const component = getContext("component")
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { getContext, setContext } from "svelte"
|
||||
import Section from "../Section.svelte"
|
||||
import Section from "../deprecated/Section.svelte"
|
||||
|
||||
export let labelPosition = "above"
|
||||
export let type = "oneColumn"
|
||||
|
|
|
@ -14,7 +14,6 @@ export { default as Placeholder } from "./Placeholder.svelte"
|
|||
|
||||
// User facing components
|
||||
export { default as container } from "./container/Container.svelte"
|
||||
export { default as section } from "./Section.svelte"
|
||||
export { default as dataprovider } from "./DataProvider.svelte"
|
||||
export { default as divider } from "./Divider.svelte"
|
||||
export { default as screenslot } from "./ScreenSlot.svelte"
|
||||
|
@ -50,3 +49,4 @@ export { default as navigation } from "./deprecated/Navigation.svelte"
|
|||
export { default as cardhorizontal } from "./deprecated/CardHorizontal.svelte"
|
||||
export { default as stackedlist } from "./deprecated/StackedList.svelte"
|
||||
export { default as card } from "./deprecated/Card.svelte"
|
||||
export { default as section } from "./deprecated/Section.svelte"
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit fc4c7f4925139af078480217965c3d6338dc0a7f
|
||||
Subproject commit 297fdc937e9c650b4964fc1a942b60022b195865
|
|
@ -208,9 +208,8 @@ export async function fetchAppDefinition(
|
|||
export async function fetchAppPackage(
|
||||
ctx: UserCtx<void, FetchAppPackageResponse>
|
||||
) {
|
||||
const db = context.getAppDB()
|
||||
const appId = context.getAppId()
|
||||
let application = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const application = await sdk.applications.metadata.get()
|
||||
const layouts = await getLayouts()
|
||||
let screens = await getScreens()
|
||||
const license = await licensing.cache.getCachedLicense()
|
||||
|
@ -272,6 +271,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
path: ctx.request.body.file?.path,
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
|
||||
const appId = generateDevAppID(generateAppID(tenantId))
|
||||
|
||||
|
@ -279,7 +279,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
const instance = await createInstance(appId, instanceConfig)
|
||||
const db = context.getAppDB()
|
||||
|
||||
let newApplication: App = {
|
||||
const newApplication: App = {
|
||||
_id: DocumentType.APP_METADATA,
|
||||
_rev: undefined,
|
||||
appId,
|
||||
|
@ -310,12 +310,18 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
disableUserMetadata: true,
|
||||
skeletonLoader: true,
|
||||
},
|
||||
creationVersion: undefined,
|
||||
}
|
||||
|
||||
const isImport = !!instanceConfig.file
|
||||
if (!isImport) {
|
||||
newApplication.creationVersion = envCore.VERSION
|
||||
}
|
||||
|
||||
const existing = await sdk.applications.metadata.tryGet()
|
||||
// If we used a template or imported an app there will be an existing doc.
|
||||
// Fetch and migrate some metadata from the existing app.
|
||||
try {
|
||||
const existing: App = await db.get(DocumentType.APP_METADATA)
|
||||
if (existing) {
|
||||
const keys: (keyof App)[] = [
|
||||
"_rev",
|
||||
"navigation",
|
||||
|
@ -323,6 +329,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
"customTheme",
|
||||
"icon",
|
||||
"snippets",
|
||||
"creationVersion",
|
||||
]
|
||||
keys.forEach(key => {
|
||||
if (existing[key]) {
|
||||
|
@ -340,14 +347,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
}
|
||||
|
||||
// Migrate navigation settings and screens if required
|
||||
if (existing) {
|
||||
const navigation = await migrateAppNavigation()
|
||||
if (navigation) {
|
||||
newApplication.navigation = navigation
|
||||
}
|
||||
const navigation = await migrateAppNavigation()
|
||||
if (navigation) {
|
||||
newApplication.navigation = navigation
|
||||
}
|
||||
} catch (err) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
const response = await db.put(newApplication, { force: true })
|
||||
|
@ -489,8 +492,7 @@ export async function update(
|
|||
|
||||
export async function updateClient(ctx: UserCtx) {
|
||||
// Get current app version
|
||||
const db = context.getAppDB()
|
||||
const application = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const application = await sdk.applications.metadata.get()
|
||||
const currentVersion = application.version
|
||||
|
||||
let manifest
|
||||
|
@ -518,8 +520,7 @@ export async function updateClient(ctx: UserCtx) {
|
|||
|
||||
export async function revertClient(ctx: UserCtx) {
|
||||
// Check app can be reverted
|
||||
const db = context.getAppDB()
|
||||
const application = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const application = await sdk.applications.metadata.get()
|
||||
if (!application.revertableVersion) {
|
||||
ctx.throw(400, "There is no version to revert to")
|
||||
}
|
||||
|
@ -577,7 +578,7 @@ async function destroyApp(ctx: UserCtx) {
|
|||
|
||||
const db = dbCore.getDB(devAppId)
|
||||
// standard app deletion flow
|
||||
const app = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const app = await sdk.applications.metadata.get()
|
||||
const result = await db.destroy()
|
||||
await quotas.removeApp()
|
||||
await events.app.deleted(app)
|
||||
|
@ -728,7 +729,7 @@ export async function updateAppPackage(
|
|||
) {
|
||||
return context.doInAppContext(appId, async () => {
|
||||
const db = context.getAppDB()
|
||||
const application = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const application = await sdk.applications.metadata.get()
|
||||
|
||||
const newAppPackage: App = { ...application, ...appPackage }
|
||||
if (appPackage._rev !== application._rev) {
|
||||
|
@ -754,7 +755,7 @@ export async function setRevertableVersion(
|
|||
return
|
||||
}
|
||||
const db = context.getAppDB()
|
||||
const app = await db.get<App>(DocumentType.APP_METADATA)
|
||||
const app = await sdk.applications.metadata.get()
|
||||
app.revertableVersion = ctx.request.body.revertableVersion
|
||||
await db.put(app)
|
||||
|
||||
|
@ -763,7 +764,7 @@ export async function setRevertableVersion(
|
|||
|
||||
async function migrateAppNavigation() {
|
||||
const db = context.getAppDB()
|
||||
const existing: App = await db.get(DocumentType.APP_METADATA)
|
||||
const existing = await sdk.applications.metadata.get()
|
||||
const layouts: Layout[] = await getLayouts()
|
||||
const screens: Screen[] = await getScreens()
|
||||
|
||||
|
|
|
@ -159,6 +159,7 @@ export async function trigger(ctx: UserCtx) {
|
|||
automation,
|
||||
{
|
||||
fields: ctx.request.body.fields,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
timeout:
|
||||
ctx.request.body.timeout * 1000 || env.AUTOMATION_THREAD_TIMEOUT,
|
||||
},
|
||||
|
@ -183,6 +184,7 @@ export async function trigger(ctx: UserCtx) {
|
|||
await triggers.externalTrigger(automation, {
|
||||
...ctx.request.body,
|
||||
appId: ctx.appId,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
ctx.body = {
|
||||
message: `Automation ${automation._id} has been triggered.`,
|
||||
|
@ -212,6 +214,7 @@ export async function test(ctx: UserCtx) {
|
|||
{
|
||||
...testInput,
|
||||
appId: ctx.appId,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
},
|
||||
{ getResponses: true }
|
||||
)
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
UserMetadata,
|
||||
DocumentType,
|
||||
} from "@budibase/types"
|
||||
import { RoleColor, sdk as sharedSdk } from "@budibase/shared-core"
|
||||
import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core"
|
||||
import sdk from "../../sdk"
|
||||
|
||||
const UpdateRolesOptions = {
|
||||
|
@ -27,6 +27,30 @@ const UpdateRolesOptions = {
|
|||
REMOVED: "removed",
|
||||
}
|
||||
|
||||
async function removeRoleFromOthers(roleId: string) {
|
||||
const allOtherRoles = await roles.getAllRoles()
|
||||
const updated: Role[] = []
|
||||
for (let role of allOtherRoles) {
|
||||
let changed = false
|
||||
if (Array.isArray(role.inherits)) {
|
||||
const newInherits = role.inherits.filter(
|
||||
id => !roles.compareRoleIds(id, roleId)
|
||||
)
|
||||
changed = role.inherits.length !== newInherits.length
|
||||
role.inherits = newInherits
|
||||
} else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) {
|
||||
role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
updated.push(role)
|
||||
}
|
||||
}
|
||||
if (updated.length) {
|
||||
await roles.saveRoles(updated)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRolesOnUserTable(
|
||||
db: Database,
|
||||
roleId: string,
|
||||
|
@ -53,18 +77,25 @@ async function updateRolesOnUserTable(
|
|||
}
|
||||
|
||||
export async function fetch(ctx: UserCtx<void, FetchRolesResponse>) {
|
||||
ctx.body = await roles.getAllRoles()
|
||||
ctx.body = (await roles.getAllRoles()).map(role => roles.externalRole(role))
|
||||
}
|
||||
|
||||
export async function find(ctx: UserCtx<void, FindRoleResponse>) {
|
||||
ctx.body = await roles.getRole(ctx.params.roleId)
|
||||
const role = await roles.getRole(ctx.params.roleId)
|
||||
if (!role) {
|
||||
ctx.throw(404, { message: "Role not found" })
|
||||
}
|
||||
ctx.body = roles.externalRole(role)
|
||||
}
|
||||
|
||||
export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
||||
const db = context.getAppDB()
|
||||
let { _id, name, inherits, permissionId, version, uiMetadata } =
|
||||
let { _id, _rev, name, inherits, permissionId, version, uiMetadata } =
|
||||
ctx.request.body
|
||||
let isCreate = false
|
||||
if (!_rev && !version) {
|
||||
version = roles.RoleIDVersion.NAME
|
||||
}
|
||||
const isNewVersion = version === roles.RoleIDVersion.NAME
|
||||
|
||||
if (_id && roles.isBuiltin(_id)) {
|
||||
|
@ -81,9 +112,13 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
|||
_id = dbCore.prefixRoleID(_id)
|
||||
}
|
||||
|
||||
const allRoles = (await roles.getAllRoles()).map(role => ({
|
||||
...role,
|
||||
_id: dbCore.prefixRoleID(role._id!),
|
||||
}))
|
||||
let dbRole: Role | undefined
|
||||
if (!isCreate && _id?.startsWith(DocumentType.ROLE)) {
|
||||
dbRole = await db.get<Role>(_id)
|
||||
dbRole = allRoles.find(role => role._id === _id)
|
||||
}
|
||||
if (dbRole && dbRole.name !== name && isNewVersion) {
|
||||
ctx.throw(400, "Cannot change custom role name")
|
||||
|
@ -97,7 +132,19 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
|||
if (dbRole?.permissions && !role.permissions) {
|
||||
role.permissions = dbRole.permissions
|
||||
}
|
||||
const foundRev = ctx.request.body._rev || dbRole?._rev
|
||||
|
||||
// add the new role to the list and check for loops
|
||||
const index = allRoles.findIndex(r => r._id === role._id)
|
||||
if (index === -1) {
|
||||
allRoles.push(role)
|
||||
} else {
|
||||
allRoles[index] = role
|
||||
}
|
||||
if (helpers.roles.checkForRoleInheritanceLoops(allRoles)) {
|
||||
ctx.throw(400, "Role inheritance contains a loop, this is not supported")
|
||||
}
|
||||
|
||||
const foundRev = _rev || dbRole?._rev
|
||||
if (foundRev) {
|
||||
role._rev = foundRev
|
||||
}
|
||||
|
@ -114,7 +161,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
|||
role.version
|
||||
)
|
||||
role._rev = result.rev
|
||||
ctx.body = role
|
||||
ctx.body = roles.externalRole(role)
|
||||
|
||||
const devDb = context.getDevAppDB()
|
||||
const prodDb = context.getProdAppDB()
|
||||
|
@ -163,6 +210,10 @@ export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
|
|||
UpdateRolesOptions.REMOVED,
|
||||
role.version
|
||||
)
|
||||
|
||||
// clean up inherits
|
||||
await removeRoleFromOthers(roleId)
|
||||
|
||||
ctx.message = `Role ${ctx.params.roleId} deleted successfully`
|
||||
ctx.status = 200
|
||||
}
|
||||
|
@ -172,30 +223,35 @@ export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
|
|||
if (!roleId) {
|
||||
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||
}
|
||||
let roleIds: string[] = []
|
||||
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
|
||||
const appId = context.getAppId()
|
||||
if (!appId) {
|
||||
ctx.body = []
|
||||
} else {
|
||||
ctx.body = await roles.getAllRoleIds(appId)
|
||||
if (appId) {
|
||||
roleIds = await roles.getAllRoleIds(appId)
|
||||
}
|
||||
} else {
|
||||
ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
|
||||
roleIds = await roles.getUserRoleIdHierarchy(roleId!)
|
||||
}
|
||||
|
||||
// If a custom role is provided in the header, filter out higher level roles
|
||||
const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
|
||||
if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
|
||||
const inherits = (await roles.getRole(roleHeader))?.inherits
|
||||
const orderedRoles = ctx.body.reverse()
|
||||
const role = await roles.getRole(roleHeader)
|
||||
const inherits = role?.inherits
|
||||
const orderedRoles = roleIds.reverse()
|
||||
let filteredRoles = [roleHeader]
|
||||
for (let role of orderedRoles) {
|
||||
filteredRoles = [role, ...filteredRoles]
|
||||
if (role === inherits) {
|
||||
if (
|
||||
(Array.isArray(inherits) && inherits.includes(role)) ||
|
||||
role === inherits
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
filteredRoles.pop()
|
||||
ctx.body = [roleHeader, ...filteredRoles]
|
||||
roleIds = [roleHeader, ...filteredRoles]
|
||||
}
|
||||
|
||||
ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId))
|
||||
}
|
||||
|
|
|
@ -204,18 +204,6 @@ export class ExternalRequest<T extends Operation> {
|
|||
filters: SearchFilters,
|
||||
table: Table
|
||||
): SearchFilters {
|
||||
// replace any relationship columns initially, table names and relationship column names are acceptable
|
||||
const relationshipColumns = sdk.rows.filters.getRelationshipColumns(table)
|
||||
filters = sdk.rows.filters.updateFilterKeys(
|
||||
filters,
|
||||
relationshipColumns.map(({ name, definition }) => {
|
||||
const { tableName } = breakExternalTableId(definition.tableId)
|
||||
return {
|
||||
original: name,
|
||||
updated: tableName,
|
||||
}
|
||||
})
|
||||
)
|
||||
const primary = table.primary
|
||||
// if passed in array need to copy for shifting etc
|
||||
let idCopy: undefined | string | any[] = cloneDeep(id)
|
||||
|
|
|
@ -15,13 +15,16 @@ import {
|
|||
ExportRowsResponse,
|
||||
FieldType,
|
||||
GetRowResponse,
|
||||
isRelationshipField,
|
||||
PatchRowRequest,
|
||||
PatchRowResponse,
|
||||
Row,
|
||||
RowAttachment,
|
||||
RowSearchParams,
|
||||
SearchFilters,
|
||||
SearchRowRequest,
|
||||
SearchRowResponse,
|
||||
Table,
|
||||
UserCtx,
|
||||
ValidateResponse,
|
||||
} from "@budibase/types"
|
||||
|
@ -33,6 +36,7 @@ import sdk from "../../../sdk"
|
|||
import * as exporters from "../view/exporters"
|
||||
import { Format } from "../view/exporters"
|
||||
import { apiFileReturn } from "../../../utilities/fileSystem"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
|
||||
export * as views from "./views"
|
||||
|
||||
|
@ -61,7 +65,14 @@ export async function patch(
|
|||
}
|
||||
ctx.status = 200
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emitRow(`row:update`, appId, row, table, oldRow)
|
||||
ctx.eventEmitter.emitRow({
|
||||
eventName: `row:update`,
|
||||
appId,
|
||||
row,
|
||||
table,
|
||||
oldRow,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
ctx.message = `${table.name} updated successfully.`
|
||||
ctx.body = row
|
||||
gridSocket?.emitRowUpdate(ctx, row)
|
||||
|
@ -92,7 +103,14 @@ export const save = async (ctx: UserCtx<Row, Row>) => {
|
|||
sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id)
|
||||
)
|
||||
ctx.status = 200
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emitRow({
|
||||
eventName: `row:save`,
|
||||
appId,
|
||||
row,
|
||||
table,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
ctx.message = `${table.name} saved successfully`
|
||||
// prefer squashed for response
|
||||
ctx.body = row || squashed
|
||||
|
@ -164,10 +182,15 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
|
|||
}
|
||||
|
||||
for (let row of rows) {
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emitRow({
|
||||
eventName: `row:delete`,
|
||||
appId,
|
||||
row,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
gridSocket?.emitRowDeletion(ctx, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
|
@ -180,7 +203,13 @@ async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
|
|||
await quotas.removeRow()
|
||||
}
|
||||
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emitRow({
|
||||
eventName: `row:delete`,
|
||||
appId,
|
||||
row: resp.row,
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
gridSocket?.emitRowDeletion(ctx, resp.row)
|
||||
|
||||
return resp
|
||||
|
@ -211,12 +240,15 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
|||
|
||||
await context.ensureSnippetContext(true)
|
||||
|
||||
const enrichedQuery = await utils.enrichSearchContext(
|
||||
{ ...ctx.request.body.query },
|
||||
{
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
}
|
||||
)
|
||||
let { query } = ctx.request.body
|
||||
if (query) {
|
||||
const allTables = await sdk.tables.getAllTables()
|
||||
query = replaceTableNamesInFilters(tableId, query, allTables)
|
||||
}
|
||||
|
||||
let enrichedQuery: SearchFilters = await utils.enrichSearchContext(query, {
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
|
||||
const searchParams: RowSearchParams = {
|
||||
...ctx.request.body,
|
||||
|
@ -229,6 +261,47 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
|||
ctx.body = await sdk.rows.search(searchParams)
|
||||
}
|
||||
|
||||
function replaceTableNamesInFilters(
|
||||
tableId: string,
|
||||
filters: SearchFilters,
|
||||
allTables: Table[]
|
||||
): SearchFilters {
|
||||
for (const filter of Object.values(filters)) {
|
||||
for (const key of Object.keys(filter)) {
|
||||
const matches = key.match(`^(?<relation>.+)\\.(?<field>.+)`)
|
||||
|
||||
const relation = matches?.groups?.["relation"]
|
||||
const field = matches?.groups?.["field"]
|
||||
|
||||
if (!relation || !field) {
|
||||
continue
|
||||
}
|
||||
|
||||
const table = allTables.find(r => r._id === tableId)!
|
||||
if (Object.values(table.schema).some(f => f.name === relation)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedTable = allTables.find(t => t.name === relation)
|
||||
const relationship = Object.values(table.schema).find(
|
||||
f => isRelationshipField(f) && f.tableId === matchedTable?._id
|
||||
)
|
||||
if (!relationship) {
|
||||
continue
|
||||
}
|
||||
|
||||
const updatedField = `${relationship.name}.${field}`
|
||||
if (updatedField && updatedField !== key) {
|
||||
filter[updatedField] = filter[key]
|
||||
delete filter[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataFilters.recurseLogicalOperators(filters, (f: SearchFilters) => {
|
||||
return replaceTableNamesInFilters(tableId, f, allTables)
|
||||
})
|
||||
}
|
||||
|
||||
export async function validate(ctx: Ctx<Row, ValidateResponse>) {
|
||||
const source = await utils.getSource(ctx)
|
||||
const table = await utils.getTableFromSource(source)
|
||||
|
|
|
@ -5,6 +5,6 @@ export async function run(ctx: Ctx<RowActionTriggerRequest, void>) {
|
|||
const { tableId, actionId } = ctx.params
|
||||
const { rowId } = ctx.request.body
|
||||
|
||||
await sdk.rowActions.run(tableId, actionId, rowId)
|
||||
await sdk.rowActions.run(tableId, actionId, rowId, ctx.user)
|
||||
ctx.status = 200
|
||||
}
|
||||
|
|
|
@ -2,11 +2,12 @@ import { InvalidFileExtensions } from "@budibase/shared-core"
|
|||
import AppComponent from "./templates/BudibaseApp.svelte"
|
||||
import { join } from "../../../utilities/centralPath"
|
||||
import * as uuid from "uuid"
|
||||
import { devClientVersion, ObjectStoreBuckets } from "../../../constants"
|
||||
import { ObjectStoreBuckets } from "../../../constants"
|
||||
import { processString } from "@budibase/string-templates"
|
||||
import {
|
||||
loadHandlebarsFile,
|
||||
NODE_MODULES_PATH,
|
||||
shouldServeLocally,
|
||||
TOP_LEVEL_PATH,
|
||||
} from "../../../utilities/fileSystem"
|
||||
import env from "../../../environment"
|
||||
|
@ -257,25 +258,29 @@ export const serveBuilderPreview = async function (ctx: Ctx) {
|
|||
export const serveClientLibrary = async function (ctx: Ctx) {
|
||||
const version = ctx.request.query.version
|
||||
|
||||
if (Array.isArray(version)) {
|
||||
ctx.throw(400)
|
||||
}
|
||||
|
||||
const appId = context.getAppId() || (ctx.request.query.appId as string)
|
||||
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
|
||||
if (!appId) {
|
||||
ctx.throw(400, "No app ID provided - cannot fetch client library.")
|
||||
}
|
||||
if (env.isProd() || (env.isDev() && version !== devClientVersion)) {
|
||||
|
||||
const serveLocally = shouldServeLocally(version || "")
|
||||
if (!serveLocally) {
|
||||
ctx.body = await objectStore.getReadStream(
|
||||
ObjectStoreBuckets.APPS,
|
||||
objectStore.clientLibraryPath(appId!)
|
||||
)
|
||||
ctx.set("Content-Type", "application/javascript")
|
||||
} else if (env.isDev() && version === devClientVersion) {
|
||||
} else {
|
||||
// incase running from TS directly
|
||||
const tsPath = join(require.resolve("@budibase/client"), "..")
|
||||
return send(ctx, "budibase-client.js", {
|
||||
root: !fs.existsSync(rootPath) ? tsPath : rootPath,
|
||||
})
|
||||
} else {
|
||||
ctx.throw(500, "Unable to retrieve client library.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,34 +1,35 @@
|
|||
import { parse, isSchema, isRows } from "../../../utilities/schema"
|
||||
import { getRowParams, generateRowID, InternalTables } from "../../../db/utils"
|
||||
import { isRows, isSchema, parse } from "../../../utilities/schema"
|
||||
import { generateRowID, getRowParams, InternalTables } from "../../../db/utils"
|
||||
import isEqual from "lodash/isEqual"
|
||||
import {
|
||||
GOOGLE_SHEETS_PRIMARY_KEY,
|
||||
USERS_TABLE_SCHEMA,
|
||||
SwitchableTypes,
|
||||
CanSwitchTypes,
|
||||
GOOGLE_SHEETS_PRIMARY_KEY,
|
||||
SwitchableTypes,
|
||||
USERS_TABLE_SCHEMA,
|
||||
} from "../../../constants"
|
||||
import {
|
||||
inputProcessing,
|
||||
AttachmentCleanup,
|
||||
inputProcessing,
|
||||
} from "../../../utilities/rowProcessor"
|
||||
import { getViews, saveView } from "../view/utils"
|
||||
import viewTemplate from "../view/viewBuilder"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { quotas } from "@budibase/pro"
|
||||
import { events, context, features } from "@budibase/backend-core"
|
||||
import { context, events, features } from "@budibase/backend-core"
|
||||
import {
|
||||
AutoFieldSubType,
|
||||
Database,
|
||||
Datasource,
|
||||
FeatureFlag,
|
||||
FieldSchema,
|
||||
FieldType,
|
||||
NumberFieldMetadata,
|
||||
RelationshipFieldMetadata,
|
||||
RenameColumn,
|
||||
Row,
|
||||
SourceName,
|
||||
Table,
|
||||
Database,
|
||||
RenameColumn,
|
||||
NumberFieldMetadata,
|
||||
FieldSchema,
|
||||
View,
|
||||
RelationshipFieldMetadata,
|
||||
FieldType,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../../sdk"
|
||||
import env from "../../../environment"
|
||||
|
@ -329,7 +330,7 @@ class TableSaveFunctions {
|
|||
importRows: this.importRows,
|
||||
userId: this.userId,
|
||||
})
|
||||
if (await features.flags.isEnabled("SQS")) {
|
||||
if (await features.flags.isEnabled(FeatureFlag.SQS)) {
|
||||
await sdk.tables.sqs.addTable(table)
|
||||
}
|
||||
return table
|
||||
|
@ -523,7 +524,7 @@ export async function internalTableCleanup(table: Table, rows?: Row[]) {
|
|||
if (rows) {
|
||||
await AttachmentCleanup.tableDelete(table, rows)
|
||||
}
|
||||
if (await features.flags.isEnabled("SQS")) {
|
||||
if (await features.flags.isEnabled(FeatureFlag.SQS)) {
|
||||
await sdk.tables.sqs.removeTable(table)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { roles } from "@budibase/backend-core"
|
||||
import { Document, PermissionLevel, Row } from "@budibase/types"
|
||||
import { Document, PermissionLevel, Role, Row, Table } from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||
|
||||
|
@ -288,6 +288,88 @@ describe("/permission", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("multi-inheritance permissions", () => {
|
||||
let table1: Table, table2: Table, role1: Role, role2: Role
|
||||
beforeEach(async () => {
|
||||
// create new app
|
||||
await config.init()
|
||||
table1 = await config.createTable()
|
||||
table2 = await config.createTable()
|
||||
await config.api.row.save(table1._id!, {
|
||||
name: "a",
|
||||
})
|
||||
await config.api.row.save(table2._id!, {
|
||||
name: "b",
|
||||
})
|
||||
role1 = await config.api.roles.save(
|
||||
{
|
||||
name: "test_1",
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
inherits: BUILTIN_ROLE_IDS.BASIC,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
role2 = await config.api.roles.save(
|
||||
{
|
||||
name: "test_2",
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
inherits: BUILTIN_ROLE_IDS.BASIC,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
await config.api.permission.add({
|
||||
roleId: role1._id!,
|
||||
level: PermissionLevel.READ,
|
||||
resourceId: table1._id!,
|
||||
})
|
||||
await config.api.permission.add({
|
||||
roleId: role2._id!,
|
||||
level: PermissionLevel.READ,
|
||||
resourceId: table2._id!,
|
||||
})
|
||||
})
|
||||
|
||||
it("should be unable to search for table 2 using role 1", async () => {
|
||||
await config.loginAsRole(role1._id!, async () => {
|
||||
const response2 = await config.api.row.search(
|
||||
table2._id!,
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
expect(response2.rows).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to fetch two tables, with different roles, using multi-inheritance", async () => {
|
||||
const role3 = await config.api.roles.save({
|
||||
name: "role3",
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
inherits: [role1._id!, role2._id!],
|
||||
})
|
||||
|
||||
await config.loginAsRole(role3._id!, async () => {
|
||||
const response1 = await config.api.row.search(
|
||||
table1._id!,
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
const response2 = await config.api.row.search(
|
||||
table2._id!,
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
expect(response1.rows[0].name).toEqual("a")
|
||||
expect(response2.rows[0].name).toEqual("b")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch builtins", () => {
|
||||
it("should be able to fetch builtin definitions", async () => {
|
||||
const res = await request
|
||||
|
|
|
@ -29,6 +29,7 @@ describe.each(
|
|||
const isOracle = dbName === DatabaseName.ORACLE
|
||||
const isMsSQL = dbName === DatabaseName.SQL_SERVER
|
||||
const isPostgres = dbName === DatabaseName.POSTGRES
|
||||
const mainTableName = "test_table"
|
||||
|
||||
let rawDatasource: Datasource
|
||||
let datasource: Datasource
|
||||
|
@ -71,15 +72,15 @@ describe.each(
|
|||
|
||||
client = await knexClient(rawDatasource)
|
||||
|
||||
await client.schema.dropTableIfExists("test_table")
|
||||
await client.schema.createTable("test_table", table => {
|
||||
await client.schema.dropTableIfExists(mainTableName)
|
||||
await client.schema.createTable(mainTableName, table => {
|
||||
table.increments("id").primary()
|
||||
table.string("name")
|
||||
table.timestamp("birthday")
|
||||
table.integer("number")
|
||||
})
|
||||
|
||||
await client("test_table").insert([
|
||||
await client(mainTableName).insert([
|
||||
{ name: "one" },
|
||||
{ name: "two" },
|
||||
{ name: "three" },
|
||||
|
@ -105,7 +106,7 @@ describe.each(
|
|||
const query = await createQuery({
|
||||
name: "New Query",
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -114,7 +115,7 @@ describe.each(
|
|||
name: "New Query",
|
||||
parameters: [],
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
schema: {},
|
||||
queryVerb: "read",
|
||||
|
@ -133,7 +134,7 @@ describe.each(
|
|||
it("should be able to update a query", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -143,7 +144,7 @@ describe.each(
|
|||
...query,
|
||||
name: "Updated Query",
|
||||
fields: {
|
||||
sql: client("test_table").where({ id: 1 }).toString(),
|
||||
sql: client(mainTableName).where({ id: 1 }).toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -152,7 +153,7 @@ describe.each(
|
|||
name: "Updated Query",
|
||||
parameters: [],
|
||||
fields: {
|
||||
sql: client("test_table").where({ id: 1 }).toString(),
|
||||
sql: client(mainTableName).where({ id: 1 }).toString(),
|
||||
},
|
||||
schema: {},
|
||||
queryVerb: "read",
|
||||
|
@ -169,7 +170,7 @@ describe.each(
|
|||
it("should be able to delete a query", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -188,7 +189,7 @@ describe.each(
|
|||
it("should be able to list queries", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -199,7 +200,7 @@ describe.each(
|
|||
it("should strip sensitive fields for prod apps", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("*").toString(),
|
||||
sql: client(mainTableName).select("*").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -217,7 +218,7 @@ describe.each(
|
|||
const jsonStatement = `COALESCE(json_build_object('name', name),'{"name":{}}'::json)`
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.select([
|
||||
"*",
|
||||
client.raw(
|
||||
|
@ -245,7 +246,7 @@ describe.each(
|
|||
datasourceId: datasource._id!,
|
||||
queryVerb: "read",
|
||||
fields: {
|
||||
sql: client("test_table").where({ id: 1 }).toString(),
|
||||
sql: client(mainTableName).where({ id: 1 }).toString(),
|
||||
},
|
||||
parameters: [],
|
||||
transformer: "return data",
|
||||
|
@ -391,7 +392,7 @@ describe.each(
|
|||
it("should work with dynamic variables", async () => {
|
||||
const basedOnQuery = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("name").where({ id: 1 }).toString(),
|
||||
sql: client(mainTableName).select("name").where({ id: 1 }).toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -440,7 +441,7 @@ describe.each(
|
|||
it("should handle the dynamic base query being deleted", async () => {
|
||||
const basedOnQuery = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("name").where({ id: 1 }).toString(),
|
||||
sql: client(mainTableName).select("name").where({ id: 1 }).toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -494,7 +495,7 @@ describe.each(
|
|||
it("should be able to insert with bindings", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").insert({ name: "{{ foo }}" }).toString(),
|
||||
sql: client(mainTableName).insert({ name: "{{ foo }}" }).toString(),
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
|
@ -517,7 +518,7 @@ describe.each(
|
|||
},
|
||||
])
|
||||
|
||||
const rows = await client("test_table").where({ name: "baz" }).select()
|
||||
const rows = await client(mainTableName).where({ name: "baz" }).select()
|
||||
expect(rows).toHaveLength(1)
|
||||
for (const row of rows) {
|
||||
expect(row).toMatchObject({ name: "baz" })
|
||||
|
@ -527,7 +528,7 @@ describe.each(
|
|||
it("should not allow handlebars as parameters", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").insert({ name: "{{ foo }}" }).toString(),
|
||||
sql: client(mainTableName).insert({ name: "{{ foo }}" }).toString(),
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
|
@ -563,7 +564,7 @@ describe.each(
|
|||
const date = new Date(datetimeStr)
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.insert({
|
||||
name: "foo",
|
||||
birthday: client.raw("{{ birthday }}"),
|
||||
|
@ -585,7 +586,7 @@ describe.each(
|
|||
|
||||
expect(result.data).toEqual([{ created: true }])
|
||||
|
||||
const rows = await client("test_table")
|
||||
const rows = await client(mainTableName)
|
||||
.where({ birthday: datetimeStr })
|
||||
.select()
|
||||
expect(rows).toHaveLength(1)
|
||||
|
@ -601,7 +602,7 @@ describe.each(
|
|||
async notDateStr => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.insert({ name: client.raw("{{ name }}") })
|
||||
.toString(),
|
||||
},
|
||||
|
@ -622,7 +623,7 @@ describe.each(
|
|||
|
||||
expect(result.data).toEqual([{ created: true }])
|
||||
|
||||
const rows = await client("test_table")
|
||||
const rows = await client(mainTableName)
|
||||
.where({ name: notDateStr })
|
||||
.select()
|
||||
expect(rows).toHaveLength(1)
|
||||
|
@ -634,7 +635,7 @@ describe.each(
|
|||
it("should execute a query", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").select("*").orderBy("id").toString(),
|
||||
sql: client(mainTableName).select("*").orderBy("id").toString(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -677,7 +678,7 @@ describe.each(
|
|||
it("should be able to transform a query", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").where({ id: 1 }).select("*").toString(),
|
||||
sql: client(mainTableName).where({ id: 1 }).select("*").toString(),
|
||||
},
|
||||
transformer: `
|
||||
data[0].id = data[0].id + 1;
|
||||
|
@ -700,7 +701,7 @@ describe.each(
|
|||
it("should coerce numeric bindings", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.where({ id: client.raw("{{ id }}") })
|
||||
.select("*")
|
||||
.toString(),
|
||||
|
@ -734,7 +735,7 @@ describe.each(
|
|||
it("should be able to update rows", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.update({ name: client.raw("{{ name }}") })
|
||||
.where({ id: client.raw("{{ id }}") })
|
||||
.toString(),
|
||||
|
@ -759,7 +760,7 @@ describe.each(
|
|||
},
|
||||
})
|
||||
|
||||
const rows = await client("test_table").where({ id: 1 }).select()
|
||||
const rows = await client(mainTableName).where({ id: 1 }).select()
|
||||
expect(rows).toEqual([
|
||||
{ id: 1, name: "foo", birthday: null, number: null },
|
||||
])
|
||||
|
@ -768,7 +769,7 @@ describe.each(
|
|||
it("should be able to execute an update that updates no rows", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.update({ name: "updated" })
|
||||
.where({ id: 100 })
|
||||
.toString(),
|
||||
|
@ -778,7 +779,7 @@ describe.each(
|
|||
|
||||
await config.api.query.execute(query._id!)
|
||||
|
||||
const rows = await client("test_table").select()
|
||||
const rows = await client(mainTableName).select()
|
||||
for (const row of rows) {
|
||||
expect(row.name).not.toEqual("updated")
|
||||
}
|
||||
|
@ -787,14 +788,14 @@ describe.each(
|
|||
it("should be able to execute a delete that deletes no rows", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table").where({ id: 100 }).delete().toString(),
|
||||
sql: client(mainTableName).where({ id: 100 }).delete().toString(),
|
||||
},
|
||||
queryVerb: "delete",
|
||||
})
|
||||
|
||||
await config.api.query.execute(query._id!)
|
||||
|
||||
const rows = await client("test_table").select()
|
||||
const rows = await client(mainTableName).select()
|
||||
expect(rows).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
@ -803,7 +804,7 @@ describe.each(
|
|||
it("should be able to delete rows", async () => {
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.where({ id: client.raw("{{ id }}") })
|
||||
.delete()
|
||||
.toString(),
|
||||
|
@ -823,7 +824,7 @@ describe.each(
|
|||
},
|
||||
})
|
||||
|
||||
const rows = await client("test_table").where({ id: 1 }).select()
|
||||
const rows = await client(mainTableName).where({ id: 1 }).select()
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
@ -831,7 +832,7 @@ describe.each(
|
|||
|
||||
describe("query through datasource", () => {
|
||||
it("should be able to query the datasource", async () => {
|
||||
const entityId = "test_table"
|
||||
const entityId = mainTableName
|
||||
await config.api.datasource.update({
|
||||
...datasource,
|
||||
entities: {
|
||||
|
@ -876,7 +877,7 @@ describe.each(
|
|||
beforeAll(async () => {
|
||||
queryParams = {
|
||||
fields: {
|
||||
sql: client("test_table")
|
||||
sql: client(mainTableName)
|
||||
.insert({
|
||||
name: client.raw("{{ bindingName }}"),
|
||||
number: client.raw("{{ bindingNumber }}"),
|
||||
|
@ -929,4 +930,34 @@ describe.each(
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should find rows with a binding containing a slash", async () => {
|
||||
const slashValue = "1/10"
|
||||
await client(mainTableName).insert([{ name: slashValue }])
|
||||
|
||||
const query = await createQuery({
|
||||
fields: {
|
||||
sql: client(mainTableName)
|
||||
.select("*")
|
||||
.where("name", "=", client.raw("{{ bindingName }}"))
|
||||
.toString(),
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
name: "bindingName",
|
||||
default: "",
|
||||
},
|
||||
],
|
||||
queryVerb: "read",
|
||||
})
|
||||
const results = await config.api.query.execute(query._id!, {
|
||||
parameters: {
|
||||
bindingName: slashValue,
|
||||
},
|
||||
})
|
||||
expect(results).toBeDefined()
|
||||
expect(results.data.length).toEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,182 +0,0 @@
|
|||
const { roles, events, permissions } = require("@budibase/backend-core")
|
||||
const setup = require("./utilities")
|
||||
const { PermissionLevel } = require("@budibase/types")
|
||||
const { basicRole } = setup.structures
|
||||
const { BUILTIN_ROLE_IDS } = roles
|
||||
const { BuiltinPermissionID } = permissions
|
||||
|
||||
describe("/roles", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
const createRole = async role => {
|
||||
if (!role) {
|
||||
role = basicRole()
|
||||
}
|
||||
|
||||
return request
|
||||
.post(`/api/roles`)
|
||||
.send(role)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a success message when role is successfully created", async () => {
|
||||
const role = basicRole()
|
||||
const res = await createRole(role)
|
||||
|
||||
expect(res.body._id).toBeDefined()
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(events.role.updated).not.toBeCalled()
|
||||
expect(events.role.created).toBeCalledTimes(1)
|
||||
expect(events.role.created).toBeCalledWith(res.body)
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
it("updates a role", async () => {
|
||||
const role = basicRole()
|
||||
let res = await createRole(role)
|
||||
jest.clearAllMocks()
|
||||
res = await createRole(res.body)
|
||||
|
||||
expect(res.body._id).toBeDefined()
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(events.role.created).not.toBeCalled()
|
||||
expect(events.role.updated).toBeCalledTimes(1)
|
||||
expect(events.role.updated).toBeCalledWith(res.body)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
beforeAll(async () => {
|
||||
// Recreate the app
|
||||
await config.init()
|
||||
})
|
||||
|
||||
it("should list custom roles, plus 2 default roles", async () => {
|
||||
const customRole = await config.createRole()
|
||||
|
||||
const res = await request
|
||||
.get(`/api/roles`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.length).toBe(5)
|
||||
|
||||
const adminRole = res.body.find(r => r._id === BUILTIN_ROLE_IDS.ADMIN)
|
||||
expect(adminRole).toBeDefined()
|
||||
expect(adminRole.inherits).toEqual(BUILTIN_ROLE_IDS.POWER)
|
||||
expect(adminRole.permissionId).toEqual(BuiltinPermissionID.ADMIN)
|
||||
|
||||
const powerUserRole = res.body.find(r => r._id === BUILTIN_ROLE_IDS.POWER)
|
||||
expect(powerUserRole).toBeDefined()
|
||||
expect(powerUserRole.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
|
||||
expect(powerUserRole.permissionId).toEqual(BuiltinPermissionID.POWER)
|
||||
|
||||
const customRoleFetched = res.body.find(r => r._id === customRole.name)
|
||||
expect(customRoleFetched).toBeDefined()
|
||||
expect(customRoleFetched.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
|
||||
expect(customRoleFetched.permissionId).toEqual(
|
||||
BuiltinPermissionID.READ_ONLY
|
||||
)
|
||||
})
|
||||
|
||||
it("should be able to get the role with a permission added", async () => {
|
||||
const table = await config.createTable()
|
||||
await config.api.permission.add({
|
||||
roleId: BUILTIN_ROLE_IDS.POWER,
|
||||
resourceId: table._id,
|
||||
level: PermissionLevel.READ,
|
||||
})
|
||||
const res = await request
|
||||
.get(`/api/roles`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.length).toBeGreaterThan(0)
|
||||
const power = res.body.find(role => role._id === BUILTIN_ROLE_IDS.POWER)
|
||||
expect(power.permissions[table._id]).toEqual(["read"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should delete custom roles", async () => {
|
||||
const customRole = await config.createRole({
|
||||
name: "user",
|
||||
permissionId: BuiltinPermissionID.READ_ONLY,
|
||||
inherits: BUILTIN_ROLE_IDS.BASIC,
|
||||
})
|
||||
delete customRole._rev_tree
|
||||
await request
|
||||
.delete(`/api/roles/${customRole._id}/${customRole._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect(200)
|
||||
await request
|
||||
.get(`/api/roles/${customRole._id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect(404)
|
||||
expect(events.role.deleted).toBeCalledTimes(1)
|
||||
expect(events.role.deleted).toBeCalledWith(customRole)
|
||||
})
|
||||
})
|
||||
|
||||
describe("accessible", () => {
|
||||
it("should be able to fetch accessible roles (with builder)", async () => {
|
||||
const res = await request
|
||||
.get("/api/roles/accessible")
|
||||
.set(config.defaultHeaders())
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(5)
|
||||
expect(typeof res.body[0]).toBe("string")
|
||||
})
|
||||
|
||||
it("should be able to fetch accessible roles (basic user)", async () => {
|
||||
const res = await request
|
||||
.get("/api/roles/accessible")
|
||||
.set(await config.basicRoleHeaders())
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(2)
|
||||
expect(res.body[0]).toBe("BASIC")
|
||||
expect(res.body[1]).toBe("PUBLIC")
|
||||
})
|
||||
|
||||
it("should be able to fetch accessible roles (no user)", async () => {
|
||||
const res = await request
|
||||
.get("/api/roles/accessible")
|
||||
.set(config.publicHeaders())
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(1)
|
||||
expect(res.body[0]).toBe("PUBLIC")
|
||||
})
|
||||
|
||||
it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
|
||||
await createRole({
|
||||
name: `custom_role_1`,
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
|
||||
version: "name",
|
||||
})
|
||||
const res = await request
|
||||
.get("/api/roles/accessible")
|
||||
.set({
|
||||
...config.defaultHeaders(),
|
||||
"x-budibase-role": "custom_role_1",
|
||||
})
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(3)
|
||||
expect(res.body[0]).toBe("custom_role_1")
|
||||
expect(res.body[1]).toBe("BASIC")
|
||||
expect(res.body[2]).toBe("PUBLIC")
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,337 @@
|
|||
import {
|
||||
roles,
|
||||
events,
|
||||
permissions,
|
||||
db as dbCore,
|
||||
} from "@budibase/backend-core"
|
||||
import * as setup from "./utilities"
|
||||
import { PermissionLevel } from "@budibase/types"
|
||||
|
||||
const { basicRole } = setup.structures
|
||||
const { BUILTIN_ROLE_IDS } = roles
|
||||
const { BuiltinPermissionID } = permissions
|
||||
|
||||
const LOOP_ERROR = "Role inheritance contains a loop, this is not supported"
|
||||
|
||||
describe("/roles", () => {
|
||||
let config = setup.getConfig()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a success message when role is successfully created", async () => {
|
||||
const role = basicRole()
|
||||
const res = await config.api.roles.save(role, {
|
||||
status: 200,
|
||||
})
|
||||
|
||||
expect(res._id).toBeDefined()
|
||||
expect(res._rev).toBeDefined()
|
||||
expect(events.role.updated).not.toHaveBeenCalled()
|
||||
expect(events.role.created).toHaveBeenCalledTimes(1)
|
||||
expect(events.role.created).toHaveBeenCalledWith({
|
||||
...res,
|
||||
_id: dbCore.prefixRoleID(res._id!),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
beforeEach(async () => {
|
||||
// Recreate the app
|
||||
await config.init()
|
||||
})
|
||||
|
||||
it("updates a role", async () => {
|
||||
const role = basicRole()
|
||||
let res = await config.api.roles.save(role, {
|
||||
status: 200,
|
||||
})
|
||||
jest.clearAllMocks()
|
||||
res = await config.api.roles.save(res, {
|
||||
status: 200,
|
||||
})
|
||||
|
||||
expect(res._id).toBeDefined()
|
||||
expect(res._rev).toBeDefined()
|
||||
expect(events.role.created).not.toHaveBeenCalled()
|
||||
expect(events.role.updated).toHaveBeenCalledTimes(1)
|
||||
expect(events.role.updated).toHaveBeenCalledWith({
|
||||
...res,
|
||||
_id: dbCore.prefixRoleID(res._id!),
|
||||
})
|
||||
})
|
||||
|
||||
it("disallow loops", async () => {
|
||||
const role1 = await config.api.roles.save(basicRole(), {
|
||||
status: 200,
|
||||
})
|
||||
const role2 = await config.api.roles.save(
|
||||
{
|
||||
...basicRole(),
|
||||
inherits: [role1._id!],
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
await config.api.roles.save(
|
||||
{
|
||||
...role1,
|
||||
inherits: [role2._id!],
|
||||
},
|
||||
{ status: 400, body: { message: LOOP_ERROR } }
|
||||
)
|
||||
})
|
||||
|
||||
it("disallow more complex loops", async () => {
|
||||
let role1 = await config.api.roles.save({
|
||||
...basicRole(),
|
||||
name: "role1",
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER],
|
||||
})
|
||||
let role2 = await config.api.roles.save({
|
||||
...basicRole(),
|
||||
name: "role2",
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!],
|
||||
})
|
||||
let role3 = await config.api.roles.save({
|
||||
...basicRole(),
|
||||
name: "role3",
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!, role2._id!],
|
||||
})
|
||||
// go back to role1
|
||||
await config.api.roles.save(
|
||||
{
|
||||
...role1,
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER, role2._id!, role3._id!],
|
||||
},
|
||||
{ status: 400, body: { message: LOOP_ERROR } }
|
||||
)
|
||||
// go back to role2
|
||||
await config.api.roles.save(
|
||||
{
|
||||
...role2,
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!, role3._id!],
|
||||
},
|
||||
{ status: 400, body: { message: LOOP_ERROR } }
|
||||
)
|
||||
})
|
||||
|
||||
it("frontend example - should deny", async () => {
|
||||
const id1 = "cb27c4ec9415042f4800411adb346fb7c",
|
||||
id2 = "cbc72a9d61ab64d49b31d90d1df4c1fdb"
|
||||
const role1 = await config.api.roles.save({
|
||||
_id: id1,
|
||||
name: id1,
|
||||
permissions: {},
|
||||
permissionId: "write",
|
||||
version: "name",
|
||||
inherits: ["POWER"],
|
||||
})
|
||||
await config.api.roles.save({
|
||||
_id: id2,
|
||||
permissions: {},
|
||||
name: id2,
|
||||
permissionId: "write",
|
||||
version: "name",
|
||||
inherits: [id1],
|
||||
})
|
||||
await config.api.roles.save(
|
||||
{
|
||||
...role1,
|
||||
inherits: [BUILTIN_ROLE_IDS.POWER, id2],
|
||||
},
|
||||
{ status: 400, body: { message: LOOP_ERROR } }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
beforeAll(async () => {
|
||||
// Recreate the app
|
||||
await config.init()
|
||||
})
|
||||
|
||||
it("should list custom roles, plus 2 default roles", async () => {
|
||||
const customRole = await config.createRole()
|
||||
|
||||
const res = await config.api.roles.fetch({
|
||||
status: 200,
|
||||
})
|
||||
|
||||
expect(res.length).toBe(5)
|
||||
|
||||
const adminRole = res.find(r => r._id === BUILTIN_ROLE_IDS.ADMIN)
|
||||
expect(adminRole).toBeDefined()
|
||||
expect(adminRole!.inherits).toEqual(BUILTIN_ROLE_IDS.POWER)
|
||||
expect(adminRole!.permissionId).toEqual(BuiltinPermissionID.ADMIN)
|
||||
|
||||
const powerUserRole = res.find(r => r._id === BUILTIN_ROLE_IDS.POWER)
|
||||
expect(powerUserRole).toBeDefined()
|
||||
expect(powerUserRole!.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
|
||||
expect(powerUserRole!.permissionId).toEqual(BuiltinPermissionID.POWER)
|
||||
|
||||
const customRoleFetched = res.find(r => r._id === customRole.name)
|
||||
expect(customRoleFetched).toBeDefined()
|
||||
expect(customRoleFetched!.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
|
||||
expect(customRoleFetched!.permissionId).toEqual(
|
||||
BuiltinPermissionID.READ_ONLY
|
||||
)
|
||||
})
|
||||
|
||||
it("should be able to get the role with a permission added", async () => {
|
||||
const table = await config.createTable()
|
||||
await config.api.permission.add({
|
||||
roleId: BUILTIN_ROLE_IDS.POWER,
|
||||
resourceId: table._id!,
|
||||
level: PermissionLevel.READ,
|
||||
})
|
||||
const res = await config.api.roles.fetch()
|
||||
expect(res.length).toBeGreaterThan(0)
|
||||
const power = res.find(role => role._id === BUILTIN_ROLE_IDS.POWER)
|
||||
expect(power?.permissions[table._id!]).toEqual(["read"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should delete custom roles", async () => {
|
||||
const customRole = await config.createRole({
|
||||
name: "user",
|
||||
permissionId: BuiltinPermissionID.READ_ONLY,
|
||||
inherits: BUILTIN_ROLE_IDS.BASIC,
|
||||
})
|
||||
await config.api.roles.destroy(customRole, {
|
||||
status: 200,
|
||||
})
|
||||
await config.api.roles.find(customRole._id!, {
|
||||
status: 404,
|
||||
})
|
||||
expect(events.role.deleted).toHaveBeenCalledTimes(1)
|
||||
expect(events.role.deleted).toHaveBeenCalledWith({
|
||||
...customRole,
|
||||
_id: dbCore.prefixRoleID(customRole._id!),
|
||||
})
|
||||
})
|
||||
|
||||
it("should disconnection roles when deleted", async () => {
|
||||
const role1 = await config.api.roles.save({
|
||||
name: "role1",
|
||||
permissionId: BuiltinPermissionID.WRITE,
|
||||
inherits: [BUILTIN_ROLE_IDS.BASIC],
|
||||
})
|
||||
const role2 = await config.api.roles.save({
|
||||
name: "role2",
|
||||
permissionId: BuiltinPermissionID.WRITE,
|
||||
inherits: [BUILTIN_ROLE_IDS.BASIC, role1._id!],
|
||||
})
|
||||
const role3 = await config.api.roles.save({
|
||||
name: "role3",
|
||||
permissionId: BuiltinPermissionID.WRITE,
|
||||
inherits: [BUILTIN_ROLE_IDS.BASIC, role2._id!],
|
||||
})
|
||||
await config.api.roles.destroy(role2, { status: 200 })
|
||||
const found = await config.api.roles.find(role3._id!, { status: 200 })
|
||||
expect(found.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
|
||||
})
|
||||
})
|
||||
|
||||
describe("accessible", () => {
|
||||
beforeAll(async () => {
|
||||
// new app, reset roles
|
||||
await config.init()
|
||||
// create one custom role
|
||||
await config.createRole()
|
||||
})
|
||||
|
||||
it("should be able to fetch accessible roles (with builder)", async () => {
|
||||
await config.withHeaders(config.defaultHeaders(), async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res.length).toBe(5)
|
||||
expect(typeof res[0]).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to fetch accessible roles (basic user)", async () => {
|
||||
const headers = await config.basicRoleHeaders()
|
||||
await config.withHeaders(headers, async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res.length).toBe(2)
|
||||
expect(res[0]).toBe("BASIC")
|
||||
expect(res[1]).toBe("PUBLIC")
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to fetch accessible roles (no user)", async () => {
|
||||
await config.withHeaders(config.publicHeaders(), async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res.length).toBe(1)
|
||||
expect(res[0]).toBe("PUBLIC")
|
||||
})
|
||||
})
|
||||
|
||||
it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
|
||||
const customRoleName = "custom_role_1"
|
||||
await config.api.roles.save({
|
||||
name: customRoleName,
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
|
||||
version: "name",
|
||||
})
|
||||
await config.withHeaders(
|
||||
{ "x-budibase-role": customRoleName },
|
||||
async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res).toEqual([customRoleName, "BASIC", "PUBLIC"])
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("accessible - multi-inheritance", () => {
|
||||
it("should list access correctly for multi-inheritance role", async () => {
|
||||
const role1 = "multi_role_1",
|
||||
role2 = "multi_role_2",
|
||||
role3 = "multi_role_3"
|
||||
const { _id: roleId1 } = await config.api.roles.save({
|
||||
name: role1,
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: permissions.BuiltinPermissionID.WRITE,
|
||||
version: "name",
|
||||
})
|
||||
const { _id: roleId2 } = await config.api.roles.save({
|
||||
name: role2,
|
||||
inherits: roles.BUILTIN_ROLE_IDS.POWER,
|
||||
permissionId: permissions.BuiltinPermissionID.POWER,
|
||||
version: "name",
|
||||
})
|
||||
await config.api.roles.save({
|
||||
name: role3,
|
||||
inherits: [roleId1!, roleId2!],
|
||||
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
|
||||
version: "name",
|
||||
})
|
||||
const headers = await config.roleHeaders({
|
||||
roleId: role3,
|
||||
})
|
||||
await config.withHeaders(headers, async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res).toEqual([role3, role1, "BASIC", "PUBLIC", role2, "POWER"])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -2074,6 +2074,7 @@ describe.each([
|
|||
)
|
||||
tableId = table._id!
|
||||
|
||||
// @ts-ignore - until AI implemented
|
||||
const rowValues: Record<keyof typeof fullSchema, any> = {
|
||||
[FieldType.STRING]: generator.guid(),
|
||||
[FieldType.LONGFORM]: generator.paragraph(),
|
||||
|
|
|
@ -767,7 +767,6 @@ describe("/rowsActions", () => {
|
|||
it("can trigger an automation given valid data", async () => {
|
||||
expect(await getAutomationLogs()).toBeEmpty()
|
||||
await config.api.rowAction.trigger(viewId, rowAction.id, { rowId })
|
||||
|
||||
const automationLogs = await getAutomationLogs()
|
||||
expect(automationLogs).toEqual([
|
||||
expect.objectContaining({
|
||||
|
@ -783,6 +782,10 @@ describe("/rowsActions", () => {
|
|||
...(await config.api.table.get(tableId)),
|
||||
views: expect.anything(),
|
||||
},
|
||||
user: expect.objectContaining({
|
||||
_id: "ro_ta_users_" + config.getUser()._id,
|
||||
}),
|
||||
|
||||
automation: expect.objectContaining({
|
||||
_id: rowAction.automationId,
|
||||
}),
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
|
||||
const setup = require("./utilities")
|
||||
const { basicScreen } = setup.structures
|
||||
const { events } = require("@budibase/backend-core")
|
||||
|
||||
describe("/screens", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
let screen
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
screen = await config.createScreen()
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
it("should be able to create a layout", async () => {
|
||||
const res = await request
|
||||
.get(`/api/screens`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.length).toEqual(1)
|
||||
expect(res.body.some(s => s._id === screen._id)).toEqual(true)
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "GET",
|
||||
url: `/api/screens`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("save", () => {
|
||||
const saveScreen = async screen => {
|
||||
const res = await request
|
||||
.post(`/api/screens`)
|
||||
.send(screen)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return res
|
||||
}
|
||||
|
||||
it("should be able to create a screen", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const screen = basicScreen()
|
||||
const res = await saveScreen(screen)
|
||||
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(res.body.name).toEqual(screen.name)
|
||||
expect(events.screen.created).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should be able to update a screen", async () => {
|
||||
const screen = basicScreen()
|
||||
let res = await saveScreen(screen)
|
||||
screen._id = res.body._id
|
||||
screen._rev = res.body._rev
|
||||
screen.name = "edit"
|
||||
jest.clearAllMocks()
|
||||
|
||||
res = await saveScreen(screen)
|
||||
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(res.body.name).toEqual(screen.name)
|
||||
expect(events.screen.created).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "POST",
|
||||
url: `/api/screens`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should be able to delete the screen", async () => {
|
||||
const res = await request
|
||||
.delete(`/api/screens/${screen._id}/${screen._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.message).toBeDefined()
|
||||
expect(events.screen.deleted).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "DELETE",
|
||||
url: `/api/screens/${screen._id}/${screen._rev}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,171 @@
|
|||
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
|
||||
import * as setup from "./utilities"
|
||||
import { events, roles } from "@budibase/backend-core"
|
||||
import { Screen, PermissionLevel, Role } from "@budibase/types"
|
||||
|
||||
const { basicScreen } = setup.structures
|
||||
|
||||
describe("/screens", () => {
|
||||
let config = setup.getConfig()
|
||||
let screen: Screen
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
screen = await config.createScreen()
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
it("should be able to create a layout", async () => {
|
||||
const screens = await config.api.screen.list({ status: 200 })
|
||||
expect(screens.length).toEqual(1)
|
||||
expect(screens.some(s => s._id === screen._id)).toEqual(true)
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "GET",
|
||||
url: `/api/screens`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("permissions", () => {
|
||||
let screen1: Screen, screen2: Screen
|
||||
let role1: Role, role2: Role, multiRole: Role
|
||||
|
||||
beforeAll(async () => {
|
||||
role1 = await config.api.roles.save({
|
||||
name: "role1",
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
})
|
||||
role2 = await config.api.roles.save({
|
||||
name: "role2",
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
})
|
||||
multiRole = await config.api.roles.save({
|
||||
name: "multiRole",
|
||||
inherits: [role1._id!, role2._id!],
|
||||
permissionId: PermissionLevel.WRITE,
|
||||
})
|
||||
screen1 = await config.api.screen.save(
|
||||
{
|
||||
...basicScreen(),
|
||||
routing: {
|
||||
roleId: role1._id!,
|
||||
route: "/foo",
|
||||
homeScreen: false,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
screen2 = await config.api.screen.save(
|
||||
{
|
||||
...basicScreen(),
|
||||
routing: {
|
||||
roleId: role2._id!,
|
||||
route: "/bar",
|
||||
homeScreen: false,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
// get into prod app
|
||||
await config.publish()
|
||||
})
|
||||
|
||||
async function checkScreens(roleId: string, screenIds: string[]) {
|
||||
await config.loginAsRole(roleId, async () => {
|
||||
const res = await config.api.application.getDefinition(
|
||||
config.prodAppId!,
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
// basic and role1 screen
|
||||
expect(res.screens.length).toEqual(screenIds.length)
|
||||
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
|
||||
})
|
||||
}
|
||||
|
||||
it("should be able to fetch basic and screen1 with role1", async () => {
|
||||
await checkScreens(role1._id!, [screen._id!, screen1._id!])
|
||||
})
|
||||
|
||||
it("should be able to fetch basic and screen2 with role2", async () => {
|
||||
await checkScreens(role2._id!, [screen._id!, screen2._id!])
|
||||
})
|
||||
|
||||
it("should be able to fetch basic, screen1 and screen2 with multi-inheritance role", async () => {
|
||||
await checkScreens(multiRole._id!, [
|
||||
screen._id!,
|
||||
screen1._id!,
|
||||
screen2._id!,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("save", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should be able to create a screen", async () => {
|
||||
const screen = basicScreen()
|
||||
const responseScreen = await config.api.screen.save(screen, {
|
||||
status: 200,
|
||||
})
|
||||
|
||||
expect(responseScreen._rev).toBeDefined()
|
||||
expect(responseScreen.name).toEqual(screen.name)
|
||||
expect(events.screen.created).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should be able to update a screen", async () => {
|
||||
const screen = basicScreen()
|
||||
let responseScreen = await config.api.screen.save(screen, { status: 200 })
|
||||
screen._id = responseScreen._id
|
||||
screen._rev = responseScreen._rev
|
||||
screen.name = "edit"
|
||||
jest.clearAllMocks()
|
||||
|
||||
responseScreen = await config.api.screen.save(screen, { status: 200 })
|
||||
|
||||
expect(responseScreen._rev).toBeDefined()
|
||||
expect(responseScreen.name).toEqual(screen.name)
|
||||
expect(events.screen.created).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "POST",
|
||||
url: `/api/screens`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should be able to delete the screen", async () => {
|
||||
const response = await config.api.screen.destroy(
|
||||
screen._id!,
|
||||
screen._rev!,
|
||||
{ status: 200 }
|
||||
)
|
||||
expect(response.message).toBeDefined()
|
||||
expect(events.screen.deleted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await checkBuilderEndpoint({
|
||||
config,
|
||||
method: "DELETE",
|
||||
url: `/api/screens/${screen._id}/${screen._rev}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -2278,12 +2278,16 @@ describe.each([
|
|||
// It also can't work for in-memory searching because the related table name
|
||||
// isn't available.
|
||||
!isInMemory &&
|
||||
describe("relations", () => {
|
||||
describe.each([
|
||||
RelationshipType.ONE_TO_MANY,
|
||||
RelationshipType.MANY_TO_ONE,
|
||||
RelationshipType.MANY_TO_MANY,
|
||||
])("relations (%s)", relationshipType => {
|
||||
let productCategoryTable: Table, productCatRows: Row[]
|
||||
|
||||
beforeAll(async () => {
|
||||
const { relatedTable, tableId } = await basicRelationshipTables(
|
||||
RelationshipType.ONE_TO_MANY
|
||||
relationshipType
|
||||
)
|
||||
tableOrViewId = tableId
|
||||
productCategoryTable = relatedTable
|
||||
|
@ -2380,7 +2384,10 @@ describe.each([
|
|||
],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
|
||||
{
|
||||
name: "foo",
|
||||
productCat: [{ _id: productCatRows[0]._id }],
|
||||
},
|
||||
])
|
||||
}
|
||||
)
|
||||
|
@ -2458,7 +2465,7 @@ describe.each([
|
|||
}).toContainExactly([
|
||||
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
|
||||
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
|
||||
// { name: "baz", productCat: undefined }, // TODO
|
||||
{ name: "baz", productCat: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
|
@ -2480,7 +2487,10 @@ describe.each([
|
|||
],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
|
||||
{
|
||||
name: "foo",
|
||||
productCat: [{ _id: productCatRows[0]._id }],
|
||||
},
|
||||
])
|
||||
}
|
||||
)
|
||||
|
@ -2504,9 +2514,15 @@ describe.each([
|
|||
],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
|
||||
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
|
||||
// { name: "baz", productCat: undefined }, // TODO
|
||||
{
|
||||
name: "foo",
|
||||
productCat: [{ _id: productCatRows[0]._id }],
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
productCat: [{ _id: productCatRows[1]._id }],
|
||||
},
|
||||
{ name: "baz", productCat: undefined },
|
||||
])
|
||||
}
|
||||
)
|
||||
|
@ -2530,7 +2546,7 @@ describe.each([
|
|||
}).toContainExactly([
|
||||
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
|
||||
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
|
||||
// { name: "baz", productCat: undefined }, // TODO
|
||||
{ name: "baz", productCat: undefined },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
@ -2538,10 +2554,13 @@ describe.each([
|
|||
})
|
||||
|
||||
isSql &&
|
||||
describe("big relations", () => {
|
||||
describe.each([
|
||||
RelationshipType.MANY_TO_ONE,
|
||||
RelationshipType.MANY_TO_MANY,
|
||||
])("big relations (%s)", relationshipType => {
|
||||
beforeAll(async () => {
|
||||
const { relatedTable, tableId } = await basicRelationshipTables(
|
||||
RelationshipType.MANY_TO_ONE
|
||||
relationshipType
|
||||
)
|
||||
tableOrViewId = tableId
|
||||
const mainRow = await config.api.row.save(tableOrViewId, {
|
||||
|
@ -2567,7 +2586,8 @@ describe.each([
|
|||
expect(response.rows[0].productCat).toBeArrayOfSize(11)
|
||||
})
|
||||
})
|
||||
;(isSqs || isLucene) &&
|
||||
|
||||
isSql &&
|
||||
describe("relations to same table", () => {
|
||||
let relatedTable: string, relatedRows: Row[]
|
||||
|
||||
|
@ -2609,6 +2629,11 @@ describe.each([
|
|||
related1: [relatedRows[2]._id!],
|
||||
related2: [relatedRows[3]._id!],
|
||||
}),
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "test3",
|
||||
related1: [relatedRows[1]._id],
|
||||
related2: [relatedRows[2]._id!],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
|
@ -2626,42 +2651,59 @@ describe.each([
|
|||
related1: [{ _id: relatedRows[2]._id }],
|
||||
related2: [{ _id: relatedRows[3]._id }],
|
||||
},
|
||||
{
|
||||
name: "test3",
|
||||
related1: [{ _id: relatedRows[1]._id }],
|
||||
related2: [{ _id: relatedRows[2]._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
isSqs &&
|
||||
it("should be able to filter down to second row with equal", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
equal: {
|
||||
["related1.name"]: "baz",
|
||||
},
|
||||
it("should be able to filter via the first relation field with equal", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
equal: {
|
||||
["related1.name"]: "baz",
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "test2",
|
||||
related1: [{ _id: relatedRows[2]._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "test2",
|
||||
related1: [{ _id: relatedRows[2]._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
isSqs &&
|
||||
it("should be able to filter down to first row with not equal", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
notEqual: {
|
||||
["1:related2.name"]: "bar",
|
||||
["2:related2.name"]: "baz",
|
||||
["3:related2.name"]: "boo",
|
||||
},
|
||||
it("should be able to filter via the second relation field with not equal", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
notEqual: {
|
||||
["1:related2.name"]: "foo",
|
||||
["2:related2.name"]: "baz",
|
||||
["3:related2.name"]: "boo",
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "test",
|
||||
related1: [{ _id: relatedRows[0]._id }],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "test",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should be able to filter on both fields", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
notEqual: {
|
||||
["related1.name"]: "foo",
|
||||
["related2.name"]: "baz",
|
||||
},
|
||||
])
|
||||
})
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "test2",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
|
|
|
@ -206,7 +206,7 @@ describe.each([
|
|||
visible: false,
|
||||
icon: "ic",
|
||||
},
|
||||
} as Record<string, FieldSchema>,
|
||||
} as ViewV2Schema,
|
||||
}
|
||||
|
||||
const createdView = await config.api.viewV2.create(newView)
|
||||
|
@ -250,7 +250,7 @@ describe.each([
|
|||
name: "Category",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
} as Record<string, FieldSchema>,
|
||||
} as ViewV2Schema,
|
||||
}
|
||||
|
||||
await config.api.viewV2.create(newView, {
|
||||
|
@ -1044,7 +1044,7 @@ describe.each([
|
|||
visible: false,
|
||||
icon: "ic",
|
||||
},
|
||||
} as Record<string, FieldSchema>,
|
||||
} as ViewV2Schema,
|
||||
})
|
||||
|
||||
expect(updatedView).toEqual({
|
||||
|
@ -1078,7 +1078,7 @@ describe.each([
|
|||
name: "Category",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
} as Record<string, FieldSchema>,
|
||||
} as ViewV2Schema,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
|
|
|
@ -225,7 +225,10 @@ export function roleValidator() {
|
|||
)
|
||||
)
|
||||
.optional(),
|
||||
inherits: OPTIONAL_STRING,
|
||||
inherits: Joi.alternatives().try(
|
||||
OPTIONAL_STRING,
|
||||
Joi.array().items(OPTIONAL_STRING)
|
||||
),
|
||||
}).unknown(true)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
Hosting,
|
||||
ActionImplementation,
|
||||
AutomationStepDefinition,
|
||||
FeatureFlag,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../sdk"
|
||||
import { getAutomationPlugin } from "../utilities/fileSystem"
|
||||
|
@ -100,7 +101,7 @@ if (env.SELF_HOSTED) {
|
|||
}
|
||||
|
||||
export async function getActionDefinitions() {
|
||||
if (await features.flags.isEnabled("AUTOMATION_BRANCHING")) {
|
||||
if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
|
||||
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
|
||||
}
|
||||
const actionDefinitions = BUILTIN_ACTION_DEFINITIONS
|
||||
|
|
|
@ -482,4 +482,38 @@ describe("Automation Scenarios", () => {
|
|||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("Check user is passed through from row trigger", async () => {
|
||||
const table = await config.createTable()
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test a user is successfully passed from the trigger",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.rowUpdated(
|
||||
{ tableId: table._id! },
|
||||
{
|
||||
row: { name: "Test", description: "TEST" },
|
||||
id: "1234",
|
||||
}
|
||||
)
|
||||
.serverLog({ text: "{{ [user].[email] }}" })
|
||||
.run()
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain("example.com")
|
||||
})
|
||||
|
||||
it("Check user is passed through from app trigger", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test a user is successfully passed from the trigger",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
.serverLog({ text: "{{ [user].[email] }}" })
|
||||
.run()
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain("example.com")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
AutomationStoppedReason,
|
||||
AutomationStatus,
|
||||
AutomationRowEvent,
|
||||
UserBindings,
|
||||
} from "@budibase/types"
|
||||
import { executeInThread } from "../threads/automation"
|
||||
import { dataFilters, sdk } from "@budibase/shared-core"
|
||||
|
@ -140,7 +141,12 @@ function rowPassesFilters(row: Row, filters: SearchFilters) {
|
|||
|
||||
export async function externalTrigger(
|
||||
automation: Automation,
|
||||
params: { fields: Record<string, any>; timeout?: number; appId?: string },
|
||||
params: {
|
||||
fields: Record<string, any>
|
||||
timeout?: number
|
||||
appId?: string
|
||||
user?: UserBindings
|
||||
},
|
||||
{ getResponses }: { getResponses?: boolean } = {}
|
||||
): Promise<any> {
|
||||
if (automation.disabled) {
|
||||
|
|
|
@ -152,8 +152,6 @@ export enum AutomationErrors {
|
|||
FAILURE_CONDITION = "FAILURE_CONDITION_MET",
|
||||
}
|
||||
|
||||
export const devClientVersion = "0.0.0"
|
||||
|
||||
// pass through the list from the auth/core lib
|
||||
export const ObjectStoreBuckets = objectStore.ObjectStoreBuckets
|
||||
export const MAX_AUTOMATION_RECURRING_ERRORS = 5
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AutomationResults, LoopStepType } from "@budibase/types"
|
||||
import { AutomationResults, LoopStepType, UserBindings } from "@budibase/types"
|
||||
|
||||
export interface LoopInput {
|
||||
option: LoopStepType
|
||||
|
@ -18,6 +18,7 @@ export interface AutomationContext extends AutomationResults {
|
|||
stepsById: Record<string, any>
|
||||
stepsByName: Record<string, any>
|
||||
env?: Record<string, string>
|
||||
user?: UserBindings
|
||||
trigger: any
|
||||
settings?: {
|
||||
url?: string
|
||||
|
|
|
@ -31,7 +31,17 @@ class AutomationEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
async emitRow(eventName: string, appId: string, row: Row, table?: Table) {
|
||||
async emitRow({
|
||||
eventName,
|
||||
appId,
|
||||
row,
|
||||
table,
|
||||
}: {
|
||||
eventName: string
|
||||
appId: string
|
||||
row: Row
|
||||
table?: Table
|
||||
}) {
|
||||
let MAX_AUTOMATION_CHAIN = await this.getMaxAutomationChain()
|
||||
|
||||
// don't emit even if we've reached max automation chain
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { EventEmitter } from "events"
|
||||
import { rowEmission, tableEmission } from "./utils"
|
||||
import { Table, Row } from "@budibase/types"
|
||||
import { Table, Row, User } from "@budibase/types"
|
||||
|
||||
/**
|
||||
* keeping event emitter in one central location as it might be used for things other than
|
||||
|
@ -13,14 +13,22 @@ import { Table, Row } from "@budibase/types"
|
|||
* This is specifically quite important for template strings used in automations.
|
||||
*/
|
||||
class BudibaseEmitter extends EventEmitter {
|
||||
emitRow(
|
||||
eventName: string,
|
||||
appId: string,
|
||||
row: Row,
|
||||
table?: Table,
|
||||
emitRow({
|
||||
eventName,
|
||||
appId,
|
||||
row,
|
||||
table,
|
||||
oldRow,
|
||||
user,
|
||||
}: {
|
||||
eventName: string
|
||||
appId: string
|
||||
row: Row
|
||||
table?: Table
|
||||
oldRow?: Row
|
||||
) {
|
||||
rowEmission({ emitter: this, eventName, appId, row, table, oldRow })
|
||||
user: User
|
||||
}) {
|
||||
rowEmission({ emitter: this, eventName, appId, row, table, oldRow, user })
|
||||
}
|
||||
|
||||
emitTable(eventName: string, appId: string, table?: Table) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Table, Row } from "@budibase/types"
|
||||
import { Table, Row, User } from "@budibase/types"
|
||||
import BudibaseEmitter from "./BudibaseEmitter"
|
||||
|
||||
type BBEventOpts = {
|
||||
|
@ -9,6 +9,7 @@ type BBEventOpts = {
|
|||
row?: Row
|
||||
oldRow?: Row
|
||||
metadata?: any
|
||||
user?: User
|
||||
}
|
||||
|
||||
interface BBEventTable extends Table {
|
||||
|
@ -24,6 +25,7 @@ type BBEvent = {
|
|||
id?: string
|
||||
revision?: string
|
||||
metadata?: any
|
||||
user?: User
|
||||
}
|
||||
|
||||
export function rowEmission({
|
||||
|
@ -34,12 +36,14 @@ export function rowEmission({
|
|||
table,
|
||||
metadata,
|
||||
oldRow,
|
||||
user,
|
||||
}: BBEventOpts) {
|
||||
let event: BBEvent = {
|
||||
row,
|
||||
oldRow,
|
||||
appId,
|
||||
tableId: row?.tableId,
|
||||
user,
|
||||
}
|
||||
if (table) {
|
||||
event.table = table
|
||||
|
|
|
@ -56,6 +56,7 @@ interface AuthTokenResponse {
|
|||
const isTypeAllowed: Record<FieldType, boolean> = {
|
||||
[FieldType.STRING]: true,
|
||||
[FieldType.FORMULA]: true,
|
||||
[FieldType.AI]: true,
|
||||
[FieldType.NUMBER]: true,
|
||||
[FieldType.LONGFORM]: true,
|
||||
[FieldType.DATETIME]: true,
|
||||
|
@ -490,7 +491,8 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
|
|||
}
|
||||
if (
|
||||
!sheet.headerValues.includes(key) &&
|
||||
column.type !== FieldType.FORMULA
|
||||
column.type !== FieldType.FORMULA &&
|
||||
column.type !== FieldType.AI
|
||||
) {
|
||||
updatedHeaderValues.push(key)
|
||||
}
|
||||
|
|
|
@ -24,8 +24,7 @@ import {
|
|||
checkExternalTables,
|
||||
HOST_ADDRESS,
|
||||
} from "./utils"
|
||||
import dayjs from "dayjs"
|
||||
import { NUMBER_REGEX } from "../utilities"
|
||||
import { isDate, NUMBER_REGEX } from "../utilities"
|
||||
import { MySQLColumn } from "./base/types"
|
||||
import { getReadableErrorMessage } from "./base/errorMapping"
|
||||
import { sql } from "@budibase/backend-core"
|
||||
|
@ -129,11 +128,7 @@ export function bindingTypeCoerce(bindings: SqlQueryBinding) {
|
|||
}
|
||||
// if not a number, see if it is a date - important to do in this order as any
|
||||
// integer will be considered a valid date
|
||||
else if (
|
||||
/^\d/.test(binding) &&
|
||||
dayjs(binding).isValid() &&
|
||||
!binding.includes(",")
|
||||
) {
|
||||
else if (isDate(binding)) {
|
||||
let value: any
|
||||
value = new Date(binding)
|
||||
if (isNaN(value)) {
|
||||
|
@ -439,8 +434,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
|
|||
dumpContent.push(createTableStatement)
|
||||
}
|
||||
|
||||
const schema = dumpContent.join("\n")
|
||||
return schema
|
||||
return dumpContent.join("\n")
|
||||
} finally {
|
||||
this.disconnect()
|
||||
}
|
||||
|
|
|
@ -78,8 +78,7 @@ describe("Captures of real examples", () => {
|
|||
bindings: ["assembling", primaryLimit, relationshipLimit],
|
||||
sql: expect.stringContaining(
|
||||
multiline(
|
||||
`where exists (select 1 from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid"
|
||||
and (COALESCE("b"."taskname" = $1, FALSE))`
|
||||
`where (exists (select 1 from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid" and (COALESCE("b"."taskname" = $1, FALSE)))`
|
||||
)
|
||||
),
|
||||
})
|
||||
|
@ -133,6 +132,8 @@ describe("Captures of real examples", () => {
|
|||
|
||||
expect(query).toEqual({
|
||||
bindings: [
|
||||
rangeValue.low,
|
||||
rangeValue.high,
|
||||
rangeValue.low,
|
||||
rangeValue.high,
|
||||
equalValue,
|
||||
|
@ -144,7 +145,7 @@ describe("Captures of real examples", () => {
|
|||
],
|
||||
sql: expect.stringContaining(
|
||||
multiline(
|
||||
`where exists (select 1 from "persons" as "c" where "c"."personid" = "a"."executorid" and ("c"."year" between $1 and $2))`
|
||||
`where (exists (select 1 from "persons" as "c" where "c"."personid" = "a"."executorid" and ("c"."year" between $1 and $2))) and (exists (select 1 from "persons" as "c" where "c"."personid" = "a"."qaid" and ("c"."year" between $3 and $4))) and (exists (select 1 from "products" as "b" inner join "products_tasks" as "d" on "b"."productid" = "d"."productid" where "d"."taskid" = "a"."taskid" and (COALESCE("b"."productname" = $5, FALSE))))`
|
||||
)
|
||||
),
|
||||
})
|
||||
|
|
|
@ -242,6 +242,7 @@ function copyExistingPropsOver(
|
|||
let shouldKeepSchema = false
|
||||
switch (existingColumnType) {
|
||||
case FieldType.FORMULA:
|
||||
case FieldType.AI:
|
||||
case FieldType.AUTO:
|
||||
case FieldType.INTERNAL:
|
||||
shouldKeepSchema = true
|
||||
|
|
|
@ -59,11 +59,15 @@ export default async (ctx: UserCtx, next: any) => {
|
|||
// Ensure the role is valid by ensuring a definition exists
|
||||
try {
|
||||
if (roleHeader) {
|
||||
await roles.getRole(roleHeader)
|
||||
roleId = roleHeader
|
||||
const role = await roles.getRole(roleHeader)
|
||||
if (role) {
|
||||
roleId = roleHeader
|
||||
|
||||
// Delete admin and builder flags so that the specified role is honoured
|
||||
ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser
|
||||
// Delete admin and builder flags so that the specified role is honoured
|
||||
ctx.user = users.removePortalUserPermissions(
|
||||
ctx.user
|
||||
) as ContextUser
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Swallow error and do nothing
|
||||
|
|
|
@ -2,10 +2,12 @@ import * as sync from "./sync"
|
|||
import * as utils from "./utils"
|
||||
import * as applications from "./applications"
|
||||
import * as imports from "./import"
|
||||
import * as metadata from "./metadata"
|
||||
|
||||
export default {
|
||||
...sync,
|
||||
...utils,
|
||||
...applications,
|
||||
...imports,
|
||||
metadata,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { context, DocumentType } from "@budibase/backend-core"
|
||||
import { App } from "@budibase/types"
|
||||
|
||||
/**
|
||||
* @deprecated the plan is to get everything using `tryGet` instead, then rename
|
||||
* `tryGet` to `get`.
|
||||
*/
|
||||
export async function get() {
|
||||
const db = context.getAppDB()
|
||||
const application = await db.get<App>(DocumentType.APP_METADATA)
|
||||
return application
|
||||
}
|
||||
|
||||
export async function tryGet() {
|
||||
const db = context.getAppDB()
|
||||
const application = await db.tryGet<App>(DocumentType.APP_METADATA)
|
||||
return application
|
||||
}
|
|
@ -4,6 +4,7 @@ import {
|
|||
AutomationTriggerStepId,
|
||||
SEPARATOR,
|
||||
TableRowActions,
|
||||
User,
|
||||
VirtualDocumentType,
|
||||
} from "@budibase/types"
|
||||
import { generateRowActionsID } from "../../db/utils"
|
||||
|
@ -236,7 +237,12 @@ export async function remove(tableId: string, rowActionId: string) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function run(tableId: any, rowActionId: any, rowId: string) {
|
||||
export async function run(
|
||||
tableId: any,
|
||||
rowActionId: any,
|
||||
rowId: string,
|
||||
user: User
|
||||
) {
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
if (!table) {
|
||||
throw new HTTPError("Table not found", 404)
|
||||
|
@ -258,6 +264,7 @@ export async function run(tableId: any, rowActionId: any, rowId: string) {
|
|||
row,
|
||||
table,
|
||||
},
|
||||
user,
|
||||
appId: context.getAppId(),
|
||||
},
|
||||
{ getResponses: true }
|
||||
|
|
|
@ -3,14 +3,12 @@ import * as rows from "./rows"
|
|||
import * as search from "./search"
|
||||
import * as utils from "./utils"
|
||||
import * as external from "./external"
|
||||
import * as filters from "./search/filters"
|
||||
import AliasTables from "./sqlAlias"
|
||||
|
||||
export default {
|
||||
...attachments,
|
||||
...rows,
|
||||
...search,
|
||||
filters,
|
||||
utils,
|
||||
external,
|
||||
AliasTables,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
EmptyFilterOption,
|
||||
FeatureFlag,
|
||||
LegacyFilter,
|
||||
LogicalOperator,
|
||||
Row,
|
||||
|
@ -101,7 +102,7 @@ export async function search(
|
|||
viewQuery = checkFilters(table, viewQuery)
|
||||
delete viewQuery?.onEmptyFilter
|
||||
|
||||
const sqsEnabled = await features.flags.isEnabled("SQS")
|
||||
const sqsEnabled = await features.flags.isEnabled(FeatureFlag.SQS)
|
||||
const supportsLogicalOperators =
|
||||
isExternalTableID(view.tableId) || sqsEnabled
|
||||
|
||||
|
@ -168,7 +169,7 @@ export async function search(
|
|||
if (isExternalTable) {
|
||||
span?.addTags({ searchType: "external" })
|
||||
result = await external.search(options, source)
|
||||
} else if (await features.flags.isEnabled("SQS")) {
|
||||
} else if (await features.flags.isEnabled(FeatureFlag.SQS)) {
|
||||
span?.addTags({ searchType: "sqs" })
|
||||
result = await internal.sqs.search(options, source)
|
||||
} else {
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import {
|
||||
FieldType,
|
||||
RelationshipFieldMetadata,
|
||||
SearchFilters,
|
||||
Table,
|
||||
} from "@budibase/types"
|
||||
import { isPlainObject } from "lodash"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
|
||||
export function getRelationshipColumns(table: Table): {
|
||||
name: string
|
||||
definition: RelationshipFieldMetadata
|
||||
}[] {
|
||||
// performing this with a for loop rather than an array filter improves
|
||||
// type guarding, as no casts are required
|
||||
const linkEntries: [string, RelationshipFieldMetadata][] = []
|
||||
for (let entry of Object.entries(table.schema)) {
|
||||
if (entry[1].type === FieldType.LINK) {
|
||||
const linkColumn: RelationshipFieldMetadata = entry[1]
|
||||
linkEntries.push([entry[0], linkColumn])
|
||||
}
|
||||
}
|
||||
return linkEntries.map(entry => ({
|
||||
name: entry[0],
|
||||
definition: entry[1],
|
||||
}))
|
||||
}
|
||||
|
||||
export function getTableIDList(
|
||||
tables: Table[]
|
||||
): { name: string; id: string }[] {
|
||||
return tables
|
||||
.filter(table => table.originalName && table._id)
|
||||
.map(table => ({ id: table._id!, name: table.originalName! }))
|
||||
}
|
||||
|
||||
export function updateFilterKeys(
|
||||
filters: SearchFilters,
|
||||
updates: { original: string; updated: string }[]
|
||||
): SearchFilters {
|
||||
const makeFilterKeyRegex = (str: string) =>
|
||||
new RegExp(`^${str}\\.|:${str}\\.`)
|
||||
for (let filter of Object.values(filters)) {
|
||||
if (!isPlainObject(filter)) {
|
||||
continue
|
||||
}
|
||||
for (let [key, keyFilter] of Object.entries(filter)) {
|
||||
if (keyFilter === "") {
|
||||
delete filter[key]
|
||||
}
|
||||
const possibleKey = updates.find(({ original }) =>
|
||||
key.match(makeFilterKeyRegex(original))
|
||||
)
|
||||
if (possibleKey && possibleKey.original !== possibleKey.updated) {
|
||||
// only replace the first, not replaceAll
|
||||
filter[key.replace(possibleKey.original, possibleKey.updated)] =
|
||||
filter[key]
|
||||
delete filter[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataFilters.recurseLogicalOperators(filters, (f: SearchFilters) => {
|
||||
return updateFilterKeys(f, updates)
|
||||
})
|
||||
}
|
|
@ -39,11 +39,6 @@ import AliasTables from "../../sqlAlias"
|
|||
import { outputProcessing } from "../../../../../utilities/rowProcessor"
|
||||
import pick from "lodash/pick"
|
||||
import { processRowCountResponse } from "../../utils"
|
||||
import {
|
||||
getRelationshipColumns,
|
||||
getTableIDList,
|
||||
updateFilterKeys,
|
||||
} from "../filters"
|
||||
import {
|
||||
dataFilters,
|
||||
helpers,
|
||||
|
@ -133,31 +128,7 @@ async function buildInternalFieldList(
|
|||
return [...new Set(fieldList)]
|
||||
}
|
||||
|
||||
function cleanupFilters(
|
||||
filters: SearchFilters,
|
||||
table: Table,
|
||||
allTables: Table[]
|
||||
) {
|
||||
// get a list of all relationship columns in the table for updating
|
||||
const relationshipColumns = getRelationshipColumns(table)
|
||||
// get table names to ID map for relationships
|
||||
const tableNameToID = getTableIDList(allTables)
|
||||
// all should be applied at once
|
||||
filters = updateFilterKeys(
|
||||
filters,
|
||||
relationshipColumns
|
||||
.map(({ name, definition }) => ({
|
||||
original: name,
|
||||
updated: definition.tableId,
|
||||
}))
|
||||
.concat(
|
||||
tableNameToID.map(({ name, id }) => ({
|
||||
original: name,
|
||||
updated: id,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
function cleanupFilters(filters: SearchFilters, allTables: Table[]) {
|
||||
// generate a map of all possible column names (these can be duplicated across tables
|
||||
// the map of them will always be the same
|
||||
const userColumnMap: Record<string, string> = {}
|
||||
|
@ -356,7 +327,7 @@ export async function search(
|
|||
const relationships = buildInternalRelationships(table, allTables)
|
||||
|
||||
const searchFilters: SearchFilters = {
|
||||
...cleanupFilters(query, table, allTables),
|
||||
...cleanupFilters(query, allTables),
|
||||
documentType: DocumentType.ROW,
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
TableResponse,
|
||||
TableSourceType,
|
||||
TableViewsResponse,
|
||||
FeatureFlag,
|
||||
} from "@budibase/types"
|
||||
import datasources from "../datasources"
|
||||
import sdk from "../../../sdk"
|
||||
|
@ -39,7 +40,7 @@ export async function processTable(table: Table): Promise<Table> {
|
|||
sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
|
||||
sourceType: TableSourceType.INTERNAL,
|
||||
}
|
||||
const sqsEnabled = await features.flags.isEnabled("SQS")
|
||||
const sqsEnabled = await features.flags.isEnabled(FeatureFlag.SQS)
|
||||
if (sqsEnabled) {
|
||||
processed.sql = true
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ const FieldTypeMap: Record<FieldType, SQLiteType> = {
|
|||
[FieldType.BOOLEAN]: SQLiteType.NUMERIC,
|
||||
[FieldType.DATETIME]: SQLiteType.TEXT,
|
||||
[FieldType.FORMULA]: SQLiteType.TEXT,
|
||||
[FieldType.AI]: SQLiteType.TEXT,
|
||||
[FieldType.LONGFORM]: SQLiteType.TEXT,
|
||||
[FieldType.NUMBER]: SQLiteType.REAL,
|
||||
[FieldType.STRING]: SQLiteType.TEXT,
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
UserMetadata,
|
||||
Database,
|
||||
ContextUserMetadata,
|
||||
UserBindings,
|
||||
} from "@budibase/types"
|
||||
|
||||
export function combineMetadataAndUser(
|
||||
|
@ -125,7 +126,7 @@ export async function syncGlobalUsers() {
|
|||
}
|
||||
}
|
||||
|
||||
export function getUserContextBindings(user: ContextUser) {
|
||||
export function getUserContextBindings(user: ContextUser): UserBindings {
|
||||
if (!user) {
|
||||
return {}
|
||||
}
|
||||
|
|
|
@ -110,6 +110,7 @@ export default class TestConfiguration {
|
|||
tenantId?: string
|
||||
api: API
|
||||
csrfToken?: string
|
||||
temporaryHeaders?: Record<string, string | string[]>
|
||||
|
||||
constructor(openServer = true) {
|
||||
if (openServer) {
|
||||
|
@ -428,6 +429,38 @@ export default class TestConfiguration {
|
|||
|
||||
// HEADERS
|
||||
|
||||
// sets the role for the headers, for the period of a callback
|
||||
async loginAsRole(roleId: string, cb: () => Promise<unknown>) {
|
||||
const roleUser = await this.createUser({
|
||||
roles: {
|
||||
[this.getProdAppId()]: roleId,
|
||||
},
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
await this.login({
|
||||
roleId,
|
||||
userId: roleUser._id!,
|
||||
builder: false,
|
||||
prodApp: true,
|
||||
})
|
||||
await this.withUser(roleUser, async () => {
|
||||
await cb()
|
||||
})
|
||||
}
|
||||
|
||||
async withHeaders(
|
||||
headers: Record<string, string | string[]>,
|
||||
cb: () => Promise<unknown>
|
||||
) {
|
||||
this.temporaryHeaders = headers
|
||||
try {
|
||||
await cb()
|
||||
} finally {
|
||||
this.temporaryHeaders = undefined
|
||||
}
|
||||
}
|
||||
|
||||
defaultHeaders(extras = {}, prodApp = false) {
|
||||
const tenantId = this.getTenantId()
|
||||
const user = this.getUser()
|
||||
|
@ -451,7 +484,10 @@ export default class TestConfiguration {
|
|||
} else if (this.appId) {
|
||||
headers[constants.Header.APP_ID] = this.appId
|
||||
}
|
||||
return headers
|
||||
return {
|
||||
...headers,
|
||||
...this.temporaryHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
publicHeaders({ prodApp = true } = {}) {
|
||||
|
@ -459,6 +495,7 @@ export default class TestConfiguration {
|
|||
|
||||
const headers: any = {
|
||||
Accept: "application/json",
|
||||
Cookie: "",
|
||||
}
|
||||
if (appId) {
|
||||
headers[constants.Header.APP_ID] = appId
|
||||
|
@ -466,7 +503,10 @@ export default class TestConfiguration {
|
|||
|
||||
headers[constants.Header.TENANT_ID] = this.getTenantId()
|
||||
|
||||
return headers
|
||||
return {
|
||||
...headers,
|
||||
...this.temporaryHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
async basicRoleHeaders() {
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
FindRoleResponse,
|
||||
SaveRoleRequest,
|
||||
SaveRoleResponse,
|
||||
Role,
|
||||
} from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
|
||||
|
@ -27,13 +28,13 @@ export class RoleAPI extends TestAPI {
|
|||
})
|
||||
}
|
||||
|
||||
destroy = async (roleId: string, expectations?: Expectations) => {
|
||||
return await this._delete(`/api/roles/${roleId}`, {
|
||||
destroy = async (role: Role, expectations?: Expectations) => {
|
||||
return await this._delete(`/api/roles/${role._id}/${role._rev}`, {
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
|
||||
accesssible = async (expectations?: Expectations) => {
|
||||
accessible = async (expectations?: Expectations) => {
|
||||
return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, {
|
||||
expectations,
|
||||
})
|
||||
|
|
|
@ -5,4 +5,27 @@ export class ScreenAPI extends TestAPI {
|
|||
list = async (expectations?: Expectations): Promise<Screen[]> => {
|
||||
return await this._get<Screen[]>(`/api/screens`, { expectations })
|
||||
}
|
||||
|
||||
save = async (
|
||||
screen: Screen,
|
||||
expectations?: Expectations
|
||||
): Promise<Screen> => {
|
||||
return await this._post<Screen>(`/api/screens`, {
|
||||
expectations,
|
||||
body: screen,
|
||||
})
|
||||
}
|
||||
|
||||
destroy = async (
|
||||
screenId: string,
|
||||
screenRev: string,
|
||||
expectations?: Expectations
|
||||
): Promise<{ message: string }> => {
|
||||
return this._delete<{ message: string }>(
|
||||
`/api/screens/${screenId}/${screenRev}`,
|
||||
{
|
||||
expectations,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
BBReferenceFieldSubType,
|
||||
JsonFieldSubType,
|
||||
AutoFieldSubType,
|
||||
Role,
|
||||
CreateViewRequest,
|
||||
} from "@budibase/types"
|
||||
import { LoopInput } from "../../definitions/automations"
|
||||
|
@ -510,11 +511,12 @@ export function basicLinkedRow(
|
|||
}
|
||||
}
|
||||
|
||||
export function basicRole() {
|
||||
export function basicRole(): Role {
|
||||
return {
|
||||
name: `NewRole_${utils.newid()}`,
|
||||
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
|
||||
permissions: {},
|
||||
version: "name",
|
||||
}
|
||||
}
|
||||
|
@ -603,6 +605,7 @@ export function fullSchemaWithoutLinks({
|
|||
}): {
|
||||
[type in Exclude<FieldType, FieldType.LINK>]: FieldSchema & { type: type }
|
||||
} {
|
||||
// @ts-ignore - until AI implemented
|
||||
return {
|
||||
[FieldType.STRING]: {
|
||||
name: "string",
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
BranchStep,
|
||||
LoopStep,
|
||||
SearchFilters,
|
||||
UserBindings,
|
||||
} from "@budibase/types"
|
||||
import { AutomationContext, TriggerOutput } from "../definitions/automations"
|
||||
import { WorkerCallback } from "./definitions"
|
||||
|
@ -75,6 +76,7 @@ class Orchestrator {
|
|||
private loopStepOutputs: LoopStep[]
|
||||
private stopped: boolean
|
||||
private executionOutput: Omit<AutomationContext, "stepsByName" | "stepsById">
|
||||
private currentUser: UserBindings | undefined
|
||||
|
||||
constructor(job: AutomationJob) {
|
||||
let automation = job.data.automation
|
||||
|
@ -106,6 +108,7 @@ class Orchestrator {
|
|||
this.updateExecutionOutput(triggerId, triggerStepId, null, triggerOutput)
|
||||
this.loopStepOutputs = []
|
||||
this.stopped = false
|
||||
this.currentUser = triggerOutput.user
|
||||
}
|
||||
|
||||
cleanupTriggerOutputs(stepId: string, triggerOutput: TriggerOutput) {
|
||||
|
@ -258,6 +261,7 @@ class Orchestrator {
|
|||
automationId: this.automation._id,
|
||||
})
|
||||
this.context.env = await sdkUtils.getEnvironmentVariables()
|
||||
this.context.user = this.currentUser
|
||||
|
||||
const { config } = await configs.getSettingsConfigDoc()
|
||||
this.context.settings = {
|
||||
|
@ -579,7 +583,6 @@ class Orchestrator {
|
|||
originalStepInput,
|
||||
this.processContext(this.context)
|
||||
)
|
||||
|
||||
inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs)
|
||||
|
||||
const outputs = await stepFn({
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { budibaseTempDir } from "../budibaseDir"
|
||||
import fs from "fs"
|
||||
import { join } from "path"
|
||||
import { ObjectStoreBuckets, devClientVersion } from "../../constants"
|
||||
import { updateClientLibrary } from "./clientLibrary"
|
||||
import { ObjectStoreBuckets } from "../../constants"
|
||||
import { shouldServeLocally, updateClientLibrary } from "./clientLibrary"
|
||||
import env from "../../environment"
|
||||
import { objectStore, context } from "@budibase/backend-core"
|
||||
import { TOP_LEVEL_PATH } from "./filesystem"
|
||||
|
@ -40,7 +40,7 @@ export const getComponentLibraryManifest = async (library: string) => {
|
|||
const db = context.getAppDB()
|
||||
const app = await db.get<App>(DocumentType.APP_METADATA)
|
||||
|
||||
if (app.version === devClientVersion || env.isTest()) {
|
||||
if (shouldServeLocally(app.version) || env.isTest()) {
|
||||
const paths = [
|
||||
join(TOP_LEVEL_PATH, "packages/client", filename),
|
||||
join(process.cwd(), "client", filename),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import semver from "semver"
|
||||
import path, { join } from "path"
|
||||
import { ObjectStoreBuckets } from "../../constants"
|
||||
import fs from "fs"
|
||||
|
@ -183,3 +184,19 @@ export async function revertClientLibrary(appId: string) {
|
|||
|
||||
return JSON.parse(await manifestSrc)
|
||||
}
|
||||
|
||||
export function shouldServeLocally(version: string) {
|
||||
if (env.isProd() || !env.isDev()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (version === "0.0.0") {
|
||||
return true
|
||||
}
|
||||
|
||||
const parsedSemver = semver.parse(version)
|
||||
if (parsedSemver?.build?.[0] === "local") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -3,7 +3,10 @@ import { context } from "@budibase/backend-core"
|
|||
import { generateMetadataID } from "../db/utils"
|
||||
import { Document } from "@budibase/types"
|
||||
import stream from "stream"
|
||||
import dayjs from "dayjs"
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
const Readable = stream.Readable
|
||||
|
||||
export function wait(ms: number) {
|
||||
|
@ -13,6 +16,28 @@ export function wait(ms: number) {
|
|||
export const isDev = env.isDev
|
||||
|
||||
export const NUMBER_REGEX = /^[+-]?([0-9]*[.])?[0-9]+$/g
|
||||
const ACCEPTED_DATE_FORMATS = [
|
||||
"MM/DD/YYYY",
|
||||
"MM/DD/YY",
|
||||
"DD/MM/YYYY",
|
||||
"DD/MM/YY",
|
||||
"YYYY/MM/DD",
|
||||
"YYYY-MM-DD",
|
||||
"YYYY-MM-DDTHH:mm",
|
||||
"YYYY-MM-DDTHH:mm:ss",
|
||||
"YYYY-MM-DDTHH:mm:ss[Z]",
|
||||
"YYYY-MM-DDTHH:mm:ss.SSS[Z]",
|
||||
]
|
||||
|
||||
export function isDate(str: string) {
|
||||
// checks for xx/xx/xx or ISO date timestamp formats
|
||||
for (const format of ACCEPTED_DATE_FORMATS) {
|
||||
if (dayjs(str, format, true).isValid()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function removeFromArray(array: any[], element: any) {
|
||||
const index = array.indexOf(element)
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
Table,
|
||||
User,
|
||||
ViewV2,
|
||||
FeatureFlag,
|
||||
} from "@budibase/types"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import {
|
||||
|
@ -417,7 +418,7 @@ export async function coreOutputProcessing(
|
|||
|
||||
// remove null properties to match internal API
|
||||
const isExternal = isExternalTableID(table._id!)
|
||||
if (isExternal || (await features.flags.isEnabled("SQS"))) {
|
||||
if (isExternal || (await features.flags.isEnabled(FeatureFlag.SQS))) {
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (row[key] === null) {
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { isDate } from "../"
|
||||
|
||||
describe("isDate", () => {
|
||||
it("should handle DD/MM/YYYY", () => {
|
||||
expect(isDate("01/01/2001")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should handle DD/MM/YY", () => {
|
||||
expect(isDate("01/01/01")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should handle ISO format YYYY-MM-DD", () => {
|
||||
expect(isDate("2001-01-01")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should handle ISO format with time (YYYY-MM-DDTHH:MM)", () => {
|
||||
expect(isDate("2001-01-01T12:30")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should handle ISO format with full timestamp (YYYY-MM-DDTHH:MM:SS)", () => {
|
||||
expect(isDate("2001-01-01T12:30:45")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should handle complete ISO format", () => {
|
||||
expect(isDate("2001-01-01T12:30:00.000Z")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should return false for invalid formats", () => {
|
||||
expect(isDate("")).toEqual(false)
|
||||
expect(isDate("1/10")).toEqual(false)
|
||||
expect(isDate("random string")).toEqual(false)
|
||||
expect(isDate("123456")).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -3,3 +3,4 @@ export * from "./integrations"
|
|||
export * as cron from "./cron"
|
||||
export * as schema from "./schema"
|
||||
export * as views from "./views"
|
||||
export * as roles from "./roles"
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import { Role, DocumentType, SEPARATOR } from "@budibase/types"
|
||||
|
||||
// need to have a way to prefix, so we can check if the ID has its prefix or not
|
||||
// all new IDs should be the same in the future, but old roles they are never prefixed
|
||||
// while the role IDs always are - best to check both, also we can't access backend-core here
|
||||
function prefixForCheck(id: string) {
|
||||
return `${DocumentType.ROLE}${SEPARATOR}${id}`
|
||||
}
|
||||
|
||||
// Function to detect loops in roles
|
||||
export function checkForRoleInheritanceLoops(roles: Role[]): boolean {
|
||||
const roleMap = new Map<string, Role>()
|
||||
roles.forEach(role => {
|
||||
roleMap.set(role._id!, role)
|
||||
})
|
||||
|
||||
const checked = new Set<string>()
|
||||
const checking = new Set<string>()
|
||||
|
||||
function hasLoop(roleId: string): boolean {
|
||||
const prefixed = prefixForCheck(roleId)
|
||||
if (checking.has(roleId) || checking.has(prefixed)) {
|
||||
return true
|
||||
}
|
||||
if (checked.has(roleId) || checked.has(prefixed)) {
|
||||
return false
|
||||
}
|
||||
|
||||
checking.add(roleId)
|
||||
|
||||
const role = roleMap.get(prefixed) || roleMap.get(roleId)
|
||||
if (!role) {
|
||||
// role not found - ignore
|
||||
checking.delete(roleId)
|
||||
return false
|
||||
}
|
||||
|
||||
const inherits = Array.isArray(role.inherits)
|
||||
? role.inherits
|
||||
: [role.inherits]
|
||||
for (const inheritedId of inherits) {
|
||||
if (inheritedId && hasLoop(inheritedId)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// mark this role has been fully checked
|
||||
checking.delete(roleId)
|
||||
checked.add(roleId)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return !!roles.find(role => hasLoop(role._id!))
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { checkForRoleInheritanceLoops } from "../roles"
|
||||
import { Role } from "@budibase/types"
|
||||
|
||||
/**
|
||||
* This unit test exists as this utility will be used in the frontend and backend, confirmation
|
||||
* of its API and expected results is useful since the backend tests won't confirm it works
|
||||
* exactly as the frontend needs it to - easy to add specific test cases here that the frontend
|
||||
* might need to check/cover.
|
||||
*/
|
||||
|
||||
interface TestRole extends Omit<Role, "_id"> {
|
||||
_id: string
|
||||
}
|
||||
|
||||
let allRoles: TestRole[] = []
|
||||
|
||||
function role(id: string, inherits: string | string[]): TestRole {
|
||||
const role = {
|
||||
_id: id,
|
||||
inherits: inherits,
|
||||
name: "ROLE",
|
||||
permissionId: "PERMISSION",
|
||||
permissions: {}, // not needed for this test
|
||||
}
|
||||
allRoles.push(role)
|
||||
return role
|
||||
}
|
||||
|
||||
describe("role utilities", () => {
|
||||
let role1: TestRole, role2: TestRole
|
||||
|
||||
beforeEach(() => {
|
||||
role1 = role("role_1", [])
|
||||
role2 = role("role_2", [role1._id])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
allRoles = []
|
||||
})
|
||||
|
||||
function check(hasLoop: boolean) {
|
||||
const result = checkForRoleInheritanceLoops(allRoles)
|
||||
expect(result).toBe(hasLoop)
|
||||
}
|
||||
|
||||
describe("checkForRoleInheritanceLoops", () => {
|
||||
it("should confirm no loops", () => {
|
||||
check(false)
|
||||
})
|
||||
|
||||
it("should confirm there is a loop", () => {
|
||||
const role3 = role("role_3", [role2._id])
|
||||
const role4 = role("role_4", [role3._id, role2._id, role1._id])
|
||||
role3.inherits = [
|
||||
...(Array.isArray(role3.inherits) ? role3.inherits : []),
|
||||
role4._id,
|
||||
]
|
||||
check(true)
|
||||
})
|
||||
|
||||
it("should handle new and old inherits structure", () => {
|
||||
const role1 = role("role_role_1", "role_1")
|
||||
role("role_role_2", ["role_1"])
|
||||
role1.inherits = "role_2"
|
||||
check(true)
|
||||
})
|
||||
|
||||
it("self reference contains loop", () => {
|
||||
role("role1", "role1")
|
||||
check(true)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -8,6 +8,7 @@ const allowDisplayColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.NUMBER]: true,
|
||||
[FieldType.DATETIME]: true,
|
||||
[FieldType.FORMULA]: true,
|
||||
[FieldType.AI]: true,
|
||||
[FieldType.AUTO]: true,
|
||||
[FieldType.INTERNAL]: true,
|
||||
[FieldType.BARCODEQR]: true,
|
||||
|
@ -38,6 +39,7 @@ const allowSortColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.JSON]: true,
|
||||
|
||||
[FieldType.FORMULA]: false,
|
||||
[FieldType.AI]: false,
|
||||
[FieldType.ATTACHMENTS]: false,
|
||||
[FieldType.ATTACHMENT_SINGLE]: false,
|
||||
[FieldType.SIGNATURE_SINGLE]: false,
|
||||
|
@ -62,6 +64,7 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.BIGINT]: false,
|
||||
[FieldType.BOOLEAN]: false,
|
||||
[FieldType.FORMULA]: false,
|
||||
[FieldType.AI]: false,
|
||||
[FieldType.ATTACHMENTS]: false,
|
||||
[FieldType.ATTACHMENT_SINGLE]: false,
|
||||
[FieldType.SIGNATURE_SINGLE]: false,
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { Role, RoleUIMetadata } from "../../documents"
|
||||
import { PermissionLevel } from "../../sdk"
|
||||
|
||||
export interface SaveRoleRequest {
|
||||
_id?: string
|
||||
_rev?: string
|
||||
name: string
|
||||
inherits: string
|
||||
inherits?: string | string[]
|
||||
permissionId: string
|
||||
version: string
|
||||
permissions?: Record<string, PermissionLevel[]>
|
||||
version?: string
|
||||
uiMetadata?: RoleUIMetadata
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export interface App extends Document {
|
|||
usedPlugins?: Plugin[]
|
||||
upgradableVersion?: string
|
||||
snippets?: Snippet[]
|
||||
creationVersion?: string
|
||||
}
|
||||
|
||||
export interface AppInstance {
|
||||
|
|
|
@ -261,6 +261,7 @@ export type UpdatedRowEventEmitter = {
|
|||
oldRow: Row
|
||||
table: Table
|
||||
appId: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export enum LoopStepType {
|
||||
|
|
|
@ -9,7 +9,7 @@ export interface RoleUIMetadata {
|
|||
|
||||
export interface Role extends Document {
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
inherits?: string | string[]
|
||||
permissions: Record<string, PermissionLevel[]>
|
||||
version?: string
|
||||
name: string
|
||||
|
|
|
@ -76,6 +76,13 @@ export enum FieldType {
|
|||
* that is part of the initial formula definition, the formula will be live evaluated in the browser.
|
||||
*/
|
||||
AUTO = "auto",
|
||||
/**
|
||||
* A complex type, called an AI column within Budibase. This type is only supported against internal tables
|
||||
* and calculates the output based on a chosen operation (summarise text, translation etc) which passes to
|
||||
* the configured Budibase Large Language Model to retrieve the output and write it back into the row.
|
||||
* AI fields function in a similar fashion to static formulas, and possess many of the same characteristics.
|
||||
*/
|
||||
AI = "ai",
|
||||
/**
|
||||
* a JSON type, called JSON within Budibase. This type allows any arbitrary JSON to be input to this column
|
||||
* type, which will be represented as a JSON object in the row. This type depends on a schema being
|
||||
|
|
|
@ -30,6 +30,7 @@ export enum JsonFieldSubType {
|
|||
export enum FormulaType {
|
||||
STATIC = "static",
|
||||
DYNAMIC = "dynamic",
|
||||
AI = "ai",
|
||||
}
|
||||
|
||||
export enum BBReferenceFieldSubType {
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
JsonFieldSubType,
|
||||
RelationshipType,
|
||||
} from "./constants"
|
||||
import { AIOperationEnum } from "../../../sdk/ai"
|
||||
|
||||
export interface UIFieldMetadata {
|
||||
order?: number
|
||||
|
@ -116,6 +117,16 @@ export interface FormulaFieldMetadata extends BaseFieldSchema {
|
|||
formulaType?: FormulaType
|
||||
}
|
||||
|
||||
export interface AIFieldMetadata extends BaseFieldSchema {
|
||||
type: FieldType.AI
|
||||
operation: AIOperationEnum
|
||||
columns?: string[]
|
||||
column?: string
|
||||
categories?: string[]
|
||||
prompt?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface BBReferenceFieldMetadata
|
||||
extends Omit<BaseFieldSchema, "subtype"> {
|
||||
type: FieldType.BB_REFERENCE
|
||||
|
@ -194,6 +205,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
|
|||
| FieldType.LINK
|
||||
| FieldType.AUTO
|
||||
| FieldType.FORMULA
|
||||
| FieldType.AI
|
||||
| FieldType.NUMBER
|
||||
| FieldType.LONGFORM
|
||||
| FieldType.BB_REFERENCE
|
||||
|
@ -211,6 +223,7 @@ export type FieldSchema =
|
|||
| RelationshipFieldMetadata
|
||||
| AutoColumnFieldMetadata
|
||||
| FormulaFieldMetadata
|
||||
| AIFieldMetadata
|
||||
| NumberFieldMetadata
|
||||
| LongFormFieldMetadata
|
||||
| StringFieldMetadata
|
||||
|
|
|
@ -68,6 +68,16 @@ export interface User extends Document {
|
|||
appSort?: string
|
||||
}
|
||||
|
||||
export interface UserBindings extends Document {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
email?: string
|
||||
status?: string
|
||||
roleId?: string | null
|
||||
globalId?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
ACTIVE = "active",
|
||||
INACTIVE = "inactive",
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
export enum AIOperationEnum {
|
||||
SUMMARISE_TEXT = "SUMMARISE_TEXT",
|
||||
CLEAN_DATA = "CLEAN_DATA",
|
||||
TRANSLATE = "TRANSLATE",
|
||||
CATEGORISE_TEXT = "CATEGORISE_TEXT",
|
||||
SENTIMENT_ANALYSIS = "SENTIMENT_ANALYSIS",
|
||||
PROMPT = "PROMPT",
|
||||
SEARCH_WEB = "SEARCH_WEB",
|
||||
}
|
||||
|
||||
export enum OperationFieldTypeEnum {
|
||||
MULTI_COLUMN = "columns",
|
||||
COLUMN = "column",
|
||||
BINDABLE_TEXT = "prompt",
|
||||
}
|
||||
|
||||
export type OperationFieldsType = {
|
||||
[AIOperationEnum.SUMMARISE_TEXT]: {
|
||||
columns: OperationFieldTypeEnum.MULTI_COLUMN
|
||||
}
|
||||
[AIOperationEnum.CLEAN_DATA]: {
|
||||
column: OperationFieldTypeEnum.COLUMN
|
||||
}
|
||||
[AIOperationEnum.TRANSLATE]: {
|
||||
column: OperationFieldTypeEnum.COLUMN
|
||||
language: OperationFieldTypeEnum.BINDABLE_TEXT
|
||||
}
|
||||
[AIOperationEnum.CATEGORISE_TEXT]: {
|
||||
columns: OperationFieldTypeEnum.MULTI_COLUMN
|
||||
categories: OperationFieldTypeEnum.BINDABLE_TEXT
|
||||
}
|
||||
[AIOperationEnum.SENTIMENT_ANALYSIS]: {
|
||||
column: OperationFieldTypeEnum.COLUMN
|
||||
}
|
||||
[AIOperationEnum.PROMPT]: {
|
||||
prompt: OperationFieldTypeEnum.BINDABLE_TEXT
|
||||
}
|
||||
[AIOperationEnum.SEARCH_WEB]: {
|
||||
columns: OperationFieldTypeEnum.MULTI_COLUMN
|
||||
}
|
||||
}
|
||||
|
||||
type BaseSchema = {
|
||||
operation: AIOperationEnum
|
||||
}
|
||||
|
||||
type SummariseTextSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.SUMMARISE_TEXT
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
type CleanDataSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.CLEAN_DATA
|
||||
column: string
|
||||
}
|
||||
|
||||
type TranslateSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.TRANSLATE
|
||||
column: string
|
||||
language: string
|
||||
}
|
||||
|
||||
type CategoriseTextSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.CATEGORISE_TEXT
|
||||
columns: string[]
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
type SentimentAnalysisSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.SENTIMENT_ANALYSIS
|
||||
column: string
|
||||
}
|
||||
|
||||
type PromptSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.PROMPT
|
||||
prompt: string
|
||||
}
|
||||
|
||||
type SearchWebSchema = BaseSchema & {
|
||||
operation: AIOperationEnum.SEARCH_WEB
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
export type AIColumnSchema =
|
||||
| SummariseTextSchema
|
||||
| CleanDataSchema
|
||||
| TranslateSchema
|
||||
| CategoriseTextSchema
|
||||
| SentimentAnalysisSchema
|
||||
| PromptSchema
|
||||
| SearchWebSchema
|
|
@ -1,4 +1,9 @@
|
|||
import { Automation, AutomationMetadata, Row } from "../../documents"
|
||||
import {
|
||||
Automation,
|
||||
AutomationMetadata,
|
||||
Row,
|
||||
UserBindings,
|
||||
} from "../../documents"
|
||||
import { Job } from "bull"
|
||||
|
||||
export interface AutomationDataEvent {
|
||||
|
@ -8,6 +13,7 @@ export interface AutomationDataEvent {
|
|||
timeout?: number
|
||||
row?: Row
|
||||
oldRow?: Row
|
||||
user?: UserBindings
|
||||
}
|
||||
|
||||
export interface AutomationData {
|
||||
|
|
|
@ -3,19 +3,19 @@ import { BaseEvent } from "./event"
|
|||
export interface RoleCreatedEvent extends BaseEvent {
|
||||
roleId: string
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
inherits?: string | string[]
|
||||
}
|
||||
|
||||
export interface RoleUpdatedEvent extends BaseEvent {
|
||||
roleId: string
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
inherits?: string | string[]
|
||||
}
|
||||
|
||||
export interface RoleDeletedEvent extends BaseEvent {
|
||||
roleId: string
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
inherits?: string | string[]
|
||||
}
|
||||
|
||||
export interface RoleAssignedEvent extends BaseEvent {
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
export enum FeatureFlag {
|
||||
PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE",
|
||||
PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT",
|
||||
AUTOMATION_BRANCHING = "AUTOMATION_BRANCHING",
|
||||
SQS = "SQS",
|
||||
AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS",
|
||||
DEFAULT_VALUES = "DEFAULT_VALUES",
|
||||
ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS",
|
||||
TABLES_DEFAULT_ADMIN = "TABLES_DEFAULT_ADMIN",
|
||||
BUDIBASE_AI = "BUDIBASE_AI",
|
||||
}
|
||||
|
||||
export interface TenantFeatureFlags {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./ai"
|
||||
export * from "./automations"
|
||||
export * from "./hosting"
|
||||
export * from "./context"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Ctx, MaintenanceType } from "@budibase/types"
|
||||
import { Ctx, MaintenanceType, FeatureFlag } from "@budibase/types"
|
||||
import env from "../../../environment"
|
||||
import { env as coreEnv, db as dbCore, features } from "@budibase/backend-core"
|
||||
import nodeFetch from "node-fetch"
|
||||
|
@ -29,7 +29,10 @@ async function isSqsAvailable() {
|
|||
}
|
||||
|
||||
async function isSqsMissing() {
|
||||
return (await features.flags.isEnabled("SQS")) && !(await isSqsAvailable())
|
||||
return (
|
||||
(await features.flags.isEnabled(FeatureFlag.SQS)) &&
|
||||
!(await isSqsAvailable())
|
||||
)
|
||||
}
|
||||
|
||||
export const fetch = async (ctx: Ctx) => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { structures, TestConfiguration } from "../../../../tests"
|
||||
import { context, db, permissions, roles } from "@budibase/backend-core"
|
||||
import { Database } from "@budibase/types"
|
||||
import { App, Database } from "@budibase/types"
|
||||
|
||||
jest.mock("@budibase/backend-core", () => {
|
||||
const core = jest.requireActual("@budibase/backend-core")
|
||||
|
@ -30,6 +30,14 @@ async function addAppMetadata() {
|
|||
})
|
||||
}
|
||||
|
||||
async function updateAppMetadata(update: Partial<Omit<App, "_id" | "_rev">>) {
|
||||
const app = await appDb.get("app_metadata")
|
||||
await appDb.put({
|
||||
...app,
|
||||
...update,
|
||||
})
|
||||
}
|
||||
|
||||
describe("/api/global/roles", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
|
@ -69,6 +77,53 @@ describe("/api/global/roles", () => {
|
|||
expect(res.body[appId].roles.length).toEqual(5)
|
||||
expect(res.body[appId].roles.map((r: any) => r._id)).toContain(ROLE_NAME)
|
||||
})
|
||||
|
||||
it.each(["3.0.0", "3.0.1", "3.1.0", "3.0.0+2146.b125a7c"])(
|
||||
"exclude POWER roles after v3 (%s)",
|
||||
async creationVersion => {
|
||||
await updateAppMetadata({ creationVersion })
|
||||
const res = await config.api.roles.get()
|
||||
expect(res.body).toBeDefined()
|
||||
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
|
||||
ROLE_NAME,
|
||||
roles.BUILTIN_ROLE_IDS.ADMIN,
|
||||
roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
roles.BUILTIN_ROLE_IDS.PUBLIC,
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(["2.9.0", "1.0.0", "0.0.0", "2.32.17+2146.b125a7c"])(
|
||||
"include POWER roles before v3 (%s)",
|
||||
async creationVersion => {
|
||||
await updateAppMetadata({ creationVersion })
|
||||
const res = await config.api.roles.get()
|
||||
expect(res.body).toBeDefined()
|
||||
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
|
||||
ROLE_NAME,
|
||||
roles.BUILTIN_ROLE_IDS.ADMIN,
|
||||
roles.BUILTIN_ROLE_IDS.POWER,
|
||||
roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
roles.BUILTIN_ROLE_IDS.PUBLIC,
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(["invalid", ""])(
|
||||
"include POWER roles when the version is corrupted (%s)",
|
||||
async creationVersion => {
|
||||
await updateAppMetadata({ creationVersion })
|
||||
const res = await config.api.roles.get()
|
||||
|
||||
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
|
||||
ROLE_NAME,
|
||||
roles.BUILTIN_ROLE_IDS.ADMIN,
|
||||
roles.BUILTIN_ROLE_IDS.POWER,
|
||||
roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
roles.BUILTIN_ROLE_IDS.PUBLIC,
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe("GET api/global/roles/:appId", () => {
|
||||
|
|
Loading…
Reference in New Issue