1
0
Fork 0
mirror of synced 2024-07-07 15:25:52 +12:00

Merge pull request #7478 from Budibase/user-fixes

User fixes and updates
This commit is contained in:
Rory Powell 2022-08-31 13:03:59 +01:00 committed by GitHub
commit faf132a82a
78 changed files with 2158 additions and 1160 deletions

View file

@ -1,11 +1,11 @@
const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy
const { getGlobalDB } = require("./tenancy")
import { getGlobalDB } from "./tenancy"
const refresh = require("passport-oauth2-refresh")
const { Configs } = require("./constants")
const { getScopedConfig } = require("./db/utils")
const {
import { Configs } from "./constants"
import { getScopedConfig } from "./db/utils"
import {
jwt,
local,
authenticated,
@ -13,7 +13,6 @@ const {
oidc,
auditLog,
tenancy,
appTenancy,
authError,
ssoCallbackUrl,
csrf,
@ -22,32 +21,36 @@ const {
builderOnly,
builderOrAdmin,
joiValidator,
} = require("./middleware")
const { invalidateUser } = require("./cache/user")
} from "./middleware"
import { invalidateUser } from "./cache/user"
import { User } from "@budibase/types"
// Strategies
passport.use(new LocalStrategy(local.options, local.authenticate))
passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
passport.serializeUser((user, done) => done(null, user))
passport.serializeUser((user: User, done: any) => done(null, user))
passport.deserializeUser(async (user, done) => {
passport.deserializeUser(async (user: User, done: any) => {
const db = getGlobalDB()
try {
const user = await db.get(user._id)
return done(null, user)
const dbUser = await db.get(user._id)
return done(null, dbUser)
} catch (err) {
console.error(`User not found`, err)
return done(null, false, { message: "User not found" })
}
})
async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
async function refreshOIDCAccessToken(
db: any,
chosenConfig: any,
refreshToken: string
) {
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
let enrichedConfig
let strategy
let enrichedConfig: any
let strategy: any
try {
enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl)
@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
refresh.requestNewAccessToken(
Configs.OIDC,
refreshToken,
(err, accessToken, refreshToken, params) => {
(err: any, accessToken: string, refreshToken: any, params: any) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
async function refreshGoogleAccessToken(db, config, refreshToken) {
async function refreshGoogleAccessToken(
db: any,
config: any,
refreshToken: any
) {
let callbackUrl = await google.getCallbackUrl(db, config)
let strategy
try {
strategy = await google.strategyFactory(config, callbackUrl)
} catch (err) {
} catch (err: any) {
console.error(err)
throw new Error("Error constructing OIDC refresh strategy", err)
throw new Error(
`Error constructing OIDC refresh strategy: message=${err.message}`
)
}
refresh.use(strategy)
@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) {
refresh.requestNewAccessToken(
Configs.GOOGLE,
refreshToken,
(err, accessToken, refreshToken, params) => {
(err: any, accessToken: string, refreshToken: string, params: any) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
async function refreshOAuthToken(refreshToken, configType, configId) {
async function refreshOAuthToken(
refreshToken: string,
configType: string,
configId: string
) {
const db = getGlobalDB()
const config = await getScopedConfig(db, {
@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
let refreshResponse
if (configType === Configs.OIDC) {
// configId - retrieved from cookie.
chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
if (!chosenConfig) {
throw new Error("Invalid OIDC configuration")
}
@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
return refreshResponse
}
async function updateUserOAuth(userId, oAuthConfig) {
async function updateUserOAuth(userId: string, oAuthConfig: any) {
const details = {
accessToken: oAuthConfig.accessToken,
refreshToken: oAuthConfig.refreshToken,
@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) {
}
}
module.exports = {
export = {
buildAuthMiddleware: authenticated,
passport,
google,
oidc,
jwt: require("jsonwebtoken"),
buildTenancyMiddleware: tenancy,
buildAppTenancyMiddleware: appTenancy,
auditLog,
authError,
buildCsrfMiddleware: csrf,

View file

@ -18,6 +18,7 @@ export enum ViewName {
LINK = "by_link",
ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
}
export const DeprecatedViews = {
@ -41,6 +42,7 @@ export enum DocumentType {
MIGRATIONS = "migrations",
DEV_INFO = "devinfo",
AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata",
}
export const StaticDatabases = {

View file

@ -5,6 +5,8 @@ const {
SEPARATOR,
} = require("./utils")
const { getGlobalDB } = require("../tenancy")
const { StaticDatabases } = require("./constants")
const { doWithDB } = require("./")
const DESIGN_DB = "_design/database"
@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc)
}
exports.createAccountEmailView = async () => {
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
let designDoc
try {
designDoc = await db.get(DESIGN_DB)
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
// if using variables in a map function need to inject them before use
map: `function(doc) {
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.ACCOUNT_BY_EMAIL]: view,
}
await db.put(designDoc)
})
}
exports.createUserAppView = async () => {
const db = getGlobalDB()
let designDoc
@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => {
await db.put(designDoc)
}
exports.queryView = async (viewName, params, db, CreateFuncByName) => {
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
if (params.arrayResponse) {
return response
} else {
return response.length <= 1 ? response[0] : response
}
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryView(viewName, params, db, CreateFuncByName)
} else {
throw err
}
}
}
exports.queryPlatformView = async (viewName, params) => {
const CreateFuncByName = {
[ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView,
}
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
return exports.queryView(viewName, params, db, CreateFuncByName)
})
}
exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = {
[ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView,
@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
if (!db) {
db = getGlobalDB()
}
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
return response.length <= 1 ? response[0] : response
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryGlobalView(viewName, params)
} else {
throw err
}
}
return exports.queryView(viewName, params, db, CreateFuncByName)
}

View file

@ -8,4 +8,5 @@ import { processors } from "./processors"
export const shutdown = () => {
processors.shutdown()
console.log("Events shutdown")
}

View file

@ -17,6 +17,7 @@ import constants from "./constants"
import * as dbConstants from "./db/constants"
import logging from "./logging"
import pino from "./pino"
import * as middleware from "./middleware"
// mimic the outer package exports
import * as db from "./pkg/db"
@ -57,6 +58,7 @@ const core = {
roles,
...pino,
...errorClasses,
middleware,
}
export = core

View file

@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
* The tenancy modules should not be used here and it should be assumed that the tenancy context
* has not yet been populated.
*/
module.exports = (
export = (
noAuthPatterns = [],
opts: { publicAllowed: boolean; populateUser?: Function } = {
publicAllowed: false,

View file

@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly")
const builderOrAdmin = require("./builderOrAdmin")
const builderOnly = require("./builderOnly")
const joiValidator = require("./joi-validator")
module.exports = {
const pkg = {
google,
oidc,
jwt,
@ -33,3 +34,5 @@ module.exports = {
builderOrAdmin,
joiValidator,
}
export = pkg

View file

@ -13,10 +13,13 @@ function validate(schema, property) {
params = ctx.request[property]
}
schema = schema.append({
createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
// not all schemas have the append property e.g. array schemas
if (schema.append) {
schema = schema.append({
createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
}
const { error } = schema.validate(params)
if (error) {

View file

@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging")
const env = require("../environment")
interface Session {
key: string
userId: string
interface CreateSession {
sessionId: string
lastAccessedAt: string
createdAt: string
tenantId: string
csrfToken?: string
value: string
}
type SessionKey = { key: string }[]
interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
interface SessionKey {
key: string
}
interface ScannedSession {
value: Session
}
// a week in seconds
const EXPIRY_SECONDS = 86400 * 7
@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}`
}
export async function getSessionsForUser(userId: string) {
export async function getSessionsForUser(userId: string): Promise<Session[]> {
if (!userId) {
console.trace("Cannot get sessions for undefined userId")
return []
}
const client = await redis.getSessionClient()
const sessions = await client.scan(userId)
return sessions.map((session: Session) => session.value)
const sessions: ScannedSession[] = await client.scan(userId)
return sessions.map(session => session.value)
}
export async function invalidateSessions(
@ -39,33 +49,32 @@ export async function invalidateSessions(
try {
const reason = opts?.reason || "unknown"
let sessionIds: string[] = opts.sessionIds || []
let sessions: SessionKey
let sessionKeys: SessionKey[]
// If no sessionIds, get all the sessions for the user
if (sessionIds.length === 0) {
sessions = await getSessionsForUser(userId)
sessions.forEach(
(session: any) =>
(session.key = makeSessionID(session.userId, session.sessionId))
)
const sessions = await getSessionsForUser(userId)
sessionKeys = sessions.map(session => ({
key: makeSessionID(session.userId, session.sessionId),
}))
} else {
// use the passed array of sessionIds
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]
sessions = sessionIds.map((sessionId: string) => ({
sessionKeys = sessionIds.map(sessionId => ({
key: makeSessionID(userId, sessionId),
}))
}
if (sessions && sessions.length > 0) {
if (sessionKeys && sessionKeys.length > 0) {
const client = await redis.getSessionClient()
const promises = []
for (let session of sessions) {
promises.push(client.delete(session.key))
for (let sessionKey of sessionKeys) {
promises.push(client.delete(sessionKey.key))
}
if (!env.isTest()) {
logWarn(
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions
.map(session => session.key)
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys
.map(sessionKey => sessionKey.key)
.join(", ")}`
)
}
@ -76,22 +85,26 @@ export async function invalidateSessions(
}
}
export async function createASession(userId: string, session: Session) {
export async function createASession(
userId: string,
createSession: CreateSession
) {
// invalidate all other sessions
await invalidateSessions(userId, { reason: "creation" })
const client = await redis.getSessionClient()
const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = {
...session,
const sessionId = createSession.sessionId
const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4()
const key = makeSessionID(userId, sessionId)
const session: Session = {
...createSession,
csrfToken,
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
userId,
}
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
await client.store(key, session, EXPIRY_SECONDS)
}
export async function updateSessionTTL(session: Session) {
@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) {
await client.delete(makeSessionID(userId, sessionId))
}
export async function getSession(userId: string, sessionId: string) {
export async function getSession(
userId: string,
sessionId: string
): Promise<Session> {
if (!userId || !sessionId) {
throw new Error(`Invalid session details - ${userId} - ${sessionId}`)
}

View file

@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants")
* Given an email address this will use a view to search through
* all the users to find one with this email address.
* @param {string} email the email to lookup the user by.
* @return {Promise<object|null>}
*/
exports.getGlobalUserByEmail = async email => {
if (email == null) {

View file

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

View file

@ -1,2 +0,0 @@
exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
exports.MOCK_DATE_TIMESTAMP = 1577836800000

View file

@ -0,0 +1,2 @@
export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
export const MOCK_DATE_TIMESTAMP = 1577836800000

View file

@ -1,9 +0,0 @@
const posthog = require("./posthog")
const events = require("./events")
const date = require("./date")
module.exports = {
posthog,
date,
events,
}

View file

@ -0,0 +1,4 @@
import "./posthog"
import "./events"
export * as accounts from "./accounts"
export * as date from "./date"

View file

@ -0,0 +1,73 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
export let userData
export let deleteUsersResponse
let successCount
let failureCount
let title
let unsuccessfulUsers
let message
const setTitle = () => {
if (successCount) {
title = `${successCount} users deleted`
} else {
title = "Oops!"
}
}
const setMessage = () => {
if (successCount) {
message = "However there was a problem deleting some users."
} else {
message = "There was a problem deleting some users."
}
}
const setUsers = () => {
unsuccessfulUsers = deleteUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
successCount = deleteUsersResponse.successful.length
failureCount = deleteUsersResponse.unsuccessful.length
setTitle()
setMessage()
setUsers()
})
const schema = {
email: {},
reason: {},
}
</script>
<ModalContent
size="M"
{title}
confirmText="Close"
showCloseIcon={false}
showCancelButton={false}
>
<Body size="XS">
{message}
</Body>
<Table
{schema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
</ModalContent>
<style>
</style>

View file

@ -2,24 +2,78 @@
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
import { parseToCsv } from "helpers/data/utils"
import { onMount } from "svelte"
export let userData
export let createUsersResponse
$: mappedData = userData.map(user => {
return {
email: user.email,
password: user.password,
let hasSuccess
let hasFailure
let title
let failureMessage
let userDataIndex
let successfulUsers
let unsuccessfulUsers
const setTitle = () => {
if (hasSuccess) {
title = "Users created!"
} else if (hasFailure) {
title = "Oops!"
}
}
const setFailureMessage = () => {
if (hasSuccess) {
failureMessage = "However there was a problem creating some users."
} else {
failureMessage = "There was a problem creating some users."
}
}
const setUsers = () => {
userDataIndex = userData.reduce((prev, current) => {
prev[current.email] = current
return prev
}, {})
successfulUsers = createUsersResponse.successful.map(user => {
return {
email: user.email,
password: userDataIndex[user.email].password,
}
})
unsuccessfulUsers = createUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
hasSuccess = createUsersResponse.successful.length
hasFailure = createUsersResponse.unsuccessful.length
setTitle()
setFailureMessage()
setUsers()
})
const schema = {
const successSchema = {
email: {},
password: {},
}
const failedSchema = {
email: {},
reason: {},
}
const downloadCsvFile = () => {
const fileName = "passwords.csv"
const content = parseToCsv(["email", "password"], mappedData)
const content = parseToCsv(["email", "password"], successfulUsers)
download(fileName, content)
}
@ -42,36 +96,52 @@
</script>
<ModalContent
size="S"
title="Accounts created!"
size="M"
{title}
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
>
<Body size="XS">
All your new users can be accessed through the autogenerated passwords. Take
note of these passwords or download the CSV file.
</Body>
{#if hasFailure}
<Body size="XS">
{failureMessage}
</Body>
<Table
schema={failedSchema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
{#if hasSuccess}
<Body size="XS">
All your new users can be accessed through the autogenerated passwords.
Take note of these passwords or download the CSV file.
</Body>
<div class="container" on:click={downloadCsvFile}>
<div class="inner">
<Icon name="Download" />
<div class="container" on:click={downloadCsvFile}>
<div class="inner">
<Icon name="Download" />
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
</div>
</div>
</div>
</div>
<Table
{schema}
data={mappedData}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
/>
<Table
schema={successSchema}
data={successfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[
{ column: "password", component: PasswordCopyRenderer },
]}
/>
{/if}
</ModalContent>
<style>

View file

@ -23,6 +23,7 @@
import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte"
import DeletionFailureModal from "./_components/DeletionFailureModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
import { createPaginationStore } from "helpers/pagination"
import { get } from "svelte/store"
@ -33,7 +34,8 @@
inviteConfirmationModal,
onboardingTypeModal,
passwordModal,
importUsersModal
importUsersModal,
deletionFailureModal
let pageInfo = createPaginationStore()
let prevEmail = undefined,
searchEmail = undefined
@ -55,6 +57,8 @@
apps: {},
}
$: userData = []
$: createUsersResponse = { successful: [], unsuccessful: [] }
$: deleteUsersResponse = { successful: [], unsuccessful: [] }
$: page = $pageInfo.page
$: fetchUsers(page, searchEmail)
$: {
@ -116,8 +120,9 @@
newUsers.push(user)
}
if (!newUsers.length)
if (!newUsers.length) {
notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers }
}
@ -144,7 +149,9 @@
async function createUser() {
try {
await users.create(await removingDuplicities(userData))
createUsersResponse = await users.create(
await removingDuplicities(userData)
)
notifications.success("Successfully created user")
await groups.actions.init()
passwordModal.show()
@ -176,8 +183,15 @@
notifications.error("You cannot delete yourself")
return
}
await users.bulkDelete(ids)
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
deleteUsersResponse = await users.bulkDelete(ids)
if (deleteUsersResponse.unsuccessful?.length) {
deletionFailureModal.show()
} else {
notifications.success(
`Successfully deleted ${selectedRows.length} users`
)
}
selectedRows = []
await fetchUsers(page, searchEmail)
} catch (error) {
@ -284,7 +298,11 @@
</Modal>
<Modal bind:this={passwordModal}>
<PasswordModal userData={userData.users} />
<PasswordModal {createUsersResponse} userData={userData.users} />
</Modal>
<Modal bind:this={deletionFailureModal}>
<DeletionFailureModal {deleteUsersResponse} />
</Modal>
<Modal bind:this={importUsersModal}>

View file

@ -63,10 +63,14 @@ export function createUsersStore() {
return body
})
await API.createUsers({ users: mappedUsers, groups: data.groups })
const response = await API.createUsers({
users: mappedUsers,
groups: data.groups,
})
// re-search from first page
await search()
return response
}
async function del(id) {
@ -79,7 +83,7 @@ export function createUsersStore() {
}
async function bulkDelete(userIds) {
await API.deleteUsers(userIds)
return API.deleteUsers(userIds)
}
async function save(user) {

View file

@ -83,9 +83,7 @@ server.on("close", async () => {
return
}
shuttingDown = true
if (!env.isTest()) {
console.log("Server Closed")
}
console.log("Server Closed")
await automations.shutdown()
await redis.shutdown()
await events.shutdown()
@ -167,3 +165,7 @@ process.on("uncaughtException", err => {
process.on("SIGTERM", () => {
shutdown()
})
process.on("SIGINT", () => {
shutdown()
})

View file

@ -53,6 +53,7 @@ exports.shutdown = async () => {
await automationQueue.close()
automationQueue = null
}
console.log("Bull shutdown")
}
exports.queue = automationQueue

View file

@ -13,10 +13,13 @@ function validate(schema, property) {
params = ctx.request[property]
}
schema = schema.append({
createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
// not all schemas have the append property e.g. array schemas
if (schema.append) {
schema = schema.append({
createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
}
const { error } = schema.validate(params)
if (error) {

View file

@ -106,5 +106,6 @@ export class Thread {
static async shutdown() {
await Thread.stopThreads()
console.log("Threads shutdown")
}
}

View file

@ -20,6 +20,7 @@ exports.shutdown = async () => {
if (devAppClient) await devAppClient.finish()
if (debounceClient) await debounceClient.finish()
if (flagClient) await flagClient.finish()
console.log("Redis shutdown")
}
exports.doesUserHaveLock = async (devAppId, user) => {

View file

@ -0,0 +1,5 @@
export interface APIError {
message: string
status: number
error?: any
}

View file

@ -1 +1,3 @@
export * from "./analytics"
export * from "./user"
export * from "./errors"

View file

@ -0,0 +1,31 @@
import { User } from "../../documents"
export interface CreateUserResponse {
_id: string
_rev: string
email: string
}
export interface BulkCreateUsersRequest {
users: User[]
groups: any[]
}
export interface UserDetails {
_id: string
email: string
}
export interface BulkCreateUsersResponse {
successful: UserDetails[]
unsuccessful: { email: string; reason: string }[]
}
export interface BulkDeleteUsersRequest {
userIds: string[]
}
export interface BulkDeleteUsersResponse {
successful: UserDetails[]
unsuccessful: { _id: string; email: string; reason: string }[]
}

View file

@ -15,8 +15,26 @@ export interface User extends Document {
status?: string
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
userGroups?: string[]
forceResetPassword?: boolean
}
export interface UserRoles {
[key: string]: string
}
// utility types
export interface BuilderUser extends User {
builder: {
global: boolean
}
}
export interface AdminUser extends User {
admin: {
global: boolean
}
builder: {
global: boolean
}
}

View file

@ -1,19 +1,20 @@
import { Document } from "../document"
import { User } from "./user"
export interface UserGroup extends Document {
name: string
icon: string
color: string
users: groupUser[]
users: GroupUser[]
apps: string[]
roles: UserGroupRoles
createdAt?: number
}
export interface groupUser {
export interface GroupUser {
_id: string
email: string[]
email: string
}
export interface UserGroupRoles {
[key: string]: string
}

View file

@ -3,3 +3,4 @@ export * from "./app"
export * from "./global"
export * from "./platform"
export * from "./document"
export * from "./pouch"

View file

@ -0,0 +1,5 @@
import { Document } from "../document"
export interface AccountMetadata extends Document {
email: string
}

View file

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

View file

@ -0,0 +1,9 @@
import { Document } from "../document"
/**
* doc id is user email
*/
export interface PlatformUserByEmail extends Document {
tenantId: string
userId: string
}

View file

@ -0,0 +1,26 @@
export interface RowValue {
rev: string
deleted: boolean
}
export interface RowResponse<T> {
id: string
key: string
error: string
value: RowValue
doc: T
}
export interface AllDocsResponse<T> {
offset: number
total_rows: number
rows: RowResponse<T>[]
}
export type BulkDocsResponse = BulkDocResponse[]
interface BulkDocResponse {
ok: boolean
id: string
rev: string
}

View file

@ -0,0 +1,5 @@
export interface AuthToken {
userId: string
tenantId: string
sessionId: string
}

View file

@ -5,3 +5,4 @@ export * from "./licensing"
export * from "./migrations"
export * from "./datasources"
export * from "./search"
export * from "./auth"

View file

@ -74,6 +74,7 @@
"@types/koa-router": "7.4.4",
"@types/koa__router": "8.0.11",
"@types/node": "14.18.20",
"@types/uuid": "8.3.4",
"@typescript-eslint/parser": "5.12.0",
"copyfiles": "2.4.1",
"eslint": "6.8.0",

View file

@ -14,3 +14,9 @@ const tk = require("timekeeper")
tk.freeze(mocks.date.MOCK_DATE)
global.console.log = jest.fn() // console.log are ignored in tests
if (!process.env.CI) {
// set a longer timeout in dev for debugging
// 100 seconds
jest.setTimeout(100000)
}

View file

@ -1,97 +0,0 @@
// get the JWT secret etc
require("../../src/environment")
require("@budibase/backend-core").init()
const {
getProdAppID,
generateGlobalUserID,
} = require("@budibase/backend-core/db")
const { doInTenant, getGlobalDB } = require("@budibase/backend-core/tenancy")
const users = require("../../src/sdk/users")
const { publicApiUserFix } = require("../../src/utilities/users")
const { hash } = require("@budibase/backend-core/utils")
const USER_LOAD_NUMBER = 10000
const BATCH_SIZE = 200
const PASSWORD = "test"
const TENANT_ID = "default"
const APP_ID = process.argv[2]
const words = [
"test",
"testing",
"budi",
"mail",
"age",
"risk",
"load",
"uno",
"arm",
"leg",
"pen",
"glass",
"box",
"chicken",
"bottle",
]
if (!APP_ID) {
console.error("Must supply app ID as first CLI option!")
process.exit(-1)
}
const WORD_1 = words[Math.floor(Math.random() * words.length)]
const WORD_2 = words[Math.floor(Math.random() * words.length)]
let HASHED_PASSWORD
function generateUser(count) {
return {
_id: generateGlobalUserID(),
password: HASHED_PASSWORD,
email: `${WORD_1}${count}@${WORD_2}.com`,
roles: {
[getProdAppID(APP_ID)]: "BASIC",
},
status: "active",
forceResetPassword: false,
firstName: "John",
lastName: "Smith",
}
}
async function run() {
HASHED_PASSWORD = await hash(PASSWORD)
return doInTenant(TENANT_ID, async () => {
const db = getGlobalDB()
for (let i = 0; i < USER_LOAD_NUMBER; i += BATCH_SIZE) {
let userSavePromises = []
for (let j = 0; j < BATCH_SIZE; j++) {
// like the public API
const ctx = publicApiUserFix({
request: {
body: generateUser(i + j),
},
})
userSavePromises.push(
users.save(ctx.request.body, {
hashPassword: false,
requirePassword: true,
bulkCreate: true,
})
)
}
const allUsers = await Promise.all(userSavePromises)
await db.bulkDocs(allUsers)
console.log(`${i + BATCH_SIZE} users have been created.`)
}
})
}
run()
.then(() => {
console.log(`Generated ${USER_LOAD_NUMBER} users!`)
})
.catch(err => {
console.error("Failed for reason: ", err)
process.exit(-1)
})

View file

@ -3,7 +3,7 @@ import { checkInviteCode } from "../../../utilities/redis"
import { sendEmail } from "../../../utilities/email"
import { users } from "../../../sdk"
import env from "../../../environment"
import { CloudAccount, User } from "@budibase/types"
import { BulkDeleteUsersRequest, CloudAccount, User } from "@budibase/types"
import {
accounts,
cache,
@ -46,8 +46,8 @@ export const bulkCreate = async (ctx: any) => {
}
try {
let response = await users.bulkCreate(newUsersRequested, groups)
await groupUtils.bulkSaveGroupUsers(groupsToSave, response)
const response = await users.bulkCreate(newUsersRequested, groups)
await groupUtils.bulkSaveGroupUsers(groupsToSave, response.successful)
ctx.body = response
} catch (err: any) {
@ -138,17 +138,15 @@ export const destroy = async (ctx: any) => {
}
export const bulkDelete = async (ctx: any) => {
const { userIds } = ctx.request.body
const { userIds } = ctx.request.body as BulkDeleteUsersRequest
if (userIds?.indexOf(ctx.user._id) !== -1) {
ctx.throw(400, "Unable to delete self.")
}
try {
let usersResponse = await users.bulkDelete(userIds)
let response = await users.bulkDelete(userIds)
ctx.body = {
message: `${usersResponse.length} user(s) deleted`,
}
ctx.body = response
} catch (err) {
ctx.throw(err)
}

View file

@ -0,0 +1,21 @@
import { Account, AccountMetadata } from "@budibase/types"
import { accounts } from "../../../sdk"
export const save = async (ctx: any) => {
const account = ctx.request.body as Account
let metadata: AccountMetadata = {
_id: accounts.formatAccountMetadataId(account.accountId),
email: account.email,
}
metadata = await accounts.saveMetadata(metadata)
ctx.body = metadata
ctx.status = 200
}
export const destroy = async (ctx: any) => {
const accountId = accounts.formatAccountMetadataId(ctx.params.accountId)
await accounts.destroyMetadata(accountId)
ctx.status = 204
}

View file

@ -1,15 +1,10 @@
const Router = require("@koa/router")
import Router from "@koa/router"
const compress = require("koa-compress")
const zlib = require("zlib")
const { routes } = require("./routes")
const {
buildAuthMiddleware,
auditLog,
buildTenancyMiddleware,
buildCsrfMiddleware,
} = require("@budibase/backend-core/auth")
const { middleware: pro } = require("@budibase/pro")
const { errors } = require("@budibase/backend-core")
import { routes } from "./routes"
import { middleware as pro } from "@budibase/pro"
import { errors, auth, middleware } from "@budibase/backend-core"
import { APIError } from "@budibase/types"
const PUBLIC_ENDPOINTS = [
// old deprecated endpoints kept for backwards compat
@ -97,9 +92,9 @@ router
})
)
.use("/health", ctx => (ctx.status = 200))
.use(buildAuthMiddleware(PUBLIC_ENDPOINTS))
.use(buildTenancyMiddleware(PUBLIC_ENDPOINTS, NO_TENANCY_ENDPOINTS))
.use(buildCsrfMiddleware({ noCsrfPatterns: NO_CSRF_ENDPOINTS }))
.use(auth.buildAuthMiddleware(PUBLIC_ENDPOINTS))
.use(auth.buildTenancyMiddleware(PUBLIC_ENDPOINTS, NO_TENANCY_ENDPOINTS))
.use(auth.buildCsrfMiddleware({ noCsrfPatterns: NO_CSRF_ENDPOINTS }))
.use(pro.licensing())
// for now no public access is allowed to worker (bar health check)
.use((ctx, next) => {
@ -114,21 +109,22 @@ router
}
return next()
})
.use(auditLog)
.use(middleware.auditLog)
// error handling middleware - TODO: This could be moved to backend-core
router.use(async (ctx, next) => {
try {
await next()
} catch (err) {
} catch (err: any) {
ctx.log.error(err)
ctx.status = err.status || err.statusCode || 500
const error = errors.getPublicError(err)
ctx.body = {
const body: APIError = {
message: err.message,
status: ctx.status,
error,
}
ctx.body = body
}
})

View file

@ -1,11 +1,11 @@
jest.mock("nodemailer")
const { config, request, mocks, structures } = require("../../../tests")
import { TestConfiguration, mocks, API } from "../../../../tests"
const sendMailMock = mocks.email.mock()
const { events } = require("@budibase/backend-core")
const TENANT_ID = structures.TENANT_ID
import { events } from "@budibase/backend-core"
describe("/api/global/auth", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
@ -19,56 +19,32 @@ describe("/api/global/auth", () => {
jest.clearAllMocks()
})
const requestPasswordReset = async () => {
await config.saveSmtpConfig()
await config.saveSettingsConfig()
await config.createUser()
const res = await request
.post(`/api/global/auth/${TENANT_ID}/reset`)
.send({
email: "test@test.com",
})
.expect("Content-Type", /json/)
.expect(200)
const emailCall = sendMailMock.mock.calls[0][0]
const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`)
const code = parts[1].split("\"")[0].split("&")[0]
return { code, res }
}
it("should logout", async () => {
await request
.post("/api/global/auth/logout")
.set(config.defaultHeaders())
.expect(200)
await api.auth.logout()
expect(events.auth.logout).toBeCalledTimes(1)
})
it("should be able to generate password reset email", async () => {
const { res, code } = await requestPasswordReset()
const { res, code } = await api.auth.requestPasswordReset(sendMailMock)
const user = await config.getUser("test@test.com")
expect(res.body).toEqual({ message: "Please check your email for a reset link." })
expect(res.body).toEqual({
message: "Please check your email for a reset link.",
})
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user)
})
it("should allow resetting user password with code", async () => {
const { code } = await requestPasswordReset()
const { code } = await api.auth.requestPasswordReset(sendMailMock)
const user = await config.getUser("test@test.com")
delete user.password
delete user.password
const res = await api.auth.updatePassword(code)
const res = await request
.post(`/api/global/auth/${TENANT_ID}/reset/update`)
.send({
password: "newpassword",
resetCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user)
@ -79,15 +55,15 @@ describe("/api/global/auth", () => {
const passportSpy = jest.spyOn(auth.passport, "authenticate")
let oidcConf
let chosenConfig
let configId
let chosenConfig: any
let configId: string
// mock the oidc strategy implementation and return value
let strategyFactory = jest.fn()
let mockStrategyReturn = jest.fn()
let mockStrategyConfig = jest.fn()
auth.oidc.fetchStrategyConfig = mockStrategyConfig
strategyFactory.mockReturnValue(mockStrategyReturn)
auth.oidc.strategyFactory = strategyFactory
@ -99,34 +75,34 @@ describe("/api/global/auth", () => {
})
afterEach(() => {
expect(strategyFactory).toBeCalledWith(
chosenConfig,
expect.any(Function)
)
expect(strategyFactory).toBeCalledWith(chosenConfig, expect.any(Function))
})
describe("oidc configs", () => {
it("should load strategy and delegate to passport", async () => {
await request.get(`/api/global/auth/${TENANT_ID}/oidc/configs/${configId}`)
await api.configs.getOIDCConfig(configId)
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
scope: ["profile", "email", "offline_access"]
scope: ["profile", "email", "offline_access"],
})
expect(passportSpy.mock.calls.length).toBe(1);
expect(passportSpy.mock.calls.length).toBe(1)
})
})
describe("oidc callback", () => {
it("should load strategy and delegate to passport", async () => {
await request.get(`/api/global/auth/${TENANT_ID}/oidc/callback`)
.set(config.getOIDConfigCookie(configId))
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
successRedirect: "/", failureRedirect: "/error"
}, expect.anything())
expect(passportSpy.mock.calls.length).toBe(1);
await api.configs.OIDCCallback(configId)
expect(passportSpy).toBeCalledWith(
mockStrategyReturn,
{
successRedirect: "/",
failureRedirect: "/error",
},
expect.anything()
)
expect(passportSpy.mock.calls.length).toBe(1)
})
})
})
})

View file

@ -1,11 +1,12 @@
// mock the email system
jest.mock("nodemailer")
const { config, structures, mocks, request } = require("../../../tests")
import { TestConfiguration, structures, mocks, API } from "../../../../tests"
mocks.email.mock()
const { Configs } = require("@budibase/backend-core/constants")
const { events } = require("@budibase/backend-core")
import { Configs, events } from "@budibase/backend-core"
describe("configs", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
@ -20,35 +21,33 @@ describe("configs", () => {
})
describe("post /api/global/configs", () => {
const saveConfig = async (conf, _id, _rev) => {
const saveConfig = async (conf: any, _id?: string, _rev?: string) => {
const data = {
...conf,
_id,
_rev
_rev,
}
const res = await request
.post(`/api/global/configs`)
.send(data)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const res = await api.configs.saveConfig(data)
return {
...data,
...res.body
...res.body,
}
}
describe("google", () => {
const saveGoogleConfig = async (conf, _id, _rev) => {
const saveGoogleConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const googleConfig = structures.configs.google(conf)
return saveConfig(googleConfig, _id, _rev)
}
describe("create", () => {
it ("should create activated google config", async () => {
it("should create activated google config", async () => {
await saveGoogleConfig()
expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Configs.GOOGLE)
@ -58,7 +57,7 @@ describe("configs", () => {
await config.deleteConfig(Configs.GOOGLE)
})
it ("should create deactivated google config", async () => {
it("should create deactivated google config", async () => {
await saveGoogleConfig({ activated: false })
expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Configs.GOOGLE)
@ -69,10 +68,14 @@ describe("configs", () => {
})
describe("update", () => {
it ("should update google config to deactivated", async () => {
it("should update google config to deactivated", async () => {
const googleConf = await saveGoogleConfig()
jest.clearAllMocks()
await saveGoogleConfig({ ...googleConf.config, activated: false }, googleConf._id, googleConf._rev)
await saveGoogleConfig(
{ ...googleConf.config, activated: false },
googleConf._id,
googleConf._rev
)
expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE)
expect(events.auth.SSOActivated).not.toBeCalled()
@ -81,10 +84,14 @@ describe("configs", () => {
await config.deleteConfig(Configs.GOOGLE)
})
it ("should update google config to activated", async () => {
it("should update google config to activated", async () => {
const googleConf = await saveGoogleConfig({ activated: false })
jest.clearAllMocks()
await saveGoogleConfig({ ...googleConf.config, activated: true}, googleConf._id, googleConf._rev)
await saveGoogleConfig(
{ ...googleConf.config, activated: true },
googleConf._id,
googleConf._rev
)
expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE)
expect(events.auth.SSODeactivated).not.toBeCalled()
@ -92,17 +99,21 @@ describe("configs", () => {
expect(events.auth.SSOActivated).toBeCalledWith(Configs.GOOGLE)
await config.deleteConfig(Configs.GOOGLE)
})
})
})
})
describe("oidc", () => {
const saveOIDCConfig = async (conf, _id, _rev) => {
const saveOIDCConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const oidcConfig = structures.configs.oidc(conf)
return saveConfig(oidcConfig, _id, _rev)
}
describe("create", () => {
it ("should create activated OIDC config", async () => {
it("should create activated OIDC config", async () => {
await saveOIDCConfig()
expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Configs.OIDC)
@ -112,7 +123,7 @@ describe("configs", () => {
await config.deleteConfig(Configs.OIDC)
})
it ("should create deactivated OIDC config", async () => {
it("should create deactivated OIDC config", async () => {
await saveOIDCConfig({ activated: false })
expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Configs.OIDC)
@ -123,10 +134,14 @@ describe("configs", () => {
})
describe("update", () => {
it ("should update OIDC config to deactivated", async () => {
it("should update OIDC config to deactivated", async () => {
const oidcConf = await saveOIDCConfig()
jest.clearAllMocks()
await saveOIDCConfig({ ...oidcConf.config.configs[0], activated: false }, oidcConf._id, oidcConf._rev)
await saveOIDCConfig(
{ ...oidcConf.config.configs[0], activated: false },
oidcConf._id,
oidcConf._rev
)
expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC)
expect(events.auth.SSOActivated).not.toBeCalled()
@ -135,10 +150,14 @@ describe("configs", () => {
await config.deleteConfig(Configs.OIDC)
})
it ("should update OIDC config to activated", async () => {
it("should update OIDC config to activated", async () => {
const oidcConf = await saveOIDCConfig({ activated: false })
jest.clearAllMocks()
await saveOIDCConfig({ ...oidcConf.config.configs[0], activated: true}, oidcConf._id, oidcConf._rev)
await saveOIDCConfig(
{ ...oidcConf.config.configs[0], activated: true },
oidcConf._id,
oidcConf._rev
)
expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC)
expect(events.auth.SSODeactivated).not.toBeCalled()
@ -147,17 +166,20 @@ describe("configs", () => {
await config.deleteConfig(Configs.OIDC)
})
})
})
describe("smtp", () => {
const saveSMTPConfig = async (conf, _id, _rev) => {
const saveSMTPConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const smtpConfig = structures.configs.smtp(conf)
return saveConfig(smtpConfig, _id, _rev)
}
describe("create", () => {
it ("should create SMTP config", async () => {
it("should create SMTP config", async () => {
await config.deleteConfig(Configs.SMTP)
await saveSMTPConfig()
expect(events.email.SMTPUpdated).not.toBeCalled()
@ -167,7 +189,7 @@ describe("configs", () => {
})
describe("update", () => {
it ("should update SMTP config", async () => {
it("should update SMTP config", async () => {
const smtpConf = await saveSMTPConfig()
jest.clearAllMocks()
await saveSMTPConfig(smtpConf.config, smtpConf._id, smtpConf._rev)
@ -179,15 +201,19 @@ describe("configs", () => {
})
describe("settings", () => {
const saveSettingsConfig = async (conf, _id, _rev) => {
const saveSettingsConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const settingsConfig = structures.configs.settings(conf)
return saveConfig(settingsConfig, _id, _rev)
}
describe("create", () => {
it ("should create settings config with default settings", async () => {
it("should create settings config with default settings", async () => {
await config.deleteConfig(Configs.SETTINGS)
await saveSettingsConfig()
expect(events.org.nameUpdated).not.toBeCalled()
@ -195,35 +221,43 @@ describe("configs", () => {
expect(events.org.platformURLUpdated).not.toBeCalled()
})
it ("should create settings config with non-default settings", async () => {
it("should create settings config with non-default settings", async () => {
config.modeSelf()
await config.deleteConfig(Configs.SETTINGS)
const conf = {
company: "acme",
logoUrl: "http://example.com",
platformUrl: "http://example.com"
platformUrl: "http://example.com",
}
await saveSettingsConfig(conf)
expect(events.org.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).toBeCalledTimes(1)
config.modeAccount()
})
})
describe("update", () => {
it ("should update settings config", async () => {
it("should update settings config", async () => {
config.modeSelf()
await config.deleteConfig(Configs.SETTINGS)
const settingsConfig = await saveSettingsConfig()
settingsConfig.config.company = "acme"
settingsConfig.config.logoUrl = "http://example.com"
settingsConfig.config.platformUrl = "http://example.com"
await saveSettingsConfig(settingsConfig.config, settingsConfig._id, settingsConfig._rev)
await saveSettingsConfig(
settingsConfig.config,
settingsConfig._id,
settingsConfig._rev
)
expect(events.org.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).toBeCalledTimes(1)
config.modeAccount()
})
})
})
@ -232,12 +266,7 @@ describe("configs", () => {
it("should return the correct checklist status based on the state of the budibase installation", async () => {
await config.saveSmtpConfig()
const res = await request
.get(`/api/global/configs/checklist`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const res = await api.configs.getConfigChecklist()
const checklist = res.body
expect(checklist.apps.checked).toBeFalsy()

View file

@ -1,12 +1,11 @@
jest.mock("nodemailer")
const { config, mocks, structures, request } = require("../../../tests")
import { TestConfiguration, mocks, API } from "../../../../tests"
const sendMailMock = mocks.email.mock()
const { EmailTemplatePurpose } = require("../../../constants")
const TENANT_ID = structures.TENANT_ID
import { EmailTemplatePurpose } from "../../../../constants"
describe("/api/global/email", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
@ -20,16 +19,9 @@ describe("/api/global/email", () => {
// initially configure settings
await config.saveSmtpConfig()
await config.saveSettingsConfig()
const res = await request
.post(`/api/global/email/send`)
.send({
email: "test@test.com",
purpose: EmailTemplatePurpose.INVITATION,
tenantId: TENANT_ID,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const res = await api.emails.sendEmail(EmailTemplatePurpose.INVITATION)
expect(res.body.message).toBeDefined()
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]

View file

@ -1,5 +1,5 @@
const { config, request } = require("../../../tests")
const { EmailTemplatePurpose } = require("../../../constants")
import { TestConfiguration, API } from "../../../../tests"
import { EmailTemplatePurpose } from "../../../../constants"
const nodemailer = require("nodemailer")
const fetch = require("node-fetch")
@ -7,6 +7,8 @@ const fetch = require("node-fetch")
jest.setTimeout(30000)
describe("/api/global/email", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
@ -16,27 +18,24 @@ describe("/api/global/email", () => {
await config.afterAll()
})
async function sendRealEmail(purpose) {
async function sendRealEmail(purpose: string) {
let response, text
try {
const timeout = () => new Promise((resolve, reject) =>
setTimeout(() => reject({
status: 301,
errno: "ETIME"
}), 20000)
)
const timeout = () =>
new Promise((resolve, reject) =>
setTimeout(
() =>
reject({
status: 301,
errno: "ETIME",
}),
20000
)
)
await Promise.race([config.saveEtherealSmtpConfig(), timeout()])
await Promise.race([config.saveSettingsConfig(), timeout()])
const user = await config.getUser("test@test.com")
const res = await request
.post(`/api/global/email/send`)
.send({
email: "test@test.com",
purpose,
userId: user._id,
})
.set(config.defaultHeaders())
.timeout(20000)
const res = await api.emails.sendEmail(purpose).timeout(20000)
// ethereal hiccup, can't test right now
if (res.status >= 300) {
return
@ -47,7 +46,7 @@ describe("/api/global/email", () => {
expect(testUrl).toBeDefined()
response = await fetch(testUrl)
text = await response.text()
} catch (err) {
} catch (err: any) {
// ethereal hiccup, can't test right now
if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) {
return
@ -81,4 +80,4 @@ describe("/api/global/email", () => {
it("should be able to send a password recovery email", async () => {
await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
})
})
})

View file

@ -1,8 +1,10 @@
jest.mock("nodemailer")
const { config, request } = require("../../../tests")
const { events } = require("@budibase/backend-core")
import { TestConfiguration, API } from "../../../../tests"
import { events } from "@budibase/backend-core"
describe("/api/global/self", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
@ -16,23 +18,13 @@ describe("/api/global/self", () => {
jest.clearAllMocks()
})
const updateSelf = async (user) => {
const res = await request
.post(`/api/global/self`)
.send(user)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res
}
describe("update", () => {
it("should update self", async () => {
const user = await config.createUser()
await config.createSession(user)
delete user.password
const res = await updateSelf(user)
const res = await api.self.updateSelf(user)
expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1)
@ -42,10 +34,10 @@ describe("/api/global/self", () => {
it("should update password", async () => {
const user = await config.createUser()
const password = "newPassword"
user.password = password
await config.createSession(user)
const res = await updateSelf(user)
user.password = "newPassword"
const res = await api.self.updateSelf(user)
delete user.password
expect(res.body._id).toBe(user._id)
@ -55,4 +47,4 @@ describe("/api/global/self", () => {
expect(events.user.passwordUpdated).toBeCalledWith(user)
})
})
})
})

View file

@ -0,0 +1,464 @@
jest.mock("nodemailer")
import {
TestConfiguration,
mocks,
structures,
TENANT_1,
API,
} from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy } from "@budibase/backend-core"
describe("/api/global/users", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
beforeEach(() => {
jest.clearAllMocks()
})
describe("invite", () => {
it("should be able to generate an invitation", async () => {
const { code, res } = await api.users.sendUserInvite(sendMailMock)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.invited).toBeCalledTimes(1)
})
it("should be able to create new user from invite", async () => {
const { code } = await api.users.sendUserInvite(sendMailMock)
const res = await api.users.acceptInvite(code)
expect(res.body._id).toBeDefined()
const user = await config.getUser("invite@test.com")
expect(user).toBeDefined()
expect(user._id).toEqual(res.body._id)
expect(events.user.inviteAccepted).toBeCalledTimes(1)
expect(events.user.inviteAccepted).toBeCalledWith(user)
})
})
describe("bulkCreate", () => {
it("should ignore users existing in the same tenant", async () => {
const user = await config.createUser()
jest.clearAllMocks()
const response = await api.users.bulkCreateUsers([user])
expect(response.successful.length).toBe(0)
expect(response.unsuccessful.length).toBe(1)
expect(response.unsuccessful[0].email).toBe(user.email)
expect(events.user.created).toBeCalledTimes(0)
})
it("should ignore users existing in other tenants", async () => {
const user = await config.createUser()
jest.resetAllMocks()
await tenancy.doInTenant(TENANT_1, async () => {
const response = await api.users.bulkCreateUsers([user])
expect(response.successful.length).toBe(0)
expect(response.unsuccessful.length).toBe(1)
expect(response.unsuccessful[0].email).toBe(user.email)
expect(events.user.created).toBeCalledTimes(0)
})
})
it("should ignore accounts using the same email", async () => {
const account = structures.accounts.account()
const resp = await api.accounts.saveMetadata(account)
const user = structures.users.user({ email: resp.email })
jest.clearAllMocks()
const response = await api.users.bulkCreateUsers([user])
expect(response.successful.length).toBe(0)
expect(response.unsuccessful.length).toBe(1)
expect(response.unsuccessful[0].email).toBe(user.email)
expect(events.user.created).toBeCalledTimes(0)
})
it("should be able to bulkCreate users", async () => {
const builder = structures.users.builderUser()
const admin = structures.users.adminUser()
const user = structures.users.user()
const response = await api.users.bulkCreateUsers([builder, admin, user])
expect(response.successful.length).toBe(3)
expect(response.successful[0].email).toBe(builder.email)
expect(response.successful[1].email).toBe(admin.email)
expect(response.successful[2].email).toBe(user.email)
expect(response.unsuccessful.length).toBe(0)
expect(events.user.created).toBeCalledTimes(3)
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(2)
})
})
describe("create", () => {
it("should be able to create a basic user", async () => {
const user = structures.users.user()
await api.users.saveUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to create an admin user", async () => {
const user = structures.users.adminUser()
await api.users.saveUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
})
it("should be able to create a builder user", async () => {
const user = structures.users.builderUser()
await api.users.saveUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to assign app roles", async () => {
const user = structures.users.user()
user.roles = {
app_123: "role1",
app_456: "role2",
}
await api.users.saveUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.role.assigned).toBeCalledTimes(2)
expect(events.role.assigned).toBeCalledWith(savedUser, "role1")
expect(events.role.assigned).toBeCalledWith(savedUser, "role2")
})
it("should not be able to create user that exists in same tenant", async () => {
const user = await config.createUser()
jest.clearAllMocks()
delete user._id
delete user._rev
const response = await api.users.saveUser(user, 400)
expect(response.body.message).toBe(`Unavailable`)
expect(events.user.created).toBeCalledTimes(0)
})
it("should not be able to create user that exists in other tenant", async () => {
const user = await config.createUser()
jest.resetAllMocks()
await tenancy.doInTenant(TENANT_1, async () => {
delete user._id
const response = await api.users.saveUser(user, 400)
expect(response.body.message).toBe(`Unavailable`)
expect(events.user.created).toBeCalledTimes(0)
})
})
it("should not be able to create user with the same email as an account", async () => {
const user = structures.users.user()
const account = structures.accounts.cloudAccount()
mocks.accounts.getAccount.mockReturnValueOnce(account)
const response = await api.users.saveUser(user, 400)
expect(response.body.message).toBe(`Unavailable`)
expect(events.user.created).toBeCalledTimes(0)
})
})
describe("update", () => {
it("should be able to update a basic user", async () => {
const user = await config.createUser()
jest.clearAllMocks()
await api.users.saveUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).not.toBeCalled()
})
it("should be able to force reset password", async () => {
const user = await config.createUser()
jest.clearAllMocks()
user.forceResetPassword = true
user.password = "tempPassword"
await api.users.saveUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).toBeCalledTimes(1)
})
it("should be able to update a basic user to an admin user", async () => {
const user = await config.createUser()
jest.clearAllMocks()
await api.users.saveUser(structures.users.adminUser(user))
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
})
it("should be able to update a basic user to a builder user", async () => {
const user = await config.createUser()
jest.clearAllMocks()
await api.users.saveUser(structures.users.builderUser(user))
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to update an admin user to a basic user", async () => {
const user = await config.createUser(structures.users.adminUser())
jest.clearAllMocks()
user.admin!.global = false
user.builder!.global = false
await api.users.saveUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
})
it("should be able to update an builder user to a basic user", async () => {
const user = await config.createUser(structures.users.builderUser())
jest.clearAllMocks()
user.builder!.global = false
await api.users.saveUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should be able to assign app roles", async () => {
const user = await config.createUser()
jest.clearAllMocks()
user.roles = {
app_123: "role1",
app_456: "role2",
}
await api.users.saveUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.assigned).toBeCalledTimes(2)
expect(events.role.assigned).toBeCalledWith(savedUser, "role1")
expect(events.role.assigned).toBeCalledWith(savedUser, "role2")
})
it("should be able to unassign app roles", async () => {
let user = structures.users.user()
user.roles = {
app_123: "role1",
app_456: "role2",
}
user = await config.createUser(user)
jest.clearAllMocks()
user.roles = {}
await api.users.saveUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledTimes(2)
expect(events.role.unassigned).toBeCalledWith(savedUser, "role1")
expect(events.role.unassigned).toBeCalledWith(savedUser, "role2")
})
it("should be able to update existing app roles", async () => {
let user = structures.users.user()
user.roles = {
app_123: "role1",
app_456: "role2",
}
user = await config.createUser(user)
jest.clearAllMocks()
user.roles = {
app_123: "role1",
app_456: "role2-edit",
}
await api.users.saveUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledWith(savedUser, "role2")
expect(events.role.assigned).toBeCalledTimes(1)
expect(events.role.assigned).toBeCalledWith(savedUser, "role2-edit")
})
it("should not be able to update email address", async () => {
const email = "email@test.com"
const user = await config.createUser(structures.users.user({ email }))
user.email = "new@test.com"
const response = await api.users.saveUser(user, 400)
const dbUser = await config.getUser(email)
user.email = email
expect(user).toStrictEqual(dbUser)
expect(response.body.message).toBe("Email address cannot be changed")
})
})
describe("bulkDelete", () => {
it("should not be able to bulkDelete current user", async () => {
const user = await config.defaultUser!
const request = { userIds: [user._id!] }
const response = await api.users.bulkDeleteUsers(request, 400)
expect(response.body.message).toBe("Unable to delete self.")
expect(events.user.deleted).not.toBeCalled()
})
it("should not be able to bulkDelete account owner", async () => {
const user = await config.createUser()
const account = structures.accounts.cloudAccount()
account.budibaseUserId = user._id!
mocks.accounts.getAccountByTenantId.mockReturnValue(account)
const request = { userIds: [user._id!] }
const response = await api.users.bulkDeleteUsers(request)
expect(response.body.successful.length).toBe(0)
expect(response.body.unsuccessful.length).toBe(1)
expect(response.body.unsuccessful[0].reason).toBe(
"Account holder cannot be deleted"
)
expect(response.body.unsuccessful[0]._id).toBe(user._id)
expect(events.user.deleted).not.toBeCalled()
})
it("should be able to bulk delete users", async () => {
const account = structures.accounts.cloudAccount()
mocks.accounts.getAccountByTenantId.mockReturnValue(account)
const builder = structures.users.builderUser()
const admin = structures.users.adminUser()
const user = structures.users.user()
const createdUsers = await api.users.bulkCreateUsers([
builder,
admin,
user,
])
const request = { userIds: createdUsers.successful.map(u => u._id!) }
const response = await api.users.bulkDeleteUsers(request)
expect(response.body.successful.length).toBe(3)
expect(response.body.unsuccessful.length).toBe(0)
expect(events.user.deleted).toBeCalledTimes(3)
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(2)
})
})
describe("destroy", () => {
it("should be able to destroy a basic user", async () => {
const user = await config.createUser()
jest.clearAllMocks()
await api.users.deleteUser(user._id!)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).not.toBeCalled()
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should be able to destroy an admin user", async () => {
const user = await config.createUser(structures.users.adminUser())
jest.clearAllMocks()
await api.users.deleteUser(user._id!)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
})
it("should be able to destroy a builder user", async () => {
const user = await config.createUser(structures.users.builderUser())
jest.clearAllMocks()
await api.users.deleteUser(user._id!)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should not be able to destroy account owner", async () => {
const user = await config.createUser()
const account = structures.accounts.cloudAccount()
mocks.accounts.getAccount.mockReturnValueOnce(account)
const response = await api.users.deleteUser(user._id!, 400)
expect(response.body.message).toBe("Account holder cannot be deleted")
})
it("should not be able to destroy account owner as account owner", async () => {
const user = await config.defaultUser!
const account = structures.accounts.cloudAccount()
account.email = user.email
mocks.accounts.getAccount.mockReturnValueOnce(account)
const response = await api.users.deleteUser(user._id!, 400)
expect(response.body.message).toBe("Unable to delete self.")
})
})
})

View file

@ -12,6 +12,7 @@ const statusRoutes = require("./system/status")
const selfRoutes = require("./global/self")
const licenseRoutes = require("./global/license")
const migrationRoutes = require("./system/migrations")
const accountRoutes = require("./system/accounts")
let userGroupRoutes = api.groups
exports.routes = [
@ -29,4 +30,5 @@ exports.routes = [
licenseRoutes,
userGroupRoutes,
migrationRoutes,
accountRoutes,
]

View file

@ -0,0 +1,19 @@
import Router from "@koa/router"
import * as controller from "../../controllers/system/accounts"
import { middleware } from "@budibase/backend-core"
const router = new Router()
router
.put(
"/api/system/accounts/:accountId/metadata",
middleware.internalApi,
controller.save
)
.delete(
"/api/system/accounts/:accountId/metadata",
middleware.internalApi,
controller.destroy
)
export = router

View file

@ -0,0 +1,57 @@
import { accounts } from "../../../../sdk"
import { TestConfiguration, structures, API } from "../../../../tests"
import { v4 as uuid } from "uuid"
describe("accounts", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
beforeEach(() => {
jest.clearAllMocks()
})
describe("metadata", () => {
describe("saveMetadata", () => {
it("saves account metadata", async () => {
let account = structures.accounts.account()
const response = await api.accounts.saveMetadata(account)
const id = accounts.formatAccountMetadataId(account.accountId)
const metadata = await accounts.getMetadata(id)
expect(response).toStrictEqual(metadata)
})
})
describe("destroyMetadata", () => {
it("destroys account metadata", async () => {
const account = structures.accounts.account()
await api.accounts.saveMetadata(account)
await api.accounts.destroyMetadata(account.accountId)
const deleted = await accounts.getMetadata(account.accountId)
expect(deleted).toBe(undefined)
})
it("destroys account metadata that does not exist", async () => {
const id = uuid()
const response = await api.accounts.destroyMetadata(id)
expect(response.status).toBe(404)
expect(response.body.message).toBe(
`id=${accounts.formatAccountMetadataId(id)} does not exist`
)
})
})
})
})

View file

@ -1,390 +0,0 @@
jest.mock("nodemailer")
const { config, request, mocks, structures } = require("../../../tests")
const sendMailMock = mocks.email.mock()
const { events } = require("@budibase/backend-core")
describe("/api/global/users", () => {
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
const sendUserInvite = async () => {
await config.saveSmtpConfig()
await config.saveSettingsConfig()
const res = await request
.post(`/api/global/users/invite`)
.send({
email: "invite@test.com",
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=")
const code = parts[1].split("\"")[0].split("&")[0]
return { code, res }
}
it("should be able to generate an invitation", async () => {
const { code, res } = await sendUserInvite()
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.invited).toBeCalledTimes(1)
})
it("should be able to create new user from invite", async () => {
const { code } = await sendUserInvite()
const res = await request
.post(`/api/global/users/invite/accept`)
.send({
password: "newpassword",
inviteCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
const user = await config.getUser("invite@test.com")
expect(user).toBeDefined()
expect(user._id).toEqual(res.body._id)
expect(events.user.inviteAccepted).toBeCalledTimes(1)
expect(events.user.inviteAccepted).toBeCalledWith(user)
})
const createUser = async (user) => {
const existing = await config.getUser(user.email)
if (existing) {
await deleteUser(existing._id)
}
return saveUser(user)
}
const updateUser = async (user) => {
const existing = await config.getUser(user.email)
user._id = existing._id
return saveUser(user)
}
const saveUser = async (user) => {
const res = await request
.post(`/api/global/users`)
.send(user)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
const bulkCreateUsers = async (users) => {
const res = await request
.post(`/api/global/users/bulkCreate`)
.send(users)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
const bulkDeleteUsers = async (users) => {
const res = await request
.post(`/api/global/users/bulkDelete`)
.send(users)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
const deleteUser = async (email) => {
const user = await config.getUser(email)
if (user) {
await request
.delete(`/api/global/users/${user._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
}
describe("create", () => {
it("should be able to create a basic user", async () => {
jest.clearAllMocks()
const user = structures.users.user({ email: "basic@test.com" })
await createUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to bulkCreate users with different permissions", async () => {
jest.clearAllMocks()
const builder = structures.users.builderUser({ email: "bulkbasic@test.com" })
const admin = structures.users.adminUser({ email: "bulkadmin@test.com" })
const user = structures.users.user({ email: "bulkuser@test.com" })
let toCreate = { users: [builder, admin, user], groups: [] }
await bulkCreateUsers(toCreate)
expect(events.user.created).toBeCalledTimes(3)
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
})
it("should be able to create an admin user", async () => {
jest.clearAllMocks()
const user = structures.users.adminUser({ email: "admin@test.com" })
await createUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
})
it("should be able to create a builder user", async () => {
jest.clearAllMocks()
const user = structures.users.builderUser({ email: "builder@test.com" })
await createUser(user)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to assign app roles", async () => {
jest.clearAllMocks()
const user = structures.users.user({ email: "assign-roles@test.com" })
user.roles = {
"app_123": "role1",
"app_456": "role2",
}
await createUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).toBeCalledTimes(1)
expect(events.user.updated).not.toBeCalled()
expect(events.role.assigned).toBeCalledTimes(2)
expect(events.role.assigned).toBeCalledWith(savedUser, "role1")
expect(events.role.assigned).toBeCalledWith(savedUser, "role2")
})
})
describe("update", () => {
it("should be able to update a basic user", async () => {
let user = structures.users.user({ email: "basic-update@test.com" })
await createUser(user)
jest.clearAllMocks()
await updateUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).not.toBeCalled()
})
it("should be able to force reset password", async () => {
let user = structures.users.user({ email: "basic-password-update@test.com" })
await createUser(user)
jest.clearAllMocks()
user.forceResetPassword = true
user.password = "tempPassword"
await updateUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).toBeCalledTimes(1)
})
it("should be able to update a basic user to an admin user", async () => {
let user = structures.users.user({ email: "basic-update-admin@test.com" })
await createUser(user)
jest.clearAllMocks()
await updateUser(structures.users.adminUser(user))
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
})
it("should be able to update a basic user to a builder user", async () => {
let user = structures.users.user({ email: "basic-update-builder@test.com" })
await createUser(user)
jest.clearAllMocks()
await updateUser(structures.users.builderUser(user))
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
expect(events.user.permissionAdminAssigned).not.toBeCalled()
})
it("should be able to update an admin user to a basic user", async () => {
let user = structures.users.adminUser({ email: "admin-update-basic@test.com" })
await createUser(user)
jest.clearAllMocks()
user.admin.global = false
await updateUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).not.toBeCalled()
})
it("should be able to update an builder user to a basic user", async () => {
let user = structures.users.builderUser({ email: "builder-update-basic@test.com" })
await createUser(user)
jest.clearAllMocks()
user.builder.global = false
await updateUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should be able to assign app roles", async () => {
const user = structures.users.user({ email: "assign-roles-update@test.com" })
await createUser(user)
jest.clearAllMocks()
user.roles = {
"app_123": "role1",
"app_456": "role2",
}
await updateUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.assigned).toBeCalledTimes(2)
expect(events.role.assigned).toBeCalledWith(savedUser, "role1")
expect(events.role.assigned).toBeCalledWith(savedUser, "role2")
})
it("should be able to unassign app roles", async () => {
const user = structures.users.user({ email: "unassign-roles@test.com" })
user.roles = {
"app_123": "role1",
"app_456": "role2",
}
await createUser(user)
jest.clearAllMocks()
user.roles = {}
await updateUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledTimes(2)
expect(events.role.unassigned).toBeCalledWith(savedUser, "role1")
expect(events.role.unassigned).toBeCalledWith(savedUser, "role2")
})
it("should be able to update existing app roles", async () => {
const user = structures.users.user({ email: "update-roles@test.com" })
user.roles = {
"app_123": "role1",
"app_456": "role2",
}
await createUser(user)
jest.clearAllMocks()
user.roles = {
"app_123": "role1",
"app_456": "role2-edit",
}
await updateUser(user)
const savedUser = await config.getUser(user.email)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledTimes(1)
expect(events.role.unassigned).toBeCalledWith(savedUser, "role2")
expect(events.role.assigned).toBeCalledTimes(1)
expect(events.role.assigned).toBeCalledWith(savedUser, "role2-edit")
})
})
describe("destroy", () => {
it("should be able to destroy a basic user", async () => {
let user = structures.users.user({ email: "destroy@test.com" })
await createUser(user)
jest.clearAllMocks()
await deleteUser(user.email)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).not.toBeCalled()
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should be able to destroy an admin user", async () => {
let user = structures.users.adminUser({ email: "destroy-admin@test.com" })
await createUser(user)
jest.clearAllMocks()
await deleteUser(user.email)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).not.toBeCalled()
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
})
it("should be able to destroy a builder user", async () => {
let user = structures.users.builderUser({ email: "destroy-admin@test.com" })
await createUser(user)
jest.clearAllMocks()
await deleteUser(user.email)
expect(events.user.deleted).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
expect(events.user.permissionAdminRemoved).not.toBeCalled()
})
it("should be able to bulk delete users with different permissions", async () => {
jest.clearAllMocks()
const builder = structures.users.builderUser({ email: "basic@test.com" })
const admin = structures.users.adminUser({ email: "admin@test.com" })
const user = structures.users.user({ email: "user@test.com" })
let toCreate = { users: [builder, admin, user], groups: [] }
let createdUsers = await bulkCreateUsers(toCreate)
await bulkDeleteUsers({ userIds: [createdUsers[0]._id, createdUsers[1]._id, createdUsers[2]._id] })
expect(events.user.deleted).toBeCalledTimes(3)
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
})
})
})

View file

@ -20,13 +20,13 @@ if (!LOADED && isDev() && !isTest()) {
LOADED = true
}
function parseIntSafe(number) {
function parseIntSafe(number: any) {
if (number) {
return parseInt(number)
}
}
module.exports = {
const env = {
// auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
@ -47,7 +47,7 @@ module.exports = {
CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags
NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED),
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
@ -62,7 +62,7 @@ module.exports = {
// other
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
_set(key, value) {
_set(key: any, value: any) {
process.env[key] = value
module.exports[key] = value
},
@ -74,16 +74,17 @@ module.exports = {
}
// if some var haven't been set, define them
if (!module.exports.APPS_URL) {
module.exports.APPS_URL = isDev()
? "http://localhost:4001"
: "http://app-service:4002"
if (!env.APPS_URL) {
env.APPS_URL = isDev() ? "http://localhost:4001" : "http://app-service:4002"
}
// clean up any environment variable edge cases
for (let [key, value] of Object.entries(module.exports)) {
// handle the edge case of "0" to disable an environment variable
if (value === "0") {
module.exports[key] = 0
// @ts-ignore
env[key] = 0
}
}
export = env

View file

@ -71,9 +71,7 @@ server.on("close", async () => {
return
}
shuttingDown = true
if (!env.isTest()) {
console.log("Server Closed")
}
console.log("Server Closed")
await redis.shutdown()
await events.shutdown()
if (!env.isTest()) {
@ -86,7 +84,7 @@ const shutdown = () => {
server.destroy()
}
module.exports = server.listen(parseInt(env.PORT || 4002), async () => {
export = server.listen(parseInt(env.PORT || 4002), async () => {
console.log(`Worker running on ${JSON.stringify(server.address())}`)
await redis.init()
})
@ -100,3 +98,7 @@ process.on("uncaughtException", err => {
process.on("SIGTERM", () => {
shutdown()
})
process.on("SIGINT", () => {
shutdown()
})

View file

@ -0,0 +1,53 @@
import { AccountMetadata } from "@budibase/types"
import {
db,
StaticDatabases,
HTTPError,
DocumentType,
SEPARATOR,
} from "@budibase/backend-core"
export const formatAccountMetadataId = (accountId: string) => {
return `${DocumentType.ACCOUNT_METADATA}${SEPARATOR}${accountId}`
}
export const saveMetadata = async (
metadata: AccountMetadata
): Promise<AccountMetadata> => {
return db.doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: any) => {
const existing = await getMetadata(metadata._id!)
if (existing) {
metadata._rev = existing._rev
}
const res = await db.put(metadata)
metadata._rev = res.rev
return metadata
})
}
export const getMetadata = async (
accountId: string
): Promise<AccountMetadata | undefined> => {
return db.doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: any) => {
try {
return await db.get(accountId)
} catch (e: any) {
if (e.status === 404) {
// do nothing
return
} else {
throw e
}
}
})
}
export const destroyMetadata = async (accountId: string) => {
await db.doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: any) => {
const metadata = await getMetadata(accountId)
if (!metadata) {
throw new HTTPError(`id=${accountId} does not exist`, 404)
}
await db.remove(accountId, metadata._rev)
})
}

View file

@ -0,0 +1 @@
export * from "./accounts"

View file

@ -1 +1,2 @@
export * as users from "./users"
export * as accounts from "./accounts"

View file

@ -14,8 +14,23 @@ import {
HTTPError,
accounts,
migrations,
StaticDatabases,
ViewName,
} from "@budibase/backend-core"
import { MigrationType, User } from "@budibase/types"
import {
MigrationType,
PlatformUserByEmail,
User,
Account,
BulkCreateUsersResponse,
CreateUserResponse,
BulkDeleteUsersResponse,
CloudAccount,
AllDocsResponse,
RowResponse,
BulkDocsResponse,
AccountMetadata,
} from "@budibase/types"
import { groups as groupUtils } from "@budibase/pro"
const PAGE_LIMIT = 8
@ -98,7 +113,6 @@ export const getUser = async (userId: string) => {
interface SaveUserOpts {
hashPassword?: boolean
requirePassword?: boolean
bulkCreate?: boolean
}
const buildUser = async (
@ -109,7 +123,7 @@ const buildUser = async (
},
tenantId: string,
dbUser?: any
) => {
): Promise<User> => {
let { password, _id } = user
let hashedPassword
@ -143,62 +157,63 @@ const buildUser = async (
return user
}
const validateUniqueUser = async (email: string, tenantId: string) => {
// check budibase users in other tenants
if (env.MULTI_TENANCY) {
const tenantUser = await tenancy.getTenantUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw `Unavailable`
}
}
// check root account users in account portal
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) {
throw `Unavailable`
}
}
}
export const save = async (
user: any,
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
bulkCreate: false,
}
) => {
): Promise<CreateUserResponse> => {
const tenantId = tenancy.getTenantId()
const db = tenancy.getGlobalDB()
let { email, _id } = user
// make sure another user isn't using the same email
let dbUser: any
if (opts.bulkCreate) {
dbUser = null
let dbUser: User | undefined
if (_id) {
// try to get existing user from db
dbUser = (await db.get(_id)) as User
if (email && dbUser.email !== email) {
throw "Email address cannot be changed"
}
email = dbUser.email
} else if (email) {
// check budibase users inside the tenant
// no id was specified - load from email instead
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) {
throw `Email address ${email} already in use.`
if (dbUser && dbUser._id !== _id) {
throw `Unavailable`
}
// check budibase users in other tenants
if (env.MULTI_TENANCY) {
const tenantUser = await tenancy.getTenantUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw `Email address ${email} already in use.`
}
}
// check root account users in account portal
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) {
throw `Email address ${email} already in use.`
}
}
} else if (_id) {
dbUser = await db.get(_id)
} else {
throw new Error("_id or email is required")
}
await validateUniqueUser(email, tenantId)
let builtUser = await buildUser(user, opts, tenantId, dbUser)
// make sure we set the _id field for a new user
if (!_id) {
_id = builtUser._id
_id = builtUser._id!
}
try {
const putOpts = {
password: builtUser.password,
...user,
}
if (opts.bulkCreate) {
return putOpts
}
// save the user to db
let response
const putUserFn = () => {
@ -247,29 +262,87 @@ export const addTenant = async (
}
}
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
return dbUtils.queryGlobalView(ViewName.USER_BY_EMAIL, {
keys: emails,
include_docs: true,
arrayResponse: true,
})
}
const getExistingPlatformUsers = async (
emails: string[]
): Promise<PlatformUserByEmail[]> => {
return dbUtils.doWithDB(
StaticDatabases.PLATFORM_INFO.name,
async (infoDb: any) => {
const response: AllDocsResponse<PlatformUserByEmail> =
await infoDb.allDocs({
keys: emails,
include_docs: true,
})
return response.rows
.filter(row => row.doc && (row.error !== "not_found") !== null)
.map((row: any) => row.doc)
}
)
}
const getExistingAccounts = async (
emails: string[]
): Promise<AccountMetadata[]> => {
return dbUtils.queryPlatformView(ViewName.ACCOUNT_BY_EMAIL, {
keys: emails,
include_docs: true,
arrayResponse: true,
})
}
/**
* Apply a system-wide search on emails:
* - in tenant
* - cross tenant
* - accounts
* return an array of emails that match the supplied emails.
*/
const searchExistingEmails = async (emails: string[]) => {
let matchedEmails: string[] = []
const existingTenantUsers = await getExistingTenantUsers(emails)
matchedEmails.push(...existingTenantUsers.map(user => user.email))
const existingPlatformUsers = await getExistingPlatformUsers(emails)
matchedEmails.push(...existingPlatformUsers.map(user => user._id!))
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
return [...new Set(matchedEmails)]
}
export const bulkCreate = async (
newUsersRequested: User[],
groups: string[]
) => {
): Promise<BulkCreateUsersResponse> => {
const db = tenancy.getGlobalDB()
const tenantId = tenancy.getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
const allUsers = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
let mapped = allUsers.rows.map((row: any) => row.id)
const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails)
const unsuccessful: { email: string; reason: string }[] = []
const currentUserEmails = mapped.map((x: any) => x.email) || []
for (const newUser of newUsersRequested) {
if (
newUsers.find((x: any) => x.email === newUser.email) ||
currentUserEmails.includes(newUser.email)
existingEmails.includes(newUser.email)
) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue
}
newUser.userGroups = groups
@ -307,63 +380,130 @@ export const bulkCreate = async (
await apps.syncUserInApps(user._id)
}
return usersToBulkSave.map(user => {
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
return {
successful: saved,
unsuccessful,
}
}
export const bulkDelete = async (userIds: any) => {
/**
* For the given user id's, return the account holder if it is in the ids.
*/
const getAccountHolderFromUserIds = async (
userIds: string[]
): Promise<CloudAccount | undefined> => {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = tenancy.getTenantId()
const account = await accounts.getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
}
}
export const bulkDelete = async (
userIds: string[]
): Promise<BulkDeleteUsersResponse> => {
const db = tenancy.getGlobalDB()
const response: BulkDeleteUsersResponse = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
reason: "Account holder cannot be deleted",
})
}
let groupsToModify: any = {}
let builderCount = 0
// Get users and delete
let usersToDelete = (
await db.allDocs({
include_docs: true,
keys: userIds,
})
).rows.map((user: any) => {
// if we find a user that has an associated group, add it to
// an array so we can easily use allDocs on them later.
// This prevents us having to re-loop over all the users
if (user.doc.userGroups) {
for (let groupId of user.doc.userGroups) {
if (!Object.keys(groupsToModify).includes(groupId)) {
groupsToModify[groupId] = [user.id]
} else {
groupsToModify[groupId] = [...groupsToModify[groupId], user.id]
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
// if we find a user that has an associated group, add it to
// an array so we can easily use allDocs on them later.
// This prevents us having to re-loop over all the users
if (user.doc.userGroups) {
for (let groupId of user.doc.userGroups) {
if (!Object.keys(groupsToModify).includes(groupId)) {
groupsToModify[groupId] = [user.id]
} else {
groupsToModify[groupId] = [...groupsToModify[groupId], user.id]
}
}
}
// Also figure out how many builders are being deleted
if (eventHelpers.isAddingBuilder(user.doc, null)) {
builderCount++
}
return user.doc
}
)
// Also figure out how many builders are being deleted
if (eventHelpers.isAddingBuilder(user.doc, null)) {
builderCount++
}
return user.doc
})
const response = await db.bulkDocs(
usersToDelete.map((user: any) => ({
// Delete from DB
const dbResponse: BulkDocsResponse = await db.bulkDocs(
usersToDelete.map(user => ({
...user,
_deleted: true,
}))
)
// Deletion post processing
await groupUtils.bulkDeleteGroupUsers(groupsToModify)
//Deletion post processing
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
await quotas.removeDevelopers(builderCount)
// Build Response
// index users by id
const userIndex: { [key: string]: User } = {}
usersToDelete.reduce((prev, current) => {
prev[current._id!] = current
return prev
}, userIndex)
// add the successful and unsuccessful users to response
dbResponse.forEach(item => {
const email = userIndex[item.id].email
if (item.ok) {
response.successful.push({ _id: item.id, email })
} else {
response.unsuccessful.push({
_id: item.id,
email,
reason: "Database error",
})
}
})
return response
}

View file

@ -1,231 +0,0 @@
require("./mocks")
require("../db").init()
const env = require("../environment")
const controllers = require("./controllers")
const supertest = require("supertest")
const { jwt } = require("@budibase/backend-core/auth")
const { Cookies, Headers } = require("@budibase/backend-core/constants")
const { Configs } = require("../constants")
const { users } = require("@budibase/backend-core")
const { createASession } = require("@budibase/backend-core/sessions")
const { TENANT_ID, CSRF_TOKEN } = require("./structures")
const structures = require("./structures")
const { doInTenant } = require("@budibase/backend-core/tenancy")
const { groups } = require("@budibase/pro")
class TestConfiguration {
constructor(openServer = true) {
if (openServer) {
env.PORT = "0" // random port
this.server = require("../index")
// we need the request for logging in, involves cookies, hard to fake
this.request = supertest(this.server)
}
}
getRequest() {
return this.request
}
// UTILS
async _req(config, params, controlFunc) {
const request = {}
// fake cookies, we don't need them
request.cookies = { set: () => {}, get: () => {} }
request.config = { jwtSecret: env.JWT_SECRET }
request.appId = this.appId
request.user = { appId: this.appId, tenantId: TENANT_ID }
request.query = {}
request.request = {
body: config,
}
request.throw = (status, err) => {
throw { status, message: err }
}
if (params) {
request.params = params
}
await doInTenant(TENANT_ID, () => {
return controlFunc(request)
})
return request.body
}
// SETUP / TEARDOWN
async beforeAll() {
await this.login()
}
async afterAll() {
if (this.server) {
await this.server.close()
}
}
// USER / AUTH
async login() {
// create a test user
await this._req(
{
email: "test@test.com",
password: "test",
_id: "us_uuid1",
builder: {
global: true,
},
admin: {
global: true,
},
},
null,
controllers.users.save
)
await createASession("us_uuid1", {
sessionId: "sessionid",
tenantId: TENANT_ID,
csrfToken: CSRF_TOKEN,
})
}
cookieHeader(cookies) {
return {
Cookie: [cookies],
}
}
defaultHeaders() {
const user = {
_id: "us_uuid1",
userId: "us_uuid1",
sessionId: "sessionid",
tenantId: TENANT_ID,
}
const authToken = jwt.sign(user, env.JWT_SECRET)
return {
Accept: "application/json",
...this.cookieHeader([`${Cookies.Auth}=${authToken}`]),
[Headers.CSRF_TOKEN]: CSRF_TOKEN,
}
}
async getUser(email) {
return doInTenant(TENANT_ID, () => {
return users.getGlobalUserByEmail(email)
})
}
async getGroup(id) {
return doInTenant(TENANT_ID, () => {
return groups.get(id)
})
}
async saveGroup(group) {
const res = await this.getRequest()
.post(`/api/global/groups`)
.send(group)
.set(this.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
async createUser(email, password) {
const user = await this.getUser(structures.users.email)
if (user) {
return user
}
await this._req(
structures.users.user({ email, password }),
null,
controllers.users.save
)
}
async saveAdminUser() {
await this._req(
structures.users.user({ tenantId: TENANT_ID }),
null,
controllers.users.adminUser
)
}
// CONFIGS
async deleteConfig(type) {
try {
const cfg = await this._req(
null,
{
type,
},
controllers.config.find
)
if (cfg) {
await this._req(
null,
{
id: cfg._id,
rev: cfg._rev,
},
controllers.config.destroy
)
}
} catch (err) {
// don't need to handle error
}
}
// CONFIGS - SETTINGS
async saveSettingsConfig() {
await this.deleteConfig(Configs.SETTINGS)
await this._req(
structures.configs.settings(),
null,
controllers.config.save
)
}
// CONFIGS - GOOGLE
async saveGoogleConfig() {
await this.deleteConfig(Configs.GOOGLE)
await this._req(structures.configs.google(), null, controllers.config.save)
}
// CONFIGS - OIDC
getOIDConfigCookie(configId) {
const token = jwt.sign(configId, env.JWT_SECRET)
return this.cookieHeader([[`${Cookies.OIDC_CONFIG}=${token}`]])
}
async saveOIDCConfig() {
await this.deleteConfig(Configs.OIDC)
const config = structures.configs.oidc()
await this._req(config, null, controllers.config.save)
return config
}
// CONFIGS - SMTP
async saveSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(structures.configs.smtp(), null, controllers.config.save)
}
async saveEtherealSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(
structures.configs.smtpEthereal(),
null,
controllers.config.save
)
}
}
module.exports = TestConfiguration

View file

@ -0,0 +1,273 @@
import "./mocks"
import dbConfig from "../db"
dbConfig.init()
import env from "../environment"
import controllers from "./controllers"
const supertest = require("supertest")
import { Configs } from "../constants"
import {
users,
tenancy,
Cookies,
Headers,
sessions,
auth,
} from "@budibase/backend-core"
import { TENANT_ID, TENANT_1, CSRF_TOKEN } from "./structures"
import structures from "./structures"
import { CreateUserResponse, User, AuthToken } from "@budibase/types"
enum Mode {
ACCOUNT = "account",
SELF = "self",
}
class TestConfiguration {
server: any
request: any
defaultUser?: User
tenant1User?: User
constructor(
opts: { openServer: boolean; mode: Mode } = {
openServer: true,
mode: Mode.ACCOUNT,
}
) {
if (opts.mode === Mode.ACCOUNT) {
this.modeAccount()
} else if (opts.mode === Mode.SELF) {
this.modeSelf()
}
if (opts.openServer) {
env.PORT = "0" // random port
this.server = require("../index")
// we need the request for logging in, involves cookies, hard to fake
this.request = supertest(this.server)
}
}
getRequest() {
return this.request
}
// MODES
modeAccount = () => {
env.SELF_HOSTED = false
// @ts-ignore
env.MULTI_TENANCY = true
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = false
}
modeSelf = () => {
env.SELF_HOSTED = true
// @ts-ignore
env.MULTI_TENANCY = false
// @ts-ignore
env.DISABLE_ACCOUNT_PORTAL = true
}
// UTILS
async _req(config: any, params: any, controlFunc: any) {
const request: any = {}
// fake cookies, we don't need them
request.cookies = { set: () => {}, get: () => {} }
request.config = { jwtSecret: env.JWT_SECRET }
request.user = { tenantId: this.getTenantId() }
request.query = {}
request.request = {
body: config,
}
request.throw = (status: any, err: any) => {
throw { status, message: err }
}
if (params) {
request.params = params
}
await tenancy.doInTenant(this.getTenantId(), () => {
return controlFunc(request)
})
return request.body
}
// SETUP / TEARDOWN
async beforeAll() {
await this.createDefaultUser()
await this.createSession(this.defaultUser!)
await tenancy.doInTenant(TENANT_1, async () => {
await this.createTenant1User()
await this.createSession(this.tenant1User!)
})
}
async afterAll() {
if (this.server) {
await this.server.close()
}
}
// TENANCY
getTenantId() {
try {
return tenancy.getTenantId()
} catch (e: any) {
return TENANT_ID
}
}
// USER / AUTH
async createDefaultUser() {
const user = structures.users.adminUser({
email: "test@test.com",
password: "test",
})
this.defaultUser = await this.createUser(user)
}
async createTenant1User() {
const user = structures.users.adminUser({
email: "tenant1@test.com",
password: "test",
})
this.tenant1User = await this.createUser(user)
}
async createSession(user: User) {
await sessions.createASession(user._id!, {
sessionId: "sessionid",
tenantId: user.tenantId,
csrfToken: CSRF_TOKEN,
})
}
cookieHeader(cookies: any) {
return {
Cookie: [cookies],
}
}
authHeaders(user: User) {
const authToken: AuthToken = {
userId: user._id!,
sessionId: "sessionid",
tenantId: user.tenantId,
}
const authCookie = auth.jwt.sign(authToken, env.JWT_SECRET)
return {
Accept: "application/json",
...this.cookieHeader([`${Cookies.Auth}=${authCookie}`]),
[Headers.CSRF_TOKEN]: CSRF_TOKEN,
}
}
defaultHeaders() {
const tenantId = this.getTenantId()
if (tenantId === TENANT_ID) {
return this.authHeaders(this.defaultUser!)
} else if (tenantId === TENANT_1) {
return this.authHeaders(this.tenant1User!)
} else {
throw new Error("could not determine auth headers to use")
}
}
async getUser(email: string): Promise<User> {
return tenancy.doInTenant(this.getTenantId(), () => {
return users.getGlobalUserByEmail(email)
})
}
async createUser(user?: User) {
if (!user) {
user = structures.users.user()
}
const response = await this._req(user, null, controllers.users.save)
const body = response as CreateUserResponse
return this.getUser(body.email)
}
// CONFIGS
async deleteConfig(type: any) {
try {
const cfg = await this._req(
null,
{
type,
},
controllers.config.find
)
if (cfg) {
await this._req(
null,
{
id: cfg._id,
rev: cfg._rev,
},
controllers.config.destroy
)
}
} catch (err) {
// don't need to handle error
}
}
// CONFIGS - SETTINGS
async saveSettingsConfig() {
await this.deleteConfig(Configs.SETTINGS)
await this._req(
structures.configs.settings(),
null,
controllers.config.save
)
}
// CONFIGS - GOOGLE
async saveGoogleConfig() {
await this.deleteConfig(Configs.GOOGLE)
await this._req(structures.configs.google(), null, controllers.config.save)
}
// CONFIGS - OIDC
getOIDConfigCookie(configId: string) {
const token = auth.jwt.sign(configId, env.JWT_SECRET)
return this.cookieHeader([[`${Cookies.OIDC_CONFIG}=${token}`]])
}
async saveOIDCConfig() {
await this.deleteConfig(Configs.OIDC)
const config = structures.configs.oidc()
await this._req(config, null, controllers.config.save)
return config
}
// CONFIGS - SMTP
async saveSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(structures.configs.smtp(), null, controllers.config.save)
}
async saveEtherealSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(
structures.configs.smtpEthereal(),
null,
controllers.config.save
)
}
}
export = TestConfiguration

View file

@ -0,0 +1,28 @@
import { Account, AccountMetadata } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
export class AccountAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
saveMetadata = async (account: Account) => {
const res = await this.request
.put(`/api/system/accounts/${account.accountId}/metadata`)
.send(account)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body as AccountMetadata
}
destroyMetadata = (accountId: string) => {
return this.request
.del(`/api/system/accounts/${accountId}/metadata`)
.set(this.config.defaultHeaders())
}
}

View file

@ -0,0 +1,48 @@
import TestConfiguration from "../TestConfiguration"
export class AuthAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
updatePassword = (code: string) => {
return this.request
.post(`/api/global/auth/${this.config.getTenantId()}/reset/update`)
.send({
password: "newpassword",
resetCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
}
logout = () => {
return this.request
.post("/api/global/auth/logout")
.set(this.config.defaultHeaders())
.expect(200)
}
requestPasswordReset = async (sendMailMock: any) => {
await this.config.saveSmtpConfig()
await this.config.saveSettingsConfig()
await this.config.createUser()
const res = await this.request
.post(`/api/global/auth/${this.config.getTenantId()}/reset`)
.send({
email: "test@test.com",
})
.expect("Content-Type", /json/)
.expect(200)
const emailCall = sendMailMock.mock.calls[0][0]
const parts = emailCall.html.split(
`http://localhost:10000/builder/auth/reset?code=`
)
const code = parts[1].split('"')[0].split("&")[0]
return { code, res }
}
}

View file

@ -0,0 +1,40 @@
import TestConfiguration from "../TestConfiguration"
export class ConfigAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
getConfigChecklist = () => {
return this.request
.get(`/api/global/configs/checklist`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
saveConfig = (data: any) => {
return this.request
.post(`/api/global/configs`)
.send(data)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
OIDCCallback = (configId: string) => {
return this.request
.get(`/api/global/auth/${this.config.getTenantId()}/oidc/callback`)
.set(this.config.getOIDConfigCookie(configId))
}
getOIDCConfig = (configId: string) => {
return this.request.get(
`/api/global/auth/${this.config.getTenantId()}/oidc/configs/${configId}`
)
}
}

View file

@ -0,0 +1,24 @@
import TestConfiguration from "../TestConfiguration"
export class EmailAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
sendEmail = (purpose: string) => {
return this.request
.post(`/api/global/email/send`)
.send({
email: "test@test.com",
purpose,
tenantId: this.config.getTenantId(),
})
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
}

View file

@ -0,0 +1,25 @@
import TestConfiguration from "../TestConfiguration"
import { AccountAPI } from "./accounts"
import { AuthAPI } from "./auth"
import { ConfigAPI } from "./configs"
import { EmailAPI } from "./email"
import { SelfAPI } from "./self"
import { UserAPI } from "./users"
export default class API {
accounts: AccountAPI
auth: AuthAPI
configs: ConfigAPI
emails: EmailAPI
self: SelfAPI
users: UserAPI
constructor(config: TestConfiguration) {
this.accounts = new AccountAPI(config)
this.auth = new AuthAPI(config)
this.configs = new ConfigAPI(config)
this.emails = new EmailAPI(config)
this.self = new SelfAPI(config)
this.users = new UserAPI(config)
}
}

View file

@ -0,0 +1,21 @@
import TestConfiguration from "../TestConfiguration"
import { User } from "@budibase/types"
export class SelfAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
updateSelf = (user: User) => {
return this.request
.post(`/api/global/self`)
.send(user)
.set(this.config.authHeaders(user))
.expect("Content-Type", /json/)
.expect(200)
}
}

View file

@ -0,0 +1,95 @@
import {
BulkCreateUsersRequest,
BulkCreateUsersResponse,
BulkDeleteUsersRequest,
CreateUserResponse,
User,
UserDetails,
} from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
export class UserAPI {
config: TestConfiguration
request: any
constructor(config: TestConfiguration) {
this.config = config
this.request = config.request
}
// INVITE
sendUserInvite = async (sendMailMock: any) => {
await this.config.saveSmtpConfig()
await this.config.saveSettingsConfig()
const res = await this.request
.post(`/api/global/users/invite`)
.send({
email: "invite@test.com",
})
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split(
"http://localhost:10000/builder/invite?code="
)
const code = parts[1].split('"')[0].split("&")[0]
return { code, res }
}
acceptInvite = (code: string) => {
return this.request
.post(`/api/global/users/invite/accept`)
.send({
password: "newpassword",
inviteCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
}
// BULK
bulkCreateUsers = async (users: User[], groups: any[] = []) => {
const body: BulkCreateUsersRequest = { users, groups }
const res = await this.request
.post(`/api/global/users/bulkCreate`)
.send(body)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body as BulkCreateUsersResponse
}
bulkDeleteUsers = (body: BulkDeleteUsersRequest, status?: number) => {
return this.request
.post(`/api/global/users/bulkDelete`)
.send(body)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
// USER
saveUser = (user: User, status?: number) => {
return this.request
.post(`/api/global/users`)
.send(user)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
deleteUser = (userId: string, status?: number) => {
return this.request
.delete(`/api/global/users/${userId}`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
}

View file

@ -1,12 +0,0 @@
const TestConfiguration = require("./TestConfiguration")
const structures = require("./structures")
const mocks = require("./mocks")
const config = new TestConfiguration()
const request = config.getRequest()
module.exports = {
structures,
mocks,
config,
request,
}

View file

@ -0,0 +1,14 @@
import TestConfiguration from "./TestConfiguration"
import structures from "./structures"
import mocks from "./mocks"
import API from "./api"
const pkg = {
structures,
TENANT_1: structures.TENANT_1,
mocks,
TestConfiguration,
API,
}
export = pkg

View file

@ -1,5 +0,0 @@
const email = require("./email")
module.exports = {
email,
}

View file

@ -0,0 +1,7 @@
const email = require("./email")
import { mocks as coreMocks } from "@budibase/backend-core/tests"
export = {
email,
...coreMocks,
}

View file

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

View file

@ -1,14 +0,0 @@
const configs = require("./configs")
const users = require("./users")
const groups = require("./groups")
const TENANT_ID = "default"
const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306"
module.exports = {
configs,
users,
TENANT_ID,
CSRF_TOKEN,
groups,
}

View file

@ -0,0 +1,20 @@
import configs from "./configs"
import * as users from "./users"
import * as groups from "./groups"
import * as accounts from "./accounts"
const TENANT_ID = "default"
const TENANT_1 = "tenant1"
const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306"
const pkg = {
configs,
users,
accounts,
TENANT_ID,
TENANT_1,
CSRF_TOKEN,
groups,
}
export = pkg

View file

@ -1,24 +1,32 @@
export const email = "test@test.com"
import { AdminUser, BuilderUser, User } from "@budibase/types"
import { v4 as uuid } from "uuid"
export const user = (userProps: any) => {
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: any): User => {
return {
email: "test@test.com",
email: newEmail(),
password: "test",
roles: {},
...userProps,
}
}
export const adminUser = (userProps: any) => {
export const adminUser = (userProps?: any): AdminUser => {
return {
...user(userProps),
admin: {
global: true,
},
builder: {
global: true,
},
}
}
export const builderUser = (userProps: any) => {
export const builderUser = (userProps?: any): BuilderUser => {
return {
...user(userProps),
builder: {

View file

@ -55,6 +55,7 @@ exports.init = async () => {
exports.shutdown = async () => {
if (pwResetClient) await pwResetClient.finish()
if (invitationClient) await invitationClient.finish()
console.log("Redis shutdown")
}
/**

View file

@ -1094,6 +1094,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/uuid@8.3.4":
version "8.3.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/yargs-parser@*":
version "21.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"