Merge branch 'v3-ui' of github.com:Budibase/budibase into data-tidy-up
This commit is contained in:
commit
5bda7daf2f
|
@ -3,7 +3,7 @@ name: Deploy QA
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- v3-ui
|
- feature/automation-branching-ux
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "2.33.2",
|
"version": "2.33.4",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8
|
Subproject commit 607e3db866c366cb609b0015a1293edbeb703237
|
|
@ -27,7 +27,7 @@ export function doInUserContext(user: User, ctx: Ctx, task: any) {
|
||||||
hostInfo: {
|
hostInfo: {
|
||||||
ipAddress: ctx.request.ip,
|
ipAddress: ctx.request.ip,
|
||||||
// filled in by koa-useragent package
|
// filled in by koa-useragent package
|
||||||
userAgent: ctx.userAgent._agent.source,
|
userAgent: ctx.userAgent.source,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return doInIdentityContext(userContext, task)
|
return doInIdentityContext(userContext, task)
|
||||||
|
|
|
@ -171,9 +171,9 @@ const identifyUser = async (
|
||||||
if (isSSOUser(user)) {
|
if (isSSOUser(user)) {
|
||||||
providerType = user.providerType
|
providerType = user.providerType
|
||||||
}
|
}
|
||||||
const accountHolder = account?.budibaseUserId === user._id || false
|
const accountHolder = await users.getExistingAccounts([user.email])
|
||||||
const verified =
|
const isAccountHolder = accountHolder.length > 0
|
||||||
account && account?.budibaseUserId === user._id ? account.verified : false
|
const verified = !!account && isAccountHolder && account.verified
|
||||||
const installationId = await getInstallationId()
|
const installationId = await getInstallationId()
|
||||||
const hosting = account ? account.hosting : getHostingFromEnv()
|
const hosting = account ? account.hosting : getHostingFromEnv()
|
||||||
const environment = getDeploymentEnvironment()
|
const environment = getDeploymentEnvironment()
|
||||||
|
@ -185,7 +185,7 @@ const identifyUser = async (
|
||||||
installationId,
|
installationId,
|
||||||
tenantId,
|
tenantId,
|
||||||
verified,
|
verified,
|
||||||
accountHolder,
|
accountHolder: isAccountHolder,
|
||||||
providerType,
|
providerType,
|
||||||
builder,
|
builder,
|
||||||
admin,
|
admin,
|
||||||
|
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
|
||||||
const environment = getDeploymentEnvironment()
|
const environment = getDeploymentEnvironment()
|
||||||
|
|
||||||
if (isCloudAccount(account)) {
|
if (isCloudAccount(account)) {
|
||||||
if (account.budibaseUserId) {
|
const user = await users.getGlobalUserByEmail(account.email)
|
||||||
|
if (user?._id) {
|
||||||
// use the budibase user as the id if set
|
// use the budibase user as the id if set
|
||||||
id = account.budibaseUserId
|
id = user._id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
import { Cookie, Header } from "../constants"
|
import { Cookie, Header } from "../constants"
|
||||||
import {
|
import {
|
||||||
getCookie,
|
|
||||||
clearCookie,
|
clearCookie,
|
||||||
openJwt,
|
getCookie,
|
||||||
isValidInternalAPIKey,
|
isValidInternalAPIKey,
|
||||||
|
openJwt,
|
||||||
} from "../utils"
|
} from "../utils"
|
||||||
import { getUser } from "../cache/user"
|
import { getUser } from "../cache/user"
|
||||||
import { getSession, updateSessionTTL } from "../security/sessions"
|
import { getSession, updateSessionTTL } from "../security/sessions"
|
||||||
import { buildMatcherRegex, matches } from "./matchers"
|
import { buildMatcherRegex, matches } from "./matchers"
|
||||||
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
|
import { queryGlobalView, SEPARATOR, ViewName } from "../db"
|
||||||
import { getGlobalDB, doInTenant } from "../context"
|
import { doInTenant, getGlobalDB } from "../context"
|
||||||
import { decrypt } from "../security/encryption"
|
import { decrypt } from "../security/encryption"
|
||||||
import * as identity from "../context/identity"
|
import * as identity from "../context/identity"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
|
import {
|
||||||
import { InvalidAPIKeyError, ErrorCode } from "../errors"
|
Ctx,
|
||||||
|
EndpointMatcher,
|
||||||
|
LoginMethod,
|
||||||
|
SessionCookie,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { ErrorCode, InvalidAPIKeyError } from "../errors"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
|
||||||
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
||||||
|
@ -26,16 +32,18 @@ interface FinaliseOpts {
|
||||||
internal?: boolean
|
internal?: boolean
|
||||||
publicEndpoint?: boolean
|
publicEndpoint?: boolean
|
||||||
version?: string
|
version?: string
|
||||||
user?: any
|
user?: User | { tenantId: string }
|
||||||
|
loginMethod?: LoginMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeMinusOneMinute() {
|
function timeMinusOneMinute() {
|
||||||
return new Date(Date.now() - ONE_MINUTE).toISOString()
|
return new Date(Date.now() - ONE_MINUTE).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalise(ctx: any, opts: FinaliseOpts = {}) {
|
function finalise(ctx: Ctx, opts: FinaliseOpts = {}) {
|
||||||
ctx.publicEndpoint = opts.publicEndpoint || false
|
ctx.publicEndpoint = opts.publicEndpoint || false
|
||||||
ctx.isAuthenticated = opts.authenticated || false
|
ctx.isAuthenticated = opts.authenticated || false
|
||||||
|
ctx.loginMethod = opts.loginMethod
|
||||||
ctx.user = opts.user
|
ctx.user = opts.user
|
||||||
ctx.internal = opts.internal || false
|
ctx.internal = opts.internal || false
|
||||||
ctx.version = opts.version
|
ctx.version = opts.version
|
||||||
|
@ -120,9 +128,10 @@ export default function (
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
||||||
let authenticated = false,
|
let authenticated: boolean = false,
|
||||||
user = null,
|
user: User | { tenantId: string } | undefined = undefined,
|
||||||
internal = false
|
internal: boolean = false,
|
||||||
|
loginMethod: LoginMethod | undefined = undefined
|
||||||
if (authCookie && !apiKey) {
|
if (authCookie && !apiKey) {
|
||||||
const sessionId = authCookie.sessionId
|
const sessionId = authCookie.sessionId
|
||||||
const userId = authCookie.userId
|
const userId = authCookie.userId
|
||||||
|
@ -146,6 +155,7 @@ export default function (
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
user.csrfToken = session.csrfToken
|
user.csrfToken = session.csrfToken
|
||||||
|
loginMethod = LoginMethod.COOKIE
|
||||||
|
|
||||||
if (session?.lastAccessedAt < timeMinusOneMinute()) {
|
if (session?.lastAccessedAt < timeMinusOneMinute()) {
|
||||||
// make sure we denote that the session is still in use
|
// make sure we denote that the session is still in use
|
||||||
|
@ -170,17 +180,16 @@ export default function (
|
||||||
apiKey,
|
apiKey,
|
||||||
populateUser
|
populateUser
|
||||||
)
|
)
|
||||||
if (valid && foundUser) {
|
if (valid) {
|
||||||
authenticated = true
|
authenticated = true
|
||||||
|
loginMethod = LoginMethod.API_KEY
|
||||||
user = foundUser
|
user = foundUser
|
||||||
} else if (valid) {
|
internal = !foundUser
|
||||||
authenticated = true
|
|
||||||
internal = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user && tenantId) {
|
if (!user && tenantId) {
|
||||||
user = { tenantId }
|
user = { tenantId }
|
||||||
} else if (user) {
|
} else if (user && "password" in user) {
|
||||||
delete user.password
|
delete user.password
|
||||||
}
|
}
|
||||||
// be explicit
|
// be explicit
|
||||||
|
@ -204,7 +213,14 @@ export default function (
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAuthenticated is a function, so use a variable to be able to check authed state
|
// isAuthenticated is a function, so use a variable to be able to check authed state
|
||||||
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
|
finalise(ctx, {
|
||||||
|
authenticated,
|
||||||
|
user,
|
||||||
|
internal,
|
||||||
|
version,
|
||||||
|
publicEndpoint,
|
||||||
|
loginMethod,
|
||||||
|
})
|
||||||
|
|
||||||
if (isUser(user)) {
|
if (isUser(user)) {
|
||||||
return identity.doInUserContext(user, ctx, next)
|
return identity.doInUserContext(user, ctx, next)
|
||||||
|
|
|
@ -179,12 +179,6 @@ class InternalBuilder {
|
||||||
return this.table.schema[column]
|
return this.table.schema[column]
|
||||||
}
|
}
|
||||||
|
|
||||||
private supportsILike(): boolean {
|
|
||||||
return !(
|
|
||||||
this.client === SqlClient.ORACLE || this.client === SqlClient.SQL_LITE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private quoteChars(): [string, string] {
|
private quoteChars(): [string, string] {
|
||||||
const wrapped = this.knexClient.wrapIdentifier("foo", {})
|
const wrapped = this.knexClient.wrapIdentifier("foo", {})
|
||||||
return [wrapped[0], wrapped[wrapped.length - 1]]
|
return [wrapped[0], wrapped[wrapped.length - 1]]
|
||||||
|
@ -216,8 +210,30 @@ class InternalBuilder {
|
||||||
return formatter.wrap(value, false)
|
return formatter.wrap(value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private rawQuotedValue(value: string): Knex.Raw {
|
private castIntToString(identifier: string | Knex.Raw): Knex.Raw {
|
||||||
return this.knex.raw(this.quotedValue(value))
|
switch (this.client) {
|
||||||
|
case SqlClient.ORACLE: {
|
||||||
|
return this.knex.raw("to_char(??)", [identifier])
|
||||||
|
}
|
||||||
|
case SqlClient.POSTGRES: {
|
||||||
|
return this.knex.raw("??::TEXT", [identifier])
|
||||||
|
}
|
||||||
|
case SqlClient.MY_SQL:
|
||||||
|
case SqlClient.MARIADB: {
|
||||||
|
return this.knex.raw("CAST(?? AS CHAR)", [identifier])
|
||||||
|
}
|
||||||
|
case SqlClient.SQL_LITE: {
|
||||||
|
// Technically sqlite can actually represent numbers larger than a 64bit
|
||||||
|
// int as a string, but it does it using scientific notation (e.g.
|
||||||
|
// "1e+20") which is not what we want. Given that the external SQL
|
||||||
|
// databases are limited to supporting only 64bit ints, we settle for
|
||||||
|
// that here.
|
||||||
|
return this.knex.raw("printf('%d', ??)", [identifier])
|
||||||
|
}
|
||||||
|
case SqlClient.MS_SQL: {
|
||||||
|
return this.knex.raw("CONVERT(NVARCHAR, ??)", [identifier])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfortuantely we cannot rely on knex's identifier escaping because it trims
|
// Unfortuantely we cannot rely on knex's identifier escaping because it trims
|
||||||
|
@ -1078,21 +1094,26 @@ class InternalBuilder {
|
||||||
query = query.count(`* as ${aggregation.name}`)
|
query = query.count(`* as ${aggregation.name}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
|
const fieldSchema = this.getFieldSchema(aggregation.field)
|
||||||
switch (op) {
|
if (!fieldSchema) {
|
||||||
case CalculationType.SUM:
|
// This should not happen in practice.
|
||||||
query = query.sum(field)
|
throw new Error(
|
||||||
break
|
`field schema missing for aggregation target: ${aggregation.field}`
|
||||||
case CalculationType.AVG:
|
)
|
||||||
query = query.avg(field)
|
|
||||||
break
|
|
||||||
case CalculationType.MIN:
|
|
||||||
query = query.min(field)
|
|
||||||
break
|
|
||||||
case CalculationType.MAX:
|
|
||||||
query = query.max(field)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let aggregate = this.knex.raw("??(??)", [
|
||||||
|
this.knex.raw(op),
|
||||||
|
this.rawQuotedIdentifier(`${tableName}.${aggregation.field}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (fieldSchema.type === FieldType.BIGINT) {
|
||||||
|
aggregate = this.castIntToString(aggregate)
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.select(
|
||||||
|
this.knex.raw("?? as ??", [aggregate, aggregation.name])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return query
|
return query
|
||||||
|
|
|
@ -1,29 +1,6 @@
|
||||||
import { getDB } from "../db/db"
|
import { getDB } from "../db/db"
|
||||||
import { getGlobalDBName } from "../context"
|
import { getGlobalDBName } from "../context"
|
||||||
import { TenantInfo } from "@budibase/types"
|
|
||||||
|
|
||||||
export function getTenantDB(tenantId: string) {
|
export function getTenantDB(tenantId: string) {
|
||||||
return getDB(getGlobalDBName(tenantId))
|
return getDB(getGlobalDBName(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTenantInfo(tenantInfo: TenantInfo) {
|
|
||||||
const db = getTenantDB(tenantInfo.tenantId)
|
|
||||||
// save the tenant info to db
|
|
||||||
return db.put({
|
|
||||||
_id: "tenant_info",
|
|
||||||
...tenantInfo,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTenantInfo(
|
|
||||||
tenantId: string
|
|
||||||
): Promise<TenantInfo | undefined> {
|
|
||||||
try {
|
|
||||||
const db = getTenantDB(tenantId)
|
|
||||||
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
|
|
||||||
delete tenantInfo.owner.password
|
|
||||||
return tenantInfo
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -16,14 +16,15 @@ import {
|
||||||
isSSOUser,
|
isSSOUser,
|
||||||
SaveUserOpts,
|
SaveUserOpts,
|
||||||
User,
|
User,
|
||||||
UserStatus,
|
|
||||||
UserGroup,
|
UserGroup,
|
||||||
|
UserIdentifier,
|
||||||
|
UserStatus,
|
||||||
PlatformUserBySsoId,
|
PlatformUserBySsoId,
|
||||||
PlatformUserById,
|
PlatformUserById,
|
||||||
AnyDocument,
|
AnyDocument,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import {
|
import {
|
||||||
getAccountHolderFromUserIds,
|
getAccountHolderFromUsers,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isCreator,
|
isCreator,
|
||||||
validateUniqueUser,
|
validateUniqueUser,
|
||||||
|
@ -412,7 +413,9 @@ export class UserDB {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
|
static async bulkDelete(
|
||||||
|
users: Array<UserIdentifier>
|
||||||
|
): Promise<BulkUserDeleted> {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
|
|
||||||
const response: BulkUserDeleted = {
|
const response: BulkUserDeleted = {
|
||||||
|
@ -421,13 +424,13 @@ export class UserDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the account holder from the delete request if present
|
// remove the account holder from the delete request if present
|
||||||
const account = await getAccountHolderFromUserIds(userIds)
|
const accountHolder = await getAccountHolderFromUsers(users)
|
||||||
if (account) {
|
if (accountHolder) {
|
||||||
userIds = userIds.filter(u => u !== account.budibaseUserId)
|
users = users.filter(u => u.userId !== accountHolder.userId)
|
||||||
// mark user as unsuccessful
|
// mark user as unsuccessful
|
||||||
response.unsuccessful.push({
|
response.unsuccessful.push({
|
||||||
_id: account.budibaseUserId,
|
_id: accountHolder.userId,
|
||||||
email: account.email,
|
email: accountHolder.email,
|
||||||
reason: "Account holder cannot be deleted",
|
reason: "Account holder cannot be deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -435,7 +438,7 @@ export class UserDB {
|
||||||
// Get users and delete
|
// Get users and delete
|
||||||
const allDocsResponse = await db.allDocs<User>({
|
const allDocsResponse = await db.allDocs<User>({
|
||||||
include_docs: true,
|
include_docs: true,
|
||||||
keys: userIds,
|
keys: users.map(u => u.userId),
|
||||||
})
|
})
|
||||||
const usersToDelete = allDocsResponse.rows.map(user => {
|
const usersToDelete = allDocsResponse.rows.map(user => {
|
||||||
return user.doc!
|
return user.doc!
|
||||||
|
|
|
@ -70,6 +70,17 @@ export async function getAllUserIds() {
|
||||||
return response.rows.map(row => row.id)
|
return response.rows.map(row => row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(): Promise<User[]> {
|
||||||
|
const db = getGlobalDB()
|
||||||
|
const startKey = `${DocumentType.USER}${SEPARATOR}`
|
||||||
|
const response = await db.allDocs({
|
||||||
|
startkey: startKey,
|
||||||
|
endkey: `${startKey}${UNICODE_MAX}`,
|
||||||
|
include_docs: true,
|
||||||
|
})
|
||||||
|
return response.rows.map(row => row.doc) as User[]
|
||||||
|
}
|
||||||
|
|
||||||
export async function bulkUpdateGlobalUsers(users: User[]) {
|
export async function bulkUpdateGlobalUsers(users: User[]) {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
|
import { ContextUser, User, UserGroup, UserIdentifier } from "@budibase/types"
|
||||||
import * as accountSdk from "../accounts"
|
import * as accountSdk from "../accounts"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { getFirstPlatformUser } from "./lookup"
|
import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
|
||||||
import { EmailUnavailableError } from "../errors"
|
import { EmailUnavailableError } from "../errors"
|
||||||
import { getTenantId } from "../context"
|
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
import { getAccountByTenantId } from "../accounts"
|
|
||||||
import { BUILTIN_ROLE_IDS } from "../security/roles"
|
import { BUILTIN_ROLE_IDS } from "../security/roles"
|
||||||
import * as context from "../context"
|
import * as context from "../context"
|
||||||
|
|
||||||
|
@ -67,21 +65,17 @@ export async function validateUniqueUser(email: string, tenantId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For the given user id's, return the account holder if it is in the ids.
|
* For a list of users, return the account holder if there is an email match.
|
||||||
*/
|
*/
|
||||||
export async function getAccountHolderFromUserIds(
|
export async function getAccountHolderFromUsers(
|
||||||
userIds: string[]
|
users: Array<UserIdentifier>
|
||||||
): Promise<CloudAccount | undefined> {
|
): Promise<UserIdentifier | undefined> {
|
||||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
const tenantId = getTenantId()
|
const accountMetadata = await getExistingAccounts(
|
||||||
const account = await getAccountByTenantId(tenantId)
|
users.map(user => user.email)
|
||||||
if (!account) {
|
)
|
||||||
throw new Error(`Account not found for tenantId=${tenantId}`)
|
return users.find(user =>
|
||||||
}
|
accountMetadata.map(metadata => metadata.email).includes(user.email)
|
||||||
|
)
|
||||||
const budibaseUserId = account.budibaseUserId
|
|
||||||
if (userIds.includes(budibaseUserId)) {
|
|
||||||
return account
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
Multiselect,
|
Multiselect,
|
||||||
Button,
|
Button,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { CalculationType, canGroupBy, FieldType } from "@budibase/types"
|
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
|
||||||
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||||
|
@ -91,10 +91,7 @@
|
||||||
return Object.entries(schema)
|
return Object.entries(schema)
|
||||||
.filter(([field, fieldSchema]) => {
|
.filter(([field, fieldSchema]) => {
|
||||||
// Only allow numeric fields that are not calculations themselves
|
// Only allow numeric fields that are not calculations themselves
|
||||||
if (
|
if (fieldSchema.calculationType || !isNumeric(fieldSchema.type)) {
|
||||||
fieldSchema.calculationType ||
|
|
||||||
fieldSchema.type !== FieldType.NUMBER
|
|
||||||
) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Don't allow duplicates
|
// Don't allow duplicates
|
||||||
|
|
|
@ -38,6 +38,10 @@
|
||||||
let loaded = false
|
let loaded = false
|
||||||
$: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
|
$: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
|
||||||
$: licensePlan = $auth.user?.license?.plan
|
$: licensePlan = $auth.user?.license?.plan
|
||||||
|
|
||||||
|
// Reset the page every time that a filter gets updated
|
||||||
|
$: pageInfo.reset(), automationId, status, timeRange
|
||||||
|
|
||||||
$: page = $pageInfo.page
|
$: page = $pageInfo.page
|
||||||
$: fetchLogs(automationId, status, page, timeRange)
|
$: fetchLogs(automationId, status, page, timeRange)
|
||||||
$: isCloud = $admin.cloud
|
$: isCloud = $admin.cloud
|
||||||
|
|
|
@ -206,7 +206,7 @@
|
||||||
if (!user?._id) {
|
if (!user?._id) {
|
||||||
$goto("./")
|
$goto("./")
|
||||||
}
|
}
|
||||||
tenantOwner = await users.tenantOwner($auth.tenantId)
|
tenantOwner = await users.getAccountHolder()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFlags(detail) {
|
async function toggleFlags(detail) {
|
||||||
|
|
|
@ -280,7 +280,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await users.bulkDelete(ids)
|
await users.bulkDelete(
|
||||||
|
selectedRows.map(user => ({
|
||||||
|
userId: user._id,
|
||||||
|
email: user.email,
|
||||||
|
}))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedInvites.length > 0) {
|
if (selectedInvites.length > 0) {
|
||||||
|
@ -319,7 +324,7 @@
|
||||||
groupsLoaded = true
|
groupsLoaded = true
|
||||||
pendingInvites = await users.getInvites()
|
pendingInvites = await users.getInvites()
|
||||||
invitesLoaded = true
|
invitesLoaded = true
|
||||||
tenantOwner = await users.tenantOwner($auth.tenantId)
|
tenantOwner = await users.getAccountHolder()
|
||||||
tenantOwnerLoaded = true
|
tenantOwnerLoaded = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error fetching user group data")
|
notifications.error("Error fetching user group data")
|
||||||
|
|
|
@ -112,8 +112,8 @@ export function createUsersStore() {
|
||||||
return await API.getUserCountByApp({ appId })
|
return await API.getUserCountByApp({ appId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkDelete(userIds) {
|
async function bulkDelete(users) {
|
||||||
return API.deleteUsers(userIds)
|
return API.deleteUsers(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(user) {
|
async function save(user) {
|
||||||
|
@ -128,9 +128,8 @@ export function createUsersStore() {
|
||||||
return await API.removeAppBuilder({ userId, appId })
|
return await API.removeAppBuilder({ userId, appId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTenantOwner(tenantId) {
|
async function getAccountHolder() {
|
||||||
const tenantInfo = await API.getTenantInfo({ tenantId })
|
return await API.getAccountHolder()
|
||||||
return tenantInfo?.owner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserRole = user => {
|
const getUserRole = user => {
|
||||||
|
@ -176,7 +175,7 @@ export function createUsersStore() {
|
||||||
save: refreshUsage(save),
|
save: refreshUsage(save),
|
||||||
bulkDelete: refreshUsage(bulkDelete),
|
bulkDelete: refreshUsage(bulkDelete),
|
||||||
delete: refreshUsage(del),
|
delete: refreshUsage(del),
|
||||||
tenantOwner: getTenantOwner,
|
getAccountHolder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,14 +122,14 @@ export const buildUserEndpoints = API => ({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes multiple users
|
* Deletes multiple users
|
||||||
* @param userIds the ID of the user to delete
|
* @param users the ID/email pair of the user to delete
|
||||||
*/
|
*/
|
||||||
deleteUsers: async userIds => {
|
deleteUsers: async users => {
|
||||||
const res = await API.post({
|
const res = await API.post({
|
||||||
url: `/api/global/users/bulk`,
|
url: `/api/global/users/bulk`,
|
||||||
body: {
|
body: {
|
||||||
delete: {
|
delete: {
|
||||||
userIds,
|
users,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -296,9 +296,9 @@ export const buildUserEndpoints = API => ({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
getTenantInfo: async ({ tenantId }) => {
|
getAccountHolder: async () => {
|
||||||
return await API.get({
|
return await API.get({
|
||||||
url: `/api/global/tenant/${tenantId}`,
|
url: `/api/global/users/accountholder`,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit f6aebba94451ce47bba551926e5ad72bd75f71c6
|
Subproject commit 2ab8536b6005576684810d774f1ac22239218546
|
|
@ -20,19 +20,15 @@ const options = {
|
||||||
{
|
{
|
||||||
url: "https://budibase.app/api/public/v1",
|
url: "https://budibase.app/api/public/v1",
|
||||||
description: "Budibase Cloud API",
|
description: "Budibase Cloud API",
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "{protocol}://{hostname}/api/public/v1",
|
|
||||||
description: "Budibase self hosted API",
|
|
||||||
variables: {
|
variables: {
|
||||||
protocol: {
|
apiKey: {
|
||||||
default: "http",
|
default: "<user API key>",
|
||||||
description:
|
description: "The API key of the user to assume for API call.",
|
||||||
"Whether HTTP or HTTPS should be used to communicate with your Budibase instance.",
|
|
||||||
},
|
},
|
||||||
hostname: {
|
appId: {
|
||||||
default: "localhost:10000",
|
default: "<App ID>",
|
||||||
description: "The URL of your Budibase instance.",
|
description:
|
||||||
|
"The ID of the app the calls will be executed within the context of, this should start with app_ (production) or app_dev (development).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,19 +8,15 @@
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "https://budibase.app/api/public/v1",
|
"url": "https://budibase.app/api/public/v1",
|
||||||
"description": "Budibase Cloud API"
|
"description": "Budibase Cloud API",
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "{protocol}://{hostname}/api/public/v1",
|
|
||||||
"description": "Budibase self hosted API",
|
|
||||||
"variables": {
|
"variables": {
|
||||||
"protocol": {
|
"apiKey": {
|
||||||
"default": "http",
|
"default": "<user API key>",
|
||||||
"description": "Whether HTTP or HTTPS should be used to communicate with your Budibase instance."
|
"description": "The API key of the user to assume for API call."
|
||||||
},
|
},
|
||||||
"hostname": {
|
"appId": {
|
||||||
"default": "localhost:10000",
|
"default": "<App ID>",
|
||||||
"description": "The URL of your Budibase instance."
|
"description": "The ID of the app the calls will be executed within the context of, this should start with app_ (production) or app_dev (development)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,6 +47,7 @@
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "The ID of the app which this request is targeting.",
|
"description": "The ID of the app which this request is targeting.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"default": "{{ appId }}",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -60,6 +57,7 @@
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "The ID of the app which this request is targeting.",
|
"description": "The ID of the app which this request is targeting.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"default": "{{ appId }}",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,16 +6,14 @@ info:
|
||||||
servers:
|
servers:
|
||||||
- url: https://budibase.app/api/public/v1
|
- url: https://budibase.app/api/public/v1
|
||||||
description: Budibase Cloud API
|
description: Budibase Cloud API
|
||||||
- url: "{protocol}://{hostname}/api/public/v1"
|
|
||||||
description: Budibase self hosted API
|
|
||||||
variables:
|
variables:
|
||||||
protocol:
|
apiKey:
|
||||||
default: http
|
default: <user API key>
|
||||||
description: Whether HTTP or HTTPS should be used to communicate with your
|
description: The API key of the user to assume for API call.
|
||||||
Budibase instance.
|
appId:
|
||||||
hostname:
|
default: <App ID>
|
||||||
default: localhost:10000
|
description: The ID of the app the calls will be executed within the context of,
|
||||||
description: The URL of your Budibase instance.
|
this should start with app_ (production) or app_dev (development).
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
tableId:
|
tableId:
|
||||||
|
@ -38,6 +36,7 @@ components:
|
||||||
required: true
|
required: true
|
||||||
description: The ID of the app which this request is targeting.
|
description: The ID of the app which this request is targeting.
|
||||||
schema:
|
schema:
|
||||||
|
default: "{{ appId }}"
|
||||||
type: string
|
type: string
|
||||||
appIdUrl:
|
appIdUrl:
|
||||||
in: path
|
in: path
|
||||||
|
@ -45,6 +44,7 @@ components:
|
||||||
required: true
|
required: true
|
||||||
description: The ID of the app which this request is targeting.
|
description: The ID of the app which this request is targeting.
|
||||||
schema:
|
schema:
|
||||||
|
default: "{{ appId }}"
|
||||||
type: string
|
type: string
|
||||||
queryId:
|
queryId:
|
||||||
in: path
|
in: path
|
||||||
|
|
|
@ -24,6 +24,7 @@ export const appId = {
|
||||||
required: true,
|
required: true,
|
||||||
description: "The ID of the app which this request is targeting.",
|
description: "The ID of the app which this request is targeting.",
|
||||||
schema: {
|
schema: {
|
||||||
|
default: "{{ appId }}",
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -34,6 +35,7 @@ export const appIdUrl = {
|
||||||
required: true,
|
required: true,
|
||||||
description: "The ID of the app which this request is targeting.",
|
description: "The ID of the app which this request is targeting.",
|
||||||
schema: {
|
schema: {
|
||||||
|
default: "{{ appId }}",
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,7 +138,7 @@ const tableSchema = {
|
||||||
},
|
},
|
||||||
formulaType: {
|
formulaType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: Object.values(FormulaType),
|
enum: [FormulaType.STATIC, FormulaType.DYNAMIC],
|
||||||
description:
|
description:
|
||||||
"Defines whether this is a static or dynamic formula.",
|
"Defines whether this is a static or dynamic formula.",
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,11 +11,13 @@ import {
|
||||||
IncludeRelationship,
|
IncludeRelationship,
|
||||||
InternalSearchFilterOperator,
|
InternalSearchFilterOperator,
|
||||||
isManyToOne,
|
isManyToOne,
|
||||||
|
isOneToMany,
|
||||||
OneToManyRelationshipFieldMetadata,
|
OneToManyRelationshipFieldMetadata,
|
||||||
Operation,
|
Operation,
|
||||||
PaginationJson,
|
PaginationJson,
|
||||||
QueryJson,
|
QueryJson,
|
||||||
RelationshipFieldMetadata,
|
RelationshipFieldMetadata,
|
||||||
|
RelationshipType,
|
||||||
Row,
|
Row,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
SortJson,
|
SortJson,
|
||||||
|
@ -50,13 +52,15 @@ import sdk from "../../../sdk"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import { makeExternalQuery } from "../../../integrations/base/query"
|
import { makeExternalQuery } from "../../../integrations/base/query"
|
||||||
import { dataFilters, helpers } from "@budibase/shared-core"
|
import { dataFilters, helpers } from "@budibase/shared-core"
|
||||||
|
import { isRelationshipColumn } from "../../../db/utils"
|
||||||
|
|
||||||
export interface ManyRelationship {
|
interface ManyRelationship {
|
||||||
tableId?: string
|
tableId?: string
|
||||||
id?: string
|
id?: string
|
||||||
isUpdate?: boolean
|
isUpdate?: boolean
|
||||||
key: string
|
key: string
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
|
relationshipType: RelationshipType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunConfig {
|
export interface RunConfig {
|
||||||
|
@ -384,6 +388,7 @@ export class ExternalRequest<T extends Operation> {
|
||||||
[otherKey]: breakRowIdField(relationship)[0],
|
[otherKey]: breakRowIdField(relationship)[0],
|
||||||
// leave the ID for enrichment later
|
// leave the ID for enrichment later
|
||||||
[thisKey]: `{{ literal ${tablePrimary} }}`,
|
[thisKey]: `{{ literal ${tablePrimary} }}`,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -400,6 +405,7 @@ export class ExternalRequest<T extends Operation> {
|
||||||
[thisKey]: breakRowIdField(relationship)[0],
|
[thisKey]: breakRowIdField(relationship)[0],
|
||||||
// leave the ID for enrichment later
|
// leave the ID for enrichment later
|
||||||
[otherKey]: `{{ literal ${tablePrimary} }}`,
|
[otherKey]: `{{ literal ${tablePrimary} }}`,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_ONE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -420,14 +426,30 @@ export class ExternalRequest<T extends Operation> {
|
||||||
return { row: newRow as T, manyRelationships }
|
return { row: newRow as T, manyRelationships }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLookupRelationsKey(relationship: {
|
||||||
|
relationshipType: RelationshipType
|
||||||
|
fieldName: string
|
||||||
|
through?: string
|
||||||
|
}) {
|
||||||
|
if (relationship.relationshipType === RelationshipType.MANY_TO_MANY) {
|
||||||
|
return `${relationship.through}_${relationship.fieldName}`
|
||||||
|
}
|
||||||
|
return relationship.fieldName
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This is a cached lookup, of relationship records, this is mainly for creating/deleting junction
|
* This is a cached lookup, of relationship records, this is mainly for creating/deleting junction
|
||||||
* information.
|
* information.
|
||||||
*/
|
*/
|
||||||
async lookupRelations(tableId: string, row: Row) {
|
private async lookupRelations(tableId: string, row: Row) {
|
||||||
const related: {
|
const related: Record<
|
||||||
[key: string]: { rows: Row[]; isMany: boolean; tableId: string }
|
string,
|
||||||
} = {}
|
{
|
||||||
|
rows: Row[]
|
||||||
|
isMany: boolean
|
||||||
|
tableId: string
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
const { tableName } = breakExternalTableId(tableId)
|
const { tableName } = breakExternalTableId(tableId)
|
||||||
const table = this.tables[tableName]
|
const table = this.tables[tableName]
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -459,11 +481,8 @@ export class ExternalRequest<T extends Operation> {
|
||||||
"Unable to lookup relationships - undefined column properties."
|
"Unable to lookup relationships - undefined column properties."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const { tableName: relatedTableName } =
|
|
||||||
breakExternalTableId(relatedTableId)
|
if (!lookupField || !row?.[lookupField]) {
|
||||||
// @ts-ignore
|
|
||||||
const linkPrimaryKey = this.tables[relatedTableName].primary[0]
|
|
||||||
if (!lookupField || !row?.[lookupField] == null) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const endpoint = getEndpoint(relatedTableId, Operation.READ)
|
const endpoint = getEndpoint(relatedTableId, Operation.READ)
|
||||||
|
@ -487,10 +506,8 @@ export class ExternalRequest<T extends Operation> {
|
||||||
!Array.isArray(response) || isKnexEmptyReadResponse(response)
|
!Array.isArray(response) || isKnexEmptyReadResponse(response)
|
||||||
? []
|
? []
|
||||||
: response
|
: response
|
||||||
const storeTo = isManyToMany(field)
|
|
||||||
? field.throughFrom || linkPrimaryKey
|
related[this.getLookupRelationsKey(field)] = {
|
||||||
: fieldName
|
|
||||||
related[storeTo] = {
|
|
||||||
rows,
|
rows,
|
||||||
isMany: isManyToMany(field),
|
isMany: isManyToMany(field),
|
||||||
tableId: relatedTableId,
|
tableId: relatedTableId,
|
||||||
|
@ -518,7 +535,8 @@ export class ExternalRequest<T extends Operation> {
|
||||||
const promises = []
|
const promises = []
|
||||||
const related = await this.lookupRelations(mainTableId, row)
|
const related = await this.lookupRelations(mainTableId, row)
|
||||||
for (let relationship of relationships) {
|
for (let relationship of relationships) {
|
||||||
const { key, tableId, isUpdate, id, ...rest } = relationship
|
const { key, tableId, isUpdate, id, relationshipType, ...rest } =
|
||||||
|
relationship
|
||||||
const body: { [key: string]: any } = processObjectSync(rest, row, {})
|
const body: { [key: string]: any } = processObjectSync(rest, row, {})
|
||||||
const linkTable = this.getTable(tableId)
|
const linkTable = this.getTable(tableId)
|
||||||
const relationshipPrimary = linkTable?.primary || []
|
const relationshipPrimary = linkTable?.primary || []
|
||||||
|
@ -529,7 +547,14 @@ export class ExternalRequest<T extends Operation> {
|
||||||
|
|
||||||
const linkSecondary = relationshipPrimary[1]
|
const linkSecondary = relationshipPrimary[1]
|
||||||
|
|
||||||
const rows = related[key]?.rows || []
|
const rows =
|
||||||
|
related[
|
||||||
|
this.getLookupRelationsKey({
|
||||||
|
relationshipType,
|
||||||
|
fieldName: key,
|
||||||
|
through: relationship.tableId,
|
||||||
|
})
|
||||||
|
]?.rows || []
|
||||||
|
|
||||||
const relationshipMatchPredicate = ({
|
const relationshipMatchPredicate = ({
|
||||||
row,
|
row,
|
||||||
|
@ -574,12 +599,12 @@ export class ExternalRequest<T extends Operation> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// finally cleanup anything that needs to be removed
|
// finally cleanup anything that needs to be removed
|
||||||
for (let [colName, { isMany, rows, tableId }] of Object.entries(related)) {
|
for (const [field, { isMany, rows, tableId }] of Object.entries(related)) {
|
||||||
const table: Table | undefined = this.getTable(tableId)
|
const table: Table | undefined = this.getTable(tableId)
|
||||||
// if it's not the foreign key skip it, nothing to do
|
// if it's not the foreign key skip it, nothing to do
|
||||||
if (
|
if (
|
||||||
!table ||
|
!table ||
|
||||||
(!isMany && table.primary && table.primary.indexOf(colName) !== -1)
|
(!isMany && table.primary && table.primary.indexOf(field) !== -1)
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -587,7 +612,7 @@ export class ExternalRequest<T extends Operation> {
|
||||||
const rowId = generateIdForRow(row, table)
|
const rowId = generateIdForRow(row, table)
|
||||||
const promise: Promise<any> = isMany
|
const promise: Promise<any> = isMany
|
||||||
? this.removeManyToManyRelationships(rowId, table)
|
? this.removeManyToManyRelationships(rowId, table)
|
||||||
: this.removeOneToManyRelationships(rowId, table, colName)
|
: this.removeOneToManyRelationships(rowId, table, field)
|
||||||
if (promise) {
|
if (promise) {
|
||||||
promises.push(promise)
|
promises.push(promise)
|
||||||
}
|
}
|
||||||
|
@ -599,23 +624,24 @@ export class ExternalRequest<T extends Operation> {
|
||||||
async removeRelationshipsToRow(table: Table, rowId: string) {
|
async removeRelationshipsToRow(table: Table, rowId: string) {
|
||||||
const row = await this.getRow(table, rowId)
|
const row = await this.getRow(table, rowId)
|
||||||
const related = await this.lookupRelations(table._id!, row)
|
const related = await this.lookupRelations(table._id!, row)
|
||||||
for (let column of Object.values(table.schema)) {
|
for (const column of Object.values(table.schema)) {
|
||||||
const relationshipColumn = column as RelationshipFieldMetadata
|
if (!isRelationshipColumn(column) || isOneToMany(column)) {
|
||||||
if (!isManyToOne(relationshipColumn)) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const { rows, isMany, tableId } = related[relationshipColumn.fieldName]
|
|
||||||
|
const relatedByTable = related[this.getLookupRelationsKey(column)]
|
||||||
|
if (!relatedByTable) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, isMany, tableId } = relatedByTable
|
||||||
const table = this.getTable(tableId)!
|
const table = this.getTable(tableId)!
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map(row => {
|
rows.map(row => {
|
||||||
const rowId = generateIdForRow(row, table)
|
const rowId = generateIdForRow(row, table)
|
||||||
return isMany
|
return isMany
|
||||||
? this.removeManyToManyRelationships(rowId, table)
|
? this.removeManyToManyRelationships(rowId, table)
|
||||||
: this.removeOneToManyRelationships(
|
: this.removeOneToManyRelationships(rowId, table, column.fieldName)
|
||||||
rowId,
|
|
||||||
table,
|
|
||||||
relationshipColumn.fieldName
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { User, Table, SearchFilters, Row } from "@budibase/types"
|
||||||
|
import { HttpMethod, MakeRequestResponse, generateMakeRequest } from "./utils"
|
||||||
|
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||||
|
import { Expectations } from "../../../../tests/utilities/api/base"
|
||||||
|
|
||||||
|
type RequestOpts = { internal?: boolean; appId?: string }
|
||||||
|
|
||||||
|
export interface PublicAPIExpectations {
|
||||||
|
status?: number
|
||||||
|
body?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicAPIRequest {
|
||||||
|
private makeRequest: MakeRequestResponse
|
||||||
|
private appId: string | undefined
|
||||||
|
|
||||||
|
tables: PublicTableAPI
|
||||||
|
rows: PublicRowAPI
|
||||||
|
apiKey: string
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
apiKey: string,
|
||||||
|
makeRequest: MakeRequestResponse,
|
||||||
|
appId?: string
|
||||||
|
) {
|
||||||
|
this.apiKey = apiKey
|
||||||
|
this.makeRequest = makeRequest
|
||||||
|
this.appId = appId
|
||||||
|
this.tables = new PublicTableAPI(this)
|
||||||
|
this.rows = new PublicRowAPI(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async init(config: TestConfiguration, user: User, opts?: RequestOpts) {
|
||||||
|
const apiKey = await config.generateApiKey(user._id)
|
||||||
|
const makeRequest = generateMakeRequest(apiKey, opts)
|
||||||
|
return new this(apiKey, makeRequest, opts?.appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts(opts: RequestOpts) {
|
||||||
|
if (opts.appId) {
|
||||||
|
this.appId = opts.appId
|
||||||
|
}
|
||||||
|
this.makeRequest = generateMakeRequest(this.apiKey, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
method: HttpMethod,
|
||||||
|
endpoint: string,
|
||||||
|
body?: any,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
) {
|
||||||
|
if (!this.makeRequest) {
|
||||||
|
throw new Error("Init has not been called")
|
||||||
|
}
|
||||||
|
const res = await this.makeRequest(method, endpoint, body, this.appId)
|
||||||
|
if (expectations?.status) {
|
||||||
|
expect(res.status).toEqual(expectations.status)
|
||||||
|
}
|
||||||
|
if (expectations?.body) {
|
||||||
|
expect(res.body).toEqual(expectations?.body)
|
||||||
|
}
|
||||||
|
return res.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicTableAPI {
|
||||||
|
request: PublicAPIRequest
|
||||||
|
|
||||||
|
constructor(request: PublicAPIRequest) {
|
||||||
|
this.request = request
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
table: Table,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
): Promise<{ data: Table }> {
|
||||||
|
return this.request.send("post", "/tables", table, expectations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicRowAPI {
|
||||||
|
request: PublicAPIRequest
|
||||||
|
|
||||||
|
constructor(request: PublicAPIRequest) {
|
||||||
|
this.request = request
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
tableId: string,
|
||||||
|
query: SearchFilters,
|
||||||
|
expectations?: PublicAPIExpectations
|
||||||
|
): Promise<{ data: Row[] }> {
|
||||||
|
return this.request.send(
|
||||||
|
"post",
|
||||||
|
`/tables/${tableId}/rows/search`,
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
expectations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
const setup = require("../../tests/utilities")
|
import * as setup from "../../tests/utilities"
|
||||||
|
|
||||||
describe("/metrics", () => {
|
describe("/metrics", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
|
@ -0,0 +1,71 @@
|
||||||
|
import * as setup from "../../tests/utilities"
|
||||||
|
import { roles } from "@budibase/backend-core"
|
||||||
|
import { basicTable } from "../../../../tests/utilities/structures"
|
||||||
|
import { Table, User } from "@budibase/types"
|
||||||
|
import { PublicAPIRequest } from "./Request"
|
||||||
|
|
||||||
|
describe("check public API security", () => {
|
||||||
|
const config = setup.getConfig()
|
||||||
|
let builderRequest: PublicAPIRequest,
|
||||||
|
appUserRequest: PublicAPIRequest,
|
||||||
|
table: Table,
|
||||||
|
appUser: User
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
const builderUser = await config.globalUser()
|
||||||
|
appUser = await config.globalUser({
|
||||||
|
builder: { global: false },
|
||||||
|
roles: {
|
||||||
|
[config.getProdAppId()]: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
builderRequest = await PublicAPIRequest.init(config, builderUser)
|
||||||
|
appUserRequest = await PublicAPIRequest.init(config, appUser)
|
||||||
|
table = (await builderRequest.tables.create(basicTable())).data
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow with builder API key", async () => {
|
||||||
|
const res = await builderRequest.rows.search(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(res.data.length).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should 403 when from browser, but API key", async () => {
|
||||||
|
await appUserRequest.rows.search(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should re-direct when using cookie", async () => {
|
||||||
|
const headers = await config.login({
|
||||||
|
userId: appUser._id!,
|
||||||
|
builder: false,
|
||||||
|
prodApp: false,
|
||||||
|
})
|
||||||
|
await config.withHeaders(
|
||||||
|
{
|
||||||
|
...headers,
|
||||||
|
"User-Agent": config.browserUserAgent(),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await config.api.row.search(
|
||||||
|
table._id!,
|
||||||
|
{ query: {} },
|
||||||
|
{
|
||||||
|
status: 302,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
|
@ -21,17 +21,19 @@ export type MakeRequestWithFormDataResponse = (
|
||||||
function base(
|
function base(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
intAppId: string | null,
|
opts?: {
|
||||||
isInternal: boolean
|
intAppId?: string
|
||||||
|
internal?: boolean
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const extraHeaders: any = {
|
const extraHeaders: any = {
|
||||||
"x-budibase-api-key": apiKey,
|
"x-budibase-api-key": apiKey,
|
||||||
}
|
}
|
||||||
if (intAppId) {
|
if (opts?.intAppId) {
|
||||||
extraHeaders["x-budibase-app-id"] = intAppId
|
extraHeaders["x-budibase-app-id"] = opts.intAppId
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = isInternal
|
const url = opts?.internal
|
||||||
? endpoint
|
? endpoint
|
||||||
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
||||||
return { headers: extraHeaders, url }
|
return { headers: extraHeaders, url }
|
||||||
|
@ -39,7 +41,7 @@ function base(
|
||||||
|
|
||||||
export function generateMakeRequest(
|
export function generateMakeRequest(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
isInternal = false
|
opts?: { internal?: boolean }
|
||||||
): MakeRequestResponse {
|
): MakeRequestResponse {
|
||||||
const request = setup.getRequest()!
|
const request = setup.getRequest()!
|
||||||
const config = setup.getConfig()!
|
const config = setup.getConfig()!
|
||||||
|
@ -47,9 +49,12 @@ export function generateMakeRequest(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
body?: any,
|
body?: any,
|
||||||
intAppId: string | null = config.getAppId()
|
intAppId: string | undefined = config.getAppId()
|
||||||
) => {
|
) => {
|
||||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
const { headers, url } = base(apiKey, endpoint, { ...opts, intAppId })
|
||||||
|
if (body && typeof body !== "string") {
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
}
|
||||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||||
if (body) {
|
if (body) {
|
||||||
req.send(body)
|
req.send(body)
|
||||||
|
@ -62,7 +67,7 @@ export function generateMakeRequest(
|
||||||
|
|
||||||
export function generateMakeRequestWithFormData(
|
export function generateMakeRequestWithFormData(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
isInternal = false
|
opts?: { internal?: boolean; browser?: boolean }
|
||||||
): MakeRequestWithFormDataResponse {
|
): MakeRequestWithFormDataResponse {
|
||||||
const request = setup.getRequest()!
|
const request = setup.getRequest()!
|
||||||
const config = setup.getConfig()!
|
const config = setup.getConfig()!
|
||||||
|
@ -70,9 +75,9 @@ export function generateMakeRequestWithFormData(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
fields: Record<string, string | { path: string }>,
|
fields: Record<string, string | { path: string }>,
|
||||||
intAppId: string | null = config.getAppId()
|
intAppId: string | undefined = config.getAppId()
|
||||||
) => {
|
) => {
|
||||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
const { headers, url } = base(apiKey, endpoint, { ...opts, intAppId })
|
||||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||||
for (let [field, value] of Object.entries(fields)) {
|
for (let [field, value] of Object.entries(fields)) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|
|
@ -9,9 +9,15 @@ import {
|
||||||
TRIGGER_DEFINITIONS,
|
TRIGGER_DEFINITIONS,
|
||||||
BUILTIN_ACTION_DEFINITIONS,
|
BUILTIN_ACTION_DEFINITIONS,
|
||||||
} from "../../../automations"
|
} from "../../../automations"
|
||||||
import { events } from "@budibase/backend-core"
|
import { configs, context, events } from "@budibase/backend-core"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import { Automation, FieldType, Table } from "@budibase/types"
|
import {
|
||||||
|
Automation,
|
||||||
|
ConfigType,
|
||||||
|
FieldType,
|
||||||
|
SettingsConfig,
|
||||||
|
Table,
|
||||||
|
} from "@budibase/types"
|
||||||
import { mocks } from "@budibase/backend-core/tests"
|
import { mocks } from "@budibase/backend-core/tests"
|
||||||
import { FilterConditions } from "../../../automations/steps/filter"
|
import { FilterConditions } from "../../../automations/steps/filter"
|
||||||
import { removeDeprecated } from "../../../automations/utils"
|
import { removeDeprecated } from "../../../automations/utils"
|
||||||
|
@ -39,8 +45,7 @@ describe("/automations", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// @ts-ignore
|
jest.clearAllMocks()
|
||||||
events.automation.deleted.mockClear()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("get definitions", () => {
|
describe("get definitions", () => {
|
||||||
|
@ -244,6 +249,59 @@ describe("/automations", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("run", () => {
|
||||||
|
let oldConfig: SettingsConfig
|
||||||
|
beforeAll(async () => {
|
||||||
|
await context.doInTenant(config.getTenantId(), async () => {
|
||||||
|
oldConfig = await configs.getSettingsConfigDoc()
|
||||||
|
|
||||||
|
const settings: SettingsConfig = {
|
||||||
|
_id: oldConfig._id,
|
||||||
|
_rev: oldConfig._rev,
|
||||||
|
type: ConfigType.SETTINGS,
|
||||||
|
config: {
|
||||||
|
platformUrl: "https://example.com",
|
||||||
|
logoUrl: "https://example.com/logo.png",
|
||||||
|
company: "Test Company",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const saved = await configs.save(settings)
|
||||||
|
oldConfig._rev = saved.rev
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await context.doInTenant(config.getTenantId(), async () => {
|
||||||
|
await configs.save(oldConfig)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to access platformUrl, logoUrl and company in the automation", async () => {
|
||||||
|
const result = await createAutomationBuilder({
|
||||||
|
name: "Test Automation",
|
||||||
|
appId: config.getAppId(),
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
.appAction({ fields: {} })
|
||||||
|
.serverLog({
|
||||||
|
text: "{{ settings.url }}",
|
||||||
|
})
|
||||||
|
.serverLog({
|
||||||
|
text: "{{ settings.logo }}",
|
||||||
|
})
|
||||||
|
.serverLog({
|
||||||
|
text: "{{ settings.company }}",
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
expect(result.steps[0].outputs.message).toEndWith("https://example.com")
|
||||||
|
expect(result.steps[1].outputs.message).toEndWith(
|
||||||
|
"https://example.com/logo.png"
|
||||||
|
)
|
||||||
|
expect(result.steps[2].outputs.message).toEndWith("Test Company")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("test", () => {
|
describe("test", () => {
|
||||||
it("tests the automation successfully", async () => {
|
it("tests the automation successfully", async () => {
|
||||||
let table = await config.createTable()
|
let table = await config.createTable()
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
const setup = require("./utilities")
|
import * as setup from "./utilities"
|
||||||
const { basicScreen, powerScreen } = setup.structures
|
import { checkBuilderEndpoint, runInProd } from "./utilities/TestFunctions"
|
||||||
const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions")
|
import { roles } from "@budibase/backend-core"
|
||||||
const { roles } = require("@budibase/backend-core")
|
import { Screen } from "@budibase/types"
|
||||||
const { BUILTIN_ROLE_IDS } = roles
|
|
||||||
|
|
||||||
|
const { BUILTIN_ROLE_IDS } = roles
|
||||||
|
const { basicScreen, powerScreen } = setup.structures
|
||||||
const route = "/test"
|
const route = "/test"
|
||||||
|
|
||||||
// there are checks which are disabled in test env,
|
// there are checks which are disabled in test env,
|
||||||
|
@ -12,7 +13,7 @@ const route = "/test"
|
||||||
describe("/routing", () => {
|
describe("/routing", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
||||||
let config = setup.getConfig()
|
let config = setup.getConfig()
|
||||||
let basic, power
|
let basic: Screen, power: Screen
|
||||||
|
|
||||||
afterAll(setup.afterAll)
|
afterAll(setup.afterAll)
|
||||||
|
|
||||||
|
@ -25,26 +26,40 @@ describe("/routing", () => {
|
||||||
|
|
||||||
describe("fetch", () => {
|
describe("fetch", () => {
|
||||||
it("prevents a public user from accessing development app", async () => {
|
it("prevents a public user from accessing development app", async () => {
|
||||||
await runInProd(() => {
|
await config.withHeaders(
|
||||||
return request
|
{
|
||||||
.get(`/api/routing/client`)
|
"User-Agent": config.browserUserAgent(),
|
||||||
.set(config.publicHeaders({ prodApp: false }))
|
},
|
||||||
.expect(302)
|
async () => {
|
||||||
})
|
await runInProd(() => {
|
||||||
|
return request
|
||||||
|
.get(`/api/routing/client`)
|
||||||
|
.set(config.publicHeaders({ prodApp: false }))
|
||||||
|
.expect(302)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("prevents a non builder from accessing development app", async () => {
|
it("prevents a non builder from accessing development app", async () => {
|
||||||
await runInProd(async () => {
|
await config.withHeaders(
|
||||||
return request
|
{
|
||||||
.get(`/api/routing/client`)
|
"User-Agent": config.browserUserAgent(),
|
||||||
.set(
|
},
|
||||||
await config.roleHeaders({
|
async () => {
|
||||||
roleId: BUILTIN_ROLE_IDS.BASIC,
|
await runInProd(async () => {
|
||||||
prodApp: false,
|
return request
|
||||||
})
|
.get(`/api/routing/client`)
|
||||||
)
|
.set(
|
||||||
.expect(302)
|
await config.roleHeaders({
|
||||||
})
|
roleId: BUILTIN_ROLE_IDS.BASIC,
|
||||||
|
prodApp: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.expect(302)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
it("returns the correct routing for basic user", async () => {
|
it("returns the correct routing for basic user", async () => {
|
||||||
const res = await request
|
const res = await request
|
|
@ -763,12 +763,25 @@ describe.each([
|
||||||
expect(row.food).toEqual(["apple", "orange"])
|
expect(row.food).toEqual(["apple", "orange"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("creates a new row with a default value when given an empty list", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, { food: [] })
|
||||||
|
expect(row.food).toEqual(["apple", "orange"])
|
||||||
|
})
|
||||||
|
|
||||||
it("does not use default value if value specified", async () => {
|
it("does not use default value if value specified", async () => {
|
||||||
const row = await config.api.row.save(table._id!, {
|
const row = await config.api.row.save(table._id!, {
|
||||||
food: ["orange"],
|
food: ["orange"],
|
||||||
})
|
})
|
||||||
expect(row.food).toEqual(["orange"])
|
expect(row.food).toEqual(["orange"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("resets back to its default value when empty", async () => {
|
||||||
|
let row = await config.api.row.save(table._id!, {
|
||||||
|
food: ["orange"],
|
||||||
|
})
|
||||||
|
row = await config.api.row.save(table._id!, { ...row, food: [] })
|
||||||
|
expect(row.food).toEqual(["apple", "orange"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("user column", () => {
|
describe("user column", () => {
|
||||||
|
@ -952,6 +965,105 @@ describe.each([
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
!isLucene &&
|
||||||
|
describe("relations to same table", () => {
|
||||||
|
let relatedRows: Row[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const relatedTable = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const relatedTableId = relatedTable._id!
|
||||||
|
table = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
related1: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related1",
|
||||||
|
fieldName: "main1",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
related2: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related2",
|
||||||
|
fieldName: "main2",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
relatedRows = await Promise.all([
|
||||||
|
config.api.row.save(relatedTableId, { name: "foo" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "bar" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "baz" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "boo" }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can create rows with both relationships", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [relatedRows[0]._id!],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "test",
|
||||||
|
related1: [
|
||||||
|
{
|
||||||
|
_id: relatedRows[0]._id,
|
||||||
|
primaryDisplay: relatedRows[0].name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
related2: [
|
||||||
|
{
|
||||||
|
_id: relatedRows[1]._id,
|
||||||
|
primaryDisplay: relatedRows[1].name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can create rows with no relationships", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row.related1).toBeUndefined()
|
||||||
|
expect(row.related2).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can create rows with only one relationships field", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "test",
|
||||||
|
related2: [
|
||||||
|
{
|
||||||
|
_id: relatedRows[1]._id,
|
||||||
|
primaryDisplay: relatedRows[1].name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(row.related1).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
|
@ -1054,6 +1166,134 @@ describe.each([
|
||||||
const rows = await config.api.row.fetch(table._id!)
|
const rows = await config.api.row.fetch(table._id!)
|
||||||
expect(rows).toHaveLength(1)
|
expect(rows).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
!isLucene &&
|
||||||
|
describe("relations to same table", () => {
|
||||||
|
let relatedRows: Row[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const relatedTable = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const relatedTableId = relatedTable._id!
|
||||||
|
table = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
related1: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related1",
|
||||||
|
fieldName: "main1",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
related2: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related2",
|
||||||
|
fieldName: "main2",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
relatedRows = await Promise.all([
|
||||||
|
config.api.row.save(relatedTableId, { name: "foo" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "bar" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "baz" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "boo" }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can edit rows with both relationships", async () => {
|
||||||
|
let row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [relatedRows[0]._id!],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
row = await config.api.row.save(table._id!, {
|
||||||
|
...row,
|
||||||
|
related1: [relatedRows[0]._id!, relatedRows[1]._id!],
|
||||||
|
related2: [relatedRows[2]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "test",
|
||||||
|
related1: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
_id: relatedRows[0]._id,
|
||||||
|
primaryDisplay: relatedRows[0].name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: relatedRows[1]._id,
|
||||||
|
primaryDisplay: relatedRows[1].name,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
related2: [
|
||||||
|
{
|
||||||
|
_id: relatedRows[2]._id,
|
||||||
|
primaryDisplay: relatedRows[2].name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can drop existing relationship", async () => {
|
||||||
|
let row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [relatedRows[0]._id!],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
row = await config.api.row.save(table._id!, {
|
||||||
|
...row,
|
||||||
|
related1: [],
|
||||||
|
related2: [relatedRows[2]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "test",
|
||||||
|
related2: [
|
||||||
|
{
|
||||||
|
_id: relatedRows[2]._id,
|
||||||
|
primaryDisplay: relatedRows[2].name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(row.related1).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can drop both relationships", async () => {
|
||||||
|
let row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [relatedRows[0]._id!],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
row = await config.api.row.save(table._id!, {
|
||||||
|
...row,
|
||||||
|
related1: [],
|
||||||
|
related2: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(row).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "test",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(row.related1).toBeUndefined()
|
||||||
|
expect(row.related2).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("patch", () => {
|
describe("patch", () => {
|
||||||
|
@ -1330,6 +1570,73 @@ describe.each([
|
||||||
)
|
)
|
||||||
expect(res.length).toEqual(2)
|
expect(res.length).toEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
!isLucene &&
|
||||||
|
describe("relations to same table", () => {
|
||||||
|
let relatedRows: Row[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const relatedTable = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const relatedTableId = relatedTable._id!
|
||||||
|
table = await config.api.table.save(
|
||||||
|
defaultTable({
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
related1: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related1",
|
||||||
|
fieldName: "main1",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
related2: {
|
||||||
|
type: FieldType.LINK,
|
||||||
|
name: "related2",
|
||||||
|
fieldName: "main2",
|
||||||
|
tableId: relatedTableId,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
relatedRows = await Promise.all([
|
||||||
|
config.api.row.save(relatedTableId, { name: "foo" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "bar" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "baz" }),
|
||||||
|
config.api.row.save(relatedTableId, { name: "boo" }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can delete rows with both relationships", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [relatedRows[0]._id!],
|
||||||
|
related2: [relatedRows[1]._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.api.row.delete(table._id!, { _id: row._id! })
|
||||||
|
|
||||||
|
await config.api.row.get(table._id!, row._id!, { status: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can delete rows with empty relationships", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
|
related1: [],
|
||||||
|
related2: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.api.row.delete(table._id!, { _id: row._id! })
|
||||||
|
|
||||||
|
await config.api.row.get(table._id!, row._id!, { status: 404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("validate", () => {
|
describe("validate", () => {
|
||||||
|
@ -2796,7 +3103,7 @@ describe.each([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
["from original saved row", (row: Row) => row],
|
["from original saved row", (row: Row) => row],
|
||||||
["from updated row", (row: Row) => config.api.row.save(viewId, row)],
|
["from updated row", (row: Row) => config.api.row.save(viewId, row)],
|
||||||
]
|
]
|
||||||
|
|
||||||
it.each(testScenarios)(
|
it.each(testScenarios)(
|
||||||
|
|
|
@ -2268,58 +2268,118 @@ describe.each([
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("calculation views", () => {
|
!isLucene &&
|
||||||
it("should not remove calculation columns when modifying table schema", async () => {
|
describe("calculation views", () => {
|
||||||
let table = await config.api.table.save(
|
it("should not remove calculation columns when modifying table schema", async () => {
|
||||||
saveTableRequest({
|
let table = await config.api.table.save(
|
||||||
schema: {
|
saveTableRequest({
|
||||||
name: {
|
schema: {
|
||||||
name: "name",
|
name: {
|
||||||
type: FieldType.STRING,
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
name: "age",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
age: {
|
})
|
||||||
name: "age",
|
)
|
||||||
type: FieldType.NUMBER,
|
|
||||||
|
let view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
type: ViewV2Type.CALCULATION,
|
||||||
|
schema: {
|
||||||
|
sum: {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.SUM,
|
||||||
|
field: "age",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
||||||
let view = await config.api.viewV2.create({
|
table = await config.api.table.get(table._id!)
|
||||||
tableId: table._id!,
|
await config.api.table.save({
|
||||||
name: generator.guid(),
|
...table,
|
||||||
type: ViewV2Type.CALCULATION,
|
schema: {
|
||||||
schema: {
|
...table.schema,
|
||||||
sum: {
|
name: {
|
||||||
visible: true,
|
name: "name",
|
||||||
calculationType: CalculationType.SUM,
|
type: FieldType.STRING,
|
||||||
field: "age",
|
constraints: { presence: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
|
|
||||||
|
view = await config.api.viewV2.get(view.id)
|
||||||
|
expect(Object.keys(view.schema!).sort()).toEqual([
|
||||||
|
"age",
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"sum",
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
table = await config.api.table.get(table._id!)
|
describe("bigints", () => {
|
||||||
await config.api.table.save({
|
let table: Table
|
||||||
...table,
|
let view: ViewV2
|
||||||
schema: {
|
|
||||||
...table.schema,
|
|
||||||
name: {
|
|
||||||
name: "name",
|
|
||||||
type: FieldType.STRING,
|
|
||||||
constraints: { presence: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
view = await config.api.viewV2.get(view.id)
|
beforeEach(async () => {
|
||||||
expect(Object.keys(view.schema!).sort()).toEqual([
|
table = await config.api.table.save(
|
||||||
"age",
|
saveTableRequest({
|
||||||
"id",
|
schema: {
|
||||||
"name",
|
bigint: {
|
||||||
"sum",
|
name: "bigint",
|
||||||
])
|
type: FieldType.BIGINT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
type: ViewV2Type.CALCULATION,
|
||||||
|
schema: {
|
||||||
|
sum: {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.SUM,
|
||||||
|
field: "bigint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not lose precision handling ints larger than JSs int53", async () => {
|
||||||
|
// The sum of the following 3 numbers cannot be represented by
|
||||||
|
// JavaScripts default int53 datatype for numbers, so this is a test
|
||||||
|
// that makes sure we aren't losing precision between the DB and the
|
||||||
|
// user.
|
||||||
|
await config.api.row.bulkImport(table._id!, {
|
||||||
|
rows: [
|
||||||
|
{ bigint: "1000000000000000000" },
|
||||||
|
{ bigint: "123" },
|
||||||
|
{ bigint: "321" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.row.search(view.id)
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].sum).toEqual("1000000000000000444")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to handle up to 2**63 - 1 bigints", async () => {
|
||||||
|
await config.api.row.bulkImport(table._id!, {
|
||||||
|
rows: [{ bigint: "9223372036854775806" }, { bigint: "1" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.row.search(view.id)
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].sum).toEqual("9223372036854775807")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("row operations", () => {
|
describe("row operations", () => {
|
||||||
|
|
|
@ -225,7 +225,9 @@ class AutomationBuilder extends BaseStepBuilder {
|
||||||
private triggerOutputs: any
|
private triggerOutputs: any
|
||||||
private triggerSet: boolean = false
|
private triggerSet: boolean = false
|
||||||
|
|
||||||
constructor(options: { name?: string; appId?: string } = {}) {
|
constructor(
|
||||||
|
options: { name?: string; appId?: string; config?: TestConfiguration } = {}
|
||||||
|
) {
|
||||||
super()
|
super()
|
||||||
this.automationConfig = {
|
this.automationConfig = {
|
||||||
name: options.name || `Test Automation ${uuidv4()}`,
|
name: options.name || `Test Automation ${uuidv4()}`,
|
||||||
|
@ -237,7 +239,7 @@ class AutomationBuilder extends BaseStepBuilder {
|
||||||
type: "automation",
|
type: "automation",
|
||||||
appId: options.appId ?? setup.getConfig().getAppId(),
|
appId: options.appId ?? setup.getConfig().getAppId(),
|
||||||
}
|
}
|
||||||
this.config = setup.getConfig()
|
this.config = options.config || setup.getConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRIGGERS
|
// TRIGGERS
|
||||||
|
@ -347,6 +349,7 @@ class AutomationBuilder extends BaseStepBuilder {
|
||||||
export function createAutomationBuilder(options?: {
|
export function createAutomationBuilder(options?: {
|
||||||
name?: string
|
name?: string
|
||||||
appId?: string
|
appId?: string
|
||||||
|
config?: TestConfiguration
|
||||||
}) {
|
}) {
|
||||||
return new AutomationBuilder(options)
|
return new AutomationBuilder(options)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,4 +20,9 @@ export interface AutomationContext extends AutomationResults {
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
user?: UserBindings
|
user?: UserBindings
|
||||||
trigger: any
|
trigger: any
|
||||||
|
settings?: {
|
||||||
|
url?: string
|
||||||
|
logo?: string
|
||||||
|
company?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,11 +277,14 @@ export interface components {
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
| "ai"
|
||||||
| "json"
|
| "json"
|
||||||
| "internal"
|
| "internal"
|
||||||
| "barcodeqr"
|
| "barcodeqr"
|
||||||
|
| "signature_single"
|
||||||
| "bigint"
|
| "bigint"
|
||||||
| "bb_reference";
|
| "bb_reference"
|
||||||
|
| "bb_reference_single";
|
||||||
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
||||||
constraints?: {
|
constraints?: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
@ -386,11 +389,14 @@ export interface components {
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
| "ai"
|
||||||
| "json"
|
| "json"
|
||||||
| "internal"
|
| "internal"
|
||||||
| "barcodeqr"
|
| "barcodeqr"
|
||||||
|
| "signature_single"
|
||||||
| "bigint"
|
| "bigint"
|
||||||
| "bb_reference";
|
| "bb_reference"
|
||||||
|
| "bb_reference_single";
|
||||||
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
||||||
constraints?: {
|
constraints?: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
@ -497,11 +503,14 @@ export interface components {
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
| "ai"
|
||||||
| "json"
|
| "json"
|
||||||
| "internal"
|
| "internal"
|
||||||
| "barcodeqr"
|
| "barcodeqr"
|
||||||
|
| "signature_single"
|
||||||
| "bigint"
|
| "bigint"
|
||||||
| "bb_reference";
|
| "bb_reference"
|
||||||
|
| "bb_reference_single";
|
||||||
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
/** @description A constraint can be applied to the column which will be validated against when a row is saved. */
|
||||||
constraints?: {
|
constraints?: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
import { generateUserMetadataID, isDevAppID } from "../db/utils"
|
import { generateUserMetadataID, isDevAppID } from "../db/utils"
|
||||||
import { getCachedSelf } from "../utilities/global"
|
import { getCachedSelf } from "../utilities/global"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { isWebhookEndpoint } from "./utils"
|
import { isWebhookEndpoint, isBrowser, isApiKey } from "./utils"
|
||||||
import { UserCtx, ContextUser } from "@budibase/types"
|
import { UserCtx, ContextUser } from "@budibase/types"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ export default async (ctx: UserCtx, next: any) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// deny access to application preview
|
// deny access to application preview
|
||||||
if (!env.isTest()) {
|
if (isBrowser(ctx) && !isApiKey(ctx)) {
|
||||||
if (
|
if (
|
||||||
isDevAppID(requestAppId) &&
|
isDevAppID(requestAppId) &&
|
||||||
!isWebhookEndpoint(ctx) &&
|
!isWebhookEndpoint(ctx) &&
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
require("../../db").init()
|
import * as db from "../../db"
|
||||||
|
|
||||||
|
db.init()
|
||||||
mockAuthWithNoCookie()
|
mockAuthWithNoCookie()
|
||||||
mockWorker()
|
mockWorker()
|
||||||
mockUserGroups()
|
mockUserGroups()
|
||||||
|
@ -45,7 +47,7 @@ function mockAuthWithNoCookie() {
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
user: {
|
user: {
|
||||||
getUser: async id => {
|
getUser: async () => {
|
||||||
return {
|
return {
|
||||||
_id: "us_uuid1",
|
_id: "us_uuid1",
|
||||||
}
|
}
|
||||||
|
@ -82,7 +84,7 @@ function mockAuthWithCookie() {
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
user: {
|
user: {
|
||||||
getUser: async id => {
|
getUser: async () => {
|
||||||
return {
|
return {
|
||||||
_id: "us_uuid1",
|
_id: "us_uuid1",
|
||||||
}
|
}
|
||||||
|
@ -94,6 +96,10 @@ function mockAuthWithCookie() {
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestConfiguration {
|
class TestConfiguration {
|
||||||
|
next: jest.MockedFunction<any>
|
||||||
|
throw: jest.MockedFunction<any>
|
||||||
|
ctx: any
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.next = jest.fn()
|
this.next = jest.fn()
|
||||||
this.throw = jest.fn()
|
this.throw = jest.fn()
|
||||||
|
@ -130,7 +136,7 @@ class TestConfiguration {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Current app middleware", () => {
|
describe("Current app middleware", () => {
|
||||||
let config
|
let config: TestConfiguration
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
config = new TestConfiguration()
|
config = new TestConfiguration()
|
||||||
|
@ -192,7 +198,7 @@ describe("Current app middleware", () => {
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
user: {
|
user: {
|
||||||
getUser: async id => {
|
getUser: async () => {
|
||||||
return {
|
return {
|
||||||
_id: "us_uuid1",
|
_id: "us_uuid1",
|
||||||
}
|
}
|
|
@ -1,9 +1,18 @@
|
||||||
import { BBContext } from "@budibase/types"
|
import { LoginMethod, UserCtx } from "@budibase/types"
|
||||||
|
|
||||||
const WEBHOOK_ENDPOINTS = new RegExp(
|
const WEBHOOK_ENDPOINTS = new RegExp(
|
||||||
["webhooks/trigger", "webhooks/schema"].join("|")
|
["webhooks/trigger", "webhooks/schema"].join("|")
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isWebhookEndpoint(ctx: BBContext) {
|
export function isWebhookEndpoint(ctx: UserCtx) {
|
||||||
return WEBHOOK_ENDPOINTS.test(ctx.request.url)
|
return WEBHOOK_ENDPOINTS.test(ctx.request.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBrowser(ctx: UserCtx) {
|
||||||
|
const browser = ctx.userAgent?.browser
|
||||||
|
return browser && browser !== "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiKey(ctx: UserCtx) {
|
||||||
|
return ctx.loginMethod === LoginMethod.API_KEY
|
||||||
|
}
|
||||||
|
|
|
@ -423,6 +423,7 @@ export default class TestConfiguration {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Cookie: [`${constants.Cookie.Auth}=${authToken}`],
|
Cookie: [`${constants.Cookie.Auth}=${authToken}`],
|
||||||
[constants.Header.APP_ID]: appId,
|
[constants.Header.APP_ID]: appId,
|
||||||
|
...this.temporaryHeaders,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -527,6 +528,10 @@ export default class TestConfiguration {
|
||||||
return this.login({ userId: email, roleId, builder, prodApp })
|
return this.login({ userId: email, roleId, builder, prodApp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
browserUserAgent() {
|
||||||
|
return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
// TENANCY
|
// TENANCY
|
||||||
|
|
||||||
tenantHost() {
|
tenantHost() {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { AutomationContext, TriggerOutput } from "../definitions/automations"
|
import { AutomationContext, TriggerOutput } from "../definitions/automations"
|
||||||
import { WorkerCallback } from "./definitions"
|
import { WorkerCallback } from "./definitions"
|
||||||
import { context, logging } from "@budibase/backend-core"
|
import { context, logging, configs } from "@budibase/backend-core"
|
||||||
import { processObject, processStringSync } from "@budibase/string-templates"
|
import { processObject, processStringSync } from "@budibase/string-templates"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { performance } from "perf_hooks"
|
import { performance } from "perf_hooks"
|
||||||
|
@ -263,6 +263,18 @@ class Orchestrator {
|
||||||
this.context.env = await sdkUtils.getEnvironmentVariables()
|
this.context.env = await sdkUtils.getEnvironmentVariables()
|
||||||
this.context.user = this.currentUser
|
this.context.user = this.currentUser
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { config } = await configs.getSettingsConfigDoc()
|
||||||
|
this.context.settings = {
|
||||||
|
url: config.platformUrl,
|
||||||
|
logo: config.logoUrl,
|
||||||
|
company: config.company,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// if settings doc doesn't exist, make the settings blank
|
||||||
|
this.context.settings = {}
|
||||||
|
}
|
||||||
|
|
||||||
let metadata
|
let metadata
|
||||||
|
|
||||||
// check if this is a recurring automation,
|
// check if this is a recurring automation,
|
||||||
|
|
|
@ -134,7 +134,12 @@ async function processDefaultValues(table: Table, row: Row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, schema] of Object.entries(table.schema)) {
|
for (const [key, schema] of Object.entries(table.schema)) {
|
||||||
if ("default" in schema && schema.default != null && row[key] == null) {
|
const isEmpty =
|
||||||
|
row[key] == null ||
|
||||||
|
row[key] === "" ||
|
||||||
|
(Array.isArray(row[key]) && row[key].length === 0)
|
||||||
|
|
||||||
|
if ("default" in schema && schema.default != null && isEmpty) {
|
||||||
let processed: string | string[]
|
let processed: string | string[]
|
||||||
if (Array.isArray(schema.default)) {
|
if (Array.isArray(schema.default)) {
|
||||||
processed = schema.default.map(val => processStringSync(val, ctx))
|
processed = schema.default.map(val => processStringSync(val, ctx))
|
||||||
|
@ -440,19 +445,26 @@ export async function coreOutputProcessing(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sdk.views.isView(source)) {
|
if (sdk.views.isView(source)) {
|
||||||
const calculationFields = Object.keys(
|
// We ensure calculation fields are returned as numbers. During the
|
||||||
helpers.views.calculationFields(source)
|
|
||||||
)
|
|
||||||
|
|
||||||
// We ensure all calculation fields are returned as numbers. During the
|
|
||||||
// testing of this feature it was discovered that the COUNT operation
|
// testing of this feature it was discovered that the COUNT operation
|
||||||
// returns a string for MySQL, MariaDB, and Postgres. But given that all
|
// returns a string for MySQL, MariaDB, and Postgres. But given that all
|
||||||
// calculation fields should be numbers, we blanket make sure of that
|
// calculation fields (except ones operating on BIGINTs) should be
|
||||||
// here.
|
// numbers, we blanket make sure of that here.
|
||||||
for (const key of calculationFields) {
|
for (const [name, field] of Object.entries(
|
||||||
|
helpers.views.calculationFields(source)
|
||||||
|
)) {
|
||||||
|
if ("field" in field) {
|
||||||
|
const targetSchema = table.schema[field.field]
|
||||||
|
// We don't convert BIGINT fields to floats because we could lose
|
||||||
|
// precision.
|
||||||
|
if (targetSchema.type === FieldType.BIGINT) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (typeof row[key] === "string") {
|
if (typeof row[name] === "string") {
|
||||||
row[key] = parseFloat(row[key])
|
row[name] = parseFloat(row[name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,8 @@
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/redlock": "4.0.7",
|
"@types/redlock": "4.0.7",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"typescript": "5.5.2"
|
"typescript": "5.5.2",
|
||||||
|
"koa-useragent": "^4.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scim-patch": "^0.8.1"
|
"scim-patch": "^0.8.1"
|
||||||
|
|
|
@ -15,7 +15,10 @@ export interface UserDetails {
|
||||||
|
|
||||||
export interface BulkUserRequest {
|
export interface BulkUserRequest {
|
||||||
delete?: {
|
delete?: {
|
||||||
userIds: string[]
|
users: Array<{
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
create?: {
|
create?: {
|
||||||
roles?: any[]
|
roles?: any[]
|
||||||
|
|
|
@ -7,4 +7,3 @@ export * from "./schedule"
|
||||||
export * from "./templates"
|
export * from "./templates"
|
||||||
export * from "./environmentVariables"
|
export * from "./environmentVariables"
|
||||||
export * from "./auditLogs"
|
export * from "./auditLogs"
|
||||||
export * from "./tenantInfo"
|
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
import { Hosting } from "../../sdk"
|
|
||||||
import { Document } from "../document"
|
|
||||||
|
|
||||||
export interface TenantInfo extends Document {
|
|
||||||
owner: {
|
|
||||||
email: string
|
|
||||||
password?: string
|
|
||||||
ssoId?: string
|
|
||||||
givenName?: string
|
|
||||||
familyName?: string
|
|
||||||
budibaseUserId?: string
|
|
||||||
}
|
|
||||||
tenantId: string
|
|
||||||
hosting: Hosting
|
|
||||||
}
|
|
|
@ -38,6 +38,11 @@ export function isSSOUser(user: User): user is SSOUser {
|
||||||
|
|
||||||
// USER
|
// USER
|
||||||
|
|
||||||
|
export interface UserIdentifier {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface User extends Document {
|
export interface User extends Document {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
email: string
|
email: string
|
||||||
|
|
|
@ -12,6 +12,12 @@ import {
|
||||||
import { FeatureFlag, License } from "../sdk"
|
import { FeatureFlag, License } from "../sdk"
|
||||||
import { Files } from "formidable"
|
import { Files } from "formidable"
|
||||||
import { EventType } from "../core"
|
import { EventType } from "../core"
|
||||||
|
import { UserAgentContext } from "koa-useragent"
|
||||||
|
|
||||||
|
export enum LoginMethod {
|
||||||
|
API_KEY = "api_key",
|
||||||
|
COOKIE = "cookie",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContextUser extends Omit<User, "roles"> {
|
export interface ContextUser extends Omit<User, "roles"> {
|
||||||
globalId?: string
|
globalId?: string
|
||||||
|
@ -41,6 +47,7 @@ export interface BBRequest<RequestBody> extends Request {
|
||||||
export interface Ctx<RequestBody = any, ResponseBody = any> extends Context {
|
export interface Ctx<RequestBody = any, ResponseBody = any> extends Context {
|
||||||
request: BBRequest<RequestBody>
|
request: BBRequest<RequestBody>
|
||||||
body: ResponseBody
|
body: ResponseBody
|
||||||
|
userAgent: UserAgentContext["userAgent"]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,6 +58,7 @@ export interface UserCtx<RequestBody = any, ResponseBody = any>
|
||||||
user: ContextUser
|
user: ContextUser
|
||||||
roleId?: string
|
roleId?: string
|
||||||
eventEmitter?: ContextEmitter
|
eventEmitter?: ContextEmitter
|
||||||
|
loginMethod?: LoginMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { tenancy } from "@budibase/backend-core"
|
|
||||||
import { TenantInfo, Ctx } from "@budibase/types"
|
|
||||||
|
|
||||||
export const save = async (ctx: Ctx<TenantInfo>) => {
|
|
||||||
const response = await tenancy.saveTenantInfo(ctx.request.body)
|
|
||||||
ctx.body = {
|
|
||||||
_id: response.id,
|
|
||||||
_rev: response.rev,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const get = async (ctx: Ctx) => {
|
|
||||||
ctx.body = await tenancy.getTenantInfo(ctx.params.id)
|
|
||||||
}
|
|
|
@ -23,9 +23,11 @@ import {
|
||||||
SearchUsersRequest,
|
SearchUsersRequest,
|
||||||
User,
|
User,
|
||||||
UserCtx,
|
UserCtx,
|
||||||
|
UserIdentifier,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
|
users,
|
||||||
cache,
|
cache,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
events,
|
events,
|
||||||
|
@ -55,8 +57,8 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
|
||||||
const requestUser = ctx.request.body
|
const requestUser = ctx.request.body
|
||||||
|
|
||||||
// Do not allow the account holder role to be changed
|
// Do not allow the account holder role to be changed
|
||||||
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId)
|
const accountMetadata = await users.getExistingAccounts([requestUser.email])
|
||||||
if (tenantInfo?.owner.email === requestUser.email) {
|
if (accountMetadata?.length > 0) {
|
||||||
if (
|
if (
|
||||||
requestUser.admin?.global !== true ||
|
requestUser.admin?.global !== true ||
|
||||||
requestUser.builder?.global !== true
|
requestUser.builder?.global !== true
|
||||||
|
@ -103,11 +105,14 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkDelete = async (userIds: string[], currentUserId: string) => {
|
const bulkDelete = async (
|
||||||
if (userIds?.indexOf(currentUserId) !== -1) {
|
users: Array<UserIdentifier>,
|
||||||
|
currentUserId: string
|
||||||
|
) => {
|
||||||
|
if (users.find(u => u.userId === currentUserId)) {
|
||||||
throw new Error("Unable to delete self.")
|
throw new Error("Unable to delete self.")
|
||||||
}
|
}
|
||||||
return await userSdk.db.bulkDelete(userIds)
|
return await userSdk.db.bulkDelete(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkCreate = async (users: User[], groupIds: string[]) => {
|
const bulkCreate = async (users: User[], groupIds: string[]) => {
|
||||||
|
@ -130,7 +135,7 @@ export const bulkUpdate = async (
|
||||||
created = await bulkCreate(input.create.users, input.create.groups)
|
created = await bulkCreate(input.create.users, input.create.groups)
|
||||||
}
|
}
|
||||||
if (input.delete) {
|
if (input.delete) {
|
||||||
deleted = await bulkDelete(input.delete.userIds, currentUserId)
|
deleted = await bulkDelete(input.delete.users, currentUserId)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.throw(err.status || 400, err?.message || err)
|
ctx.throw(err.status || 400, err?.message || err)
|
||||||
|
@ -302,6 +307,23 @@ export const tenantUserLookup = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will be paginated to a default of the first 50 users,
|
||||||
|
* So the account holder may not be found until further pagination has occurred
|
||||||
|
*/
|
||||||
|
export const accountHolderLookup = async (ctx: Ctx) => {
|
||||||
|
const users = await userSdk.core.getAllUsers()
|
||||||
|
const response = await userSdk.core.getExistingAccounts(
|
||||||
|
users.map(u => u.email)
|
||||||
|
)
|
||||||
|
const holder = response[0]
|
||||||
|
if (!holder) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holder._id = users.find(u => u.email === holder.email)?._id
|
||||||
|
ctx.body = holder
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Encapsulate the app user onboarding flows here.
|
Encapsulate the app user onboarding flows here.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -71,10 +71,6 @@ const PUBLIC_ENDPOINTS = [
|
||||||
route: "/api/global/users/invite",
|
route: "/api/global/users/invite",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
route: "/api/global/tenant",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const NO_TENANCY_ENDPOINTS = [
|
const NO_TENANCY_ENDPOINTS = [
|
||||||
|
@ -121,11 +117,7 @@ const NO_TENANCY_ENDPOINTS = [
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: "/api/global/tenant",
|
route: "/api/global/users/accountholder",
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: "/api/global/tenant/:id",
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
import Router from "@koa/router"
|
|
||||||
import * as controller from "../../controllers/global/tenant"
|
|
||||||
import cloudRestricted from "../../../middleware/cloudRestricted"
|
|
||||||
|
|
||||||
const router: Router = new Router()
|
|
||||||
|
|
||||||
router
|
|
||||||
.post("/api/global/tenant", cloudRestricted, controller.save)
|
|
||||||
.get("/api/global/tenant/:id", controller.get)
|
|
||||||
|
|
||||||
export default router
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { Hosting, TenantInfo } from "@budibase/types"
|
|
||||||
import { TestConfiguration } from "../../../../tests"
|
|
||||||
import { tenancy as _tenancy } from "@budibase/backend-core"
|
|
||||||
|
|
||||||
const tenancy = jest.mocked(_tenancy)
|
|
||||||
|
|
||||||
describe("/api/global/tenant", () => {
|
|
||||||
const config = new TestConfiguration()
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await config.beforeAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await config.afterAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("POST /api/global/tenant", () => {
|
|
||||||
it("should save the tenantInfo", async () => {
|
|
||||||
tenancy.saveTenantInfo = jest.fn().mockImplementation(async () => ({
|
|
||||||
id: "DOC_ID",
|
|
||||||
ok: true,
|
|
||||||
rev: "DOC_REV",
|
|
||||||
}))
|
|
||||||
const tenantInfo: TenantInfo = {
|
|
||||||
owner: {
|
|
||||||
email: "test@example.com",
|
|
||||||
password: "PASSWORD123!",
|
|
||||||
ssoId: "SSO_ID",
|
|
||||||
givenName: "Jane",
|
|
||||||
familyName: "Doe",
|
|
||||||
budibaseUserId: "USER_ID",
|
|
||||||
},
|
|
||||||
tenantId: "tenant123",
|
|
||||||
hosting: Hosting.CLOUD,
|
|
||||||
}
|
|
||||||
const response = await config.api.tenants.saveTenantInfo(tenantInfo)
|
|
||||||
|
|
||||||
expect(_tenancy.saveTenantInfo).toHaveBeenCalledTimes(1)
|
|
||||||
expect(_tenancy.saveTenantInfo).toHaveBeenCalledWith(tenantInfo)
|
|
||||||
expect(response.text).toEqual('{"_id":"DOC_ID","_rev":"DOC_REV"}')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -412,28 +412,6 @@ describe("/api/global/users", () => {
|
||||||
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
|
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not be able to update an account holder user to a basic user", async () => {
|
|
||||||
const accountHolderUser = await config.createUser(
|
|
||||||
structures.users.adminUser()
|
|
||||||
)
|
|
||||||
jest.clearAllMocks()
|
|
||||||
tenancy.getTenantInfo = jest.fn().mockImplementation(() => ({
|
|
||||||
owner: {
|
|
||||||
email: accountHolderUser.email,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
accountHolderUser.admin!.global = false
|
|
||||||
accountHolderUser.builder!.global = false
|
|
||||||
|
|
||||||
await config.api.users.saveUser(accountHolderUser, 400)
|
|
||||||
|
|
||||||
expect(events.user.created).not.toHaveBeenCalled()
|
|
||||||
expect(events.user.updated).not.toHaveBeenCalled()
|
|
||||||
expect(events.user.permissionAdminRemoved).not.toHaveBeenCalled()
|
|
||||||
expect(events.user.permissionBuilderRemoved).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to update an builder user to a basic user", async () => {
|
it("should be able to update an builder user to a basic user", async () => {
|
||||||
const user = await config.createUser(structures.users.builderUser())
|
const user = await config.createUser(structures.users.builderUser())
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
@ -592,55 +570,21 @@ describe("/api/global/users", () => {
|
||||||
|
|
||||||
describe("POST /api/global/users/bulk (delete)", () => {
|
describe("POST /api/global/users/bulk (delete)", () => {
|
||||||
it("should not be able to bulk delete current user", async () => {
|
it("should not be able to bulk delete current user", async () => {
|
||||||
const user = await config.user!
|
const user = config.user!
|
||||||
|
|
||||||
const response = await config.api.users.bulkDeleteUsers([user._id!], 400)
|
const response = await config.api.users.bulkDeleteUsers(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
userId: user._id!,
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
expect(response.message).toBe("Unable to delete self.")
|
expect(response.message).toBe("Unable to delete self.")
|
||||||
expect(events.user.deleted).not.toHaveBeenCalled()
|
expect(events.user.deleted).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not be able to bulk delete account owner", async () => {
|
|
||||||
const user = await config.createUser()
|
|
||||||
const account = structures.accounts.cloudAccount()
|
|
||||||
account.budibaseUserId = user._id!
|
|
||||||
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
|
|
||||||
|
|
||||||
const response = await config.api.users.bulkDeleteUsers([user._id!])
|
|
||||||
|
|
||||||
expect(response.deleted?.successful.length).toBe(0)
|
|
||||||
expect(response.deleted?.unsuccessful.length).toBe(1)
|
|
||||||
expect(response.deleted?.unsuccessful[0].reason).toBe(
|
|
||||||
"Account holder cannot be deleted"
|
|
||||||
)
|
|
||||||
expect(response.deleted?.unsuccessful[0]._id).toBe(user._id)
|
|
||||||
expect(events.user.deleted).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to bulk delete users", async () => {
|
|
||||||
const account = structures.accounts.cloudAccount()
|
|
||||||
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
|
|
||||||
|
|
||||||
const builder = structures.users.builderUser()
|
|
||||||
const admin = structures.users.adminUser()
|
|
||||||
const user = structures.users.user()
|
|
||||||
const createdUsers = await config.api.users.bulkCreateUsers([
|
|
||||||
builder,
|
|
||||||
admin,
|
|
||||||
user,
|
|
||||||
])
|
|
||||||
|
|
||||||
const toDelete = createdUsers.created?.successful.map(
|
|
||||||
u => u._id!
|
|
||||||
) as string[]
|
|
||||||
const response = await config.api.users.bulkDeleteUsers(toDelete)
|
|
||||||
|
|
||||||
expect(response.deleted?.successful.length).toBe(3)
|
|
||||||
expect(response.deleted?.unsuccessful.length).toBe(0)
|
|
||||||
expect(events.user.deleted).toHaveBeenCalledTimes(3)
|
|
||||||
expect(events.user.permissionAdminRemoved).toHaveBeenCalledTimes(1)
|
|
||||||
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /api/global/users/search", () => {
|
describe("POST /api/global/users/search", () => {
|
||||||
|
|
|
@ -136,6 +136,7 @@ router
|
||||||
buildAdminInitValidation(),
|
buildAdminInitValidation(),
|
||||||
controller.adminUser
|
controller.adminUser
|
||||||
)
|
)
|
||||||
|
.get("/api/global/users/accountholder", controller.accountHolderLookup)
|
||||||
.get("/api/global/users/tenant/:id", controller.tenantUserLookup)
|
.get("/api/global/users/tenant/:id", controller.tenantUserLookup)
|
||||||
// global endpoint but needs to come at end (blocks other endpoints otherwise)
|
// global endpoint but needs to come at end (blocks other endpoints otherwise)
|
||||||
.get("/api/global/users/:id", auth.builderOrAdmin, controller.find)
|
.get("/api/global/users/:id", auth.builderOrAdmin, controller.find)
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import Router from "@koa/router"
|
import Router from "@koa/router"
|
||||||
import { api as pro } from "@budibase/pro"
|
import { api as pro } from "@budibase/pro"
|
||||||
import userRoutes from "./global/users"
|
import userRoutes from "./global/users"
|
||||||
import tenantRoutes from "./global/tenant"
|
|
||||||
import configRoutes from "./global/configs"
|
import configRoutes from "./global/configs"
|
||||||
import workspaceRoutes from "./global/workspaces"
|
import workspaceRoutes from "./global/workspaces"
|
||||||
import templateRoutes from "./global/templates"
|
import templateRoutes from "./global/templates"
|
||||||
|
@ -41,7 +40,6 @@ export const routes: Router[] = [
|
||||||
accountRoutes,
|
accountRoutes,
|
||||||
restoreRoutes,
|
restoreRoutes,
|
||||||
eventRoutes,
|
eventRoutes,
|
||||||
tenantRoutes,
|
|
||||||
pro.scim,
|
pro.scim,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -66,7 +66,14 @@ export const buildUserBulkUserValidation = (isSelf = false) => {
|
||||||
users: Joi.array().items(Joi.object(schema).required().unknown(true)),
|
users: Joi.array().items(Joi.object(schema).required().unknown(true)),
|
||||||
}),
|
}),
|
||||||
delete: Joi.object({
|
delete: Joi.object({
|
||||||
userIds: Joi.array().items(Joi.string()),
|
users: Joi.array().items(
|
||||||
|
Joi.object({
|
||||||
|
email: Joi.string(),
|
||||||
|
userId: Joi.string(),
|
||||||
|
})
|
||||||
|
.required()
|
||||||
|
.unknown(true)
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { TenantInfo } from "@budibase/types"
|
|
||||||
import TestConfiguration from "../TestConfiguration"
|
import TestConfiguration from "../TestConfiguration"
|
||||||
import { TestAPI, TestAPIOpts } from "./base"
|
import { TestAPI, TestAPIOpts } from "./base"
|
||||||
|
|
||||||
|
@ -15,12 +14,4 @@ export class TenantAPI extends TestAPI {
|
||||||
.set(opts?.headers)
|
.set(opts?.headers)
|
||||||
.expect(opts?.status ? opts.status : 204)
|
.expect(opts?.status ? opts.status : 204)
|
||||||
}
|
}
|
||||||
|
|
||||||
saveTenantInfo = (tenantInfo: TenantInfo) => {
|
|
||||||
return this.request
|
|
||||||
.post("/api/global/tenant")
|
|
||||||
.set(this.config.internalAPIHeaders())
|
|
||||||
.send(tenantInfo)
|
|
||||||
.expect(200)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,8 +81,14 @@ export class UserAPI extends TestAPI {
|
||||||
return res.body as BulkUserResponse
|
return res.body as BulkUserResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
bulkDeleteUsers = async (userIds: string[], status?: number) => {
|
bulkDeleteUsers = async (
|
||||||
const body: BulkUserRequest = { delete: { userIds } }
|
users: Array<{
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
}>,
|
||||||
|
status?: number
|
||||||
|
) => {
|
||||||
|
const body: BulkUserRequest = { delete: { users } }
|
||||||
const res = await this.request
|
const res = await this.request
|
||||||
.post(`/api/global/users/bulk`)
|
.post(`/api/global/users/bulk`)
|
||||||
.send(body)
|
.send(body)
|
||||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -2051,7 +2051,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||||
|
|
||||||
"@budibase/backend-core@2.32.11":
|
"@budibase/backend-core@2.33.2":
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@budibase/nano" "10.1.5"
|
"@budibase/nano" "10.1.5"
|
||||||
|
@ -2132,15 +2132,15 @@
|
||||||
through2 "^2.0.0"
|
through2 "^2.0.0"
|
||||||
|
|
||||||
"@budibase/pro@npm:@budibase/pro@latest":
|
"@budibase/pro@npm:@budibase/pro@latest":
|
||||||
version "2.32.11"
|
version "2.33.2"
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.32.11.tgz#c94d534f829ca0ef252677757e157a7e58b87b4d"
|
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.33.2.tgz#5c2012f7b2bf0fd871cda1ad37ad7a0442c84658"
|
||||||
integrity sha512-mOkqJpqHKWsfTWZwWcvBCYFUIluSUHltQNinc1ZRsg9rC3OKoHSDop6gzm744++H/GzGRN8V86kLhCgtNIlkpA==
|
integrity sha512-lBB6Wfp6OIOHRlGq82WS9KxvEXRs/P2QlwJT0Aj9PhmkQFsnXm2r8d18f0xTGvcflD+iR7XGP/k56JlCanmhQg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@anthropic-ai/sdk" "^0.27.3"
|
"@anthropic-ai/sdk" "^0.27.3"
|
||||||
"@budibase/backend-core" "2.32.11"
|
"@budibase/backend-core" "2.33.2"
|
||||||
"@budibase/shared-core" "2.32.11"
|
"@budibase/shared-core" "2.33.2"
|
||||||
"@budibase/string-templates" "2.32.11"
|
"@budibase/string-templates" "2.33.2"
|
||||||
"@budibase/types" "2.32.11"
|
"@budibase/types" "2.33.2"
|
||||||
"@koa/router" "8.0.8"
|
"@koa/router" "8.0.8"
|
||||||
bull "4.10.1"
|
bull "4.10.1"
|
||||||
dd-trace "5.2.0"
|
dd-trace "5.2.0"
|
||||||
|
@ -2153,13 +2153,13 @@
|
||||||
scim-patch "^0.8.1"
|
scim-patch "^0.8.1"
|
||||||
scim2-parse-filter "^0.2.8"
|
scim2-parse-filter "^0.2.8"
|
||||||
|
|
||||||
"@budibase/shared-core@2.32.11":
|
"@budibase/shared-core@2.33.2":
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@budibase/types" "0.0.0"
|
"@budibase/types" "0.0.0"
|
||||||
cron-validate "1.4.5"
|
cron-validate "1.4.5"
|
||||||
|
|
||||||
"@budibase/string-templates@2.32.11":
|
"@budibase/string-templates@2.33.2":
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@budibase/handlebars-helpers" "^0.13.2"
|
"@budibase/handlebars-helpers" "^0.13.2"
|
||||||
|
@ -2167,7 +2167,7 @@
|
||||||
handlebars "^4.7.8"
|
handlebars "^4.7.8"
|
||||||
lodash.clonedeep "^4.5.0"
|
lodash.clonedeep "^4.5.0"
|
||||||
|
|
||||||
"@budibase/types@2.32.11":
|
"@budibase/types@2.33.2":
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
scim-patch "^0.8.1"
|
scim-patch "^0.8.1"
|
||||||
|
|
Loading…
Reference in New Issue