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

View file

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

View file

@ -5,6 +5,8 @@ const {
SEPARATOR, SEPARATOR,
} = require("./utils") } = require("./utils")
const { getGlobalDB } = require("../tenancy") const { getGlobalDB } = require("../tenancy")
const { StaticDatabases } = require("./constants")
const { doWithDB } = require("./")
const DESIGN_DB = "_design/database" const DESIGN_DB = "_design/database"
@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc) 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 () => { exports.createUserAppView = async () => {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc let designDoc
@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => {
await db.put(designDoc) 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) => { exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = { const CreateFuncByName = {
[ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView,
@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
if (!db) { if (!db) {
db = getGlobalDB() db = getGlobalDB()
} }
try { return exports.queryView(viewName, params, db, CreateFuncByName)
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
}
}
} }

View file

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

View file

@ -17,6 +17,7 @@ import constants from "./constants"
import * as dbConstants from "./db/constants" import * as dbConstants from "./db/constants"
import logging from "./logging" import logging from "./logging"
import pino from "./pino" import pino from "./pino"
import * as middleware from "./middleware"
// mimic the outer package exports // mimic the outer package exports
import * as db from "./pkg/db" import * as db from "./pkg/db"
@ -57,6 +58,7 @@ const core = {
roles, roles,
...pino, ...pino,
...errorClasses, ...errorClasses,
middleware,
} }
export = core 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 * The tenancy modules should not be used here and it should be assumed that the tenancy context
* has not yet been populated. * has not yet been populated.
*/ */
module.exports = ( export = (
noAuthPatterns = [], noAuthPatterns = [],
opts: { publicAllowed: boolean; populateUser?: Function } = { opts: { publicAllowed: boolean; populateUser?: Function } = {
publicAllowed: false, publicAllowed: false,

View file

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

View file

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

View file

@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging") const { logWarn } = require("../logging")
const env = require("../environment") const env = require("../environment")
interface Session { interface CreateSession {
key: string
userId: string
sessionId: string sessionId: string
lastAccessedAt: string tenantId: string
createdAt: string
csrfToken?: 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 // a week in seconds
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = 86400 * 7
@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`
} }
export async function getSessionsForUser(userId: string) { export async function getSessionsForUser(userId: string): Promise<Session[]> {
if (!userId) { if (!userId) {
console.trace("Cannot get sessions for undefined userId") console.trace("Cannot get sessions for undefined userId")
return [] return []
} }
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessions = await client.scan(userId) const sessions: ScannedSession[] = await client.scan(userId)
return sessions.map((session: Session) => session.value) return sessions.map(session => session.value)
} }
export async function invalidateSessions( export async function invalidateSessions(
@ -39,33 +49,32 @@ export async function invalidateSessions(
try { try {
const reason = opts?.reason || "unknown" const reason = opts?.reason || "unknown"
let sessionIds: string[] = opts.sessionIds || [] let sessionIds: string[] = opts.sessionIds || []
let sessions: SessionKey let sessionKeys: SessionKey[]
// If no sessionIds, get all the sessions for the user // If no sessionIds, get all the sessions for the user
if (sessionIds.length === 0) { if (sessionIds.length === 0) {
sessions = await getSessionsForUser(userId) const sessions = await getSessionsForUser(userId)
sessions.forEach( sessionKeys = sessions.map(session => ({
(session: any) => key: makeSessionID(session.userId, session.sessionId),
(session.key = makeSessionID(session.userId, session.sessionId)) }))
)
} else { } else {
// use the passed array of sessionIds // use the passed array of sessionIds
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]
sessions = sessionIds.map((sessionId: string) => ({ sessionKeys = sessionIds.map(sessionId => ({
key: makeSessionID(userId, sessionId), key: makeSessionID(userId, sessionId),
})) }))
} }
if (sessions && sessions.length > 0) { if (sessionKeys && sessionKeys.length > 0) {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const promises = [] const promises = []
for (let session of sessions) { for (let sessionKey of sessionKeys) {
promises.push(client.delete(session.key)) promises.push(client.delete(sessionKey.key))
} }
if (!env.isTest()) { if (!env.isTest()) {
logWarn( logWarn(
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys
.map(session => session.key) .map(sessionKey => sessionKey.key)
.join(", ")}` .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 // invalidate all other sessions
await invalidateSessions(userId, { reason: "creation" }) await invalidateSessions(userId, { reason: "creation" })
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessionId = session.sessionId const sessionId = createSession.sessionId
if (!session.csrfToken) { const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4()
session.csrfToken = uuidv4() const key = makeSessionID(userId, sessionId)
}
session = { const session: Session = {
...session, ...createSession,
csrfToken,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(),
userId, userId,
} }
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) await client.store(key, session, EXPIRY_SECONDS)
} }
export async function updateSessionTTL(session: Session) { export async function updateSessionTTL(session: Session) {
@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) {
await client.delete(makeSessionID(userId, sessionId)) 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) { if (!userId || !sessionId) {
throw new Error(`Invalid session details - ${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 * Given an email address this will use a view to search through
* all the users to find one with this email address. * all the users to find one with this email address.
* @param {string} email the email to lookup the user by. * @param {string} email the email to lookup the user by.
* @return {Promise<object|null>}
*/ */
exports.getGlobalUserByEmail = async email => { exports.getGlobalUserByEmail = async email => {
if (email == null) { 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 { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte" import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
import { parseToCsv } from "helpers/data/utils" import { parseToCsv } from "helpers/data/utils"
import { onMount } from "svelte"
export let userData export let userData
export let createUsersResponse
$: mappedData = userData.map(user => { 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 { return {
email: user.email, email: user.email,
password: user.password, password: userDataIndex[user.email].password,
} }
}) })
const schema = { 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 successSchema = {
email: {}, email: {},
password: {}, password: {},
} }
const failedSchema = {
email: {},
reason: {},
}
const downloadCsvFile = () => { const downloadCsvFile = () => {
const fileName = "passwords.csv" const fileName = "passwords.csv"
const content = parseToCsv(["email", "password"], mappedData) const content = parseToCsv(["email", "password"], successfulUsers)
download(fileName, content) download(fileName, content)
} }
@ -42,16 +96,29 @@
</script> </script>
<ModalContent <ModalContent
size="S" size="M"
title="Accounts created!" {title}
confirmText="Done" confirmText="Done"
showCancelButton={false} showCancelButton={false}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
> >
{#if hasFailure}
<Body size="XS"> <Body size="XS">
All your new users can be accessed through the autogenerated passwords. Take {failureMessage}
note of these passwords or download the CSV file. </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> </Body>
<div class="container" on:click={downloadCsvFile}> <div class="container" on:click={downloadCsvFile}>
@ -65,13 +132,16 @@
</div> </div>
<Table <Table
{schema} schema={successSchema}
data={mappedData} data={successfulUsers}
allowEditColumns={false} allowEditColumns={false}
allowEditRows={false} allowEditRows={false}
allowSelectRows={false} allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]} customRenderers={[
{ column: "password", component: PasswordCopyRenderer },
]}
/> />
{/if}
</ModalContent> </ModalContent>
<style> <style>

View file

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

View file

@ -63,10 +63,14 @@ export function createUsersStore() {
return body 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 // re-search from first page
await search() await search()
return response
} }
async function del(id) { async function del(id) {
@ -79,7 +83,7 @@ export function createUsersStore() {
} }
async function bulkDelete(userIds) { async function bulkDelete(userIds) {
await API.deleteUsers(userIds) return API.deleteUsers(userIds)
} }
async function save(user) { async function save(user) {

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ exports.shutdown = async () => {
if (devAppClient) await devAppClient.finish() if (devAppClient) await devAppClient.finish()
if (debounceClient) await debounceClient.finish() if (debounceClient) await debounceClient.finish()
if (flagClient) await flagClient.finish() if (flagClient) await flagClient.finish()
console.log("Redis shutdown")
} }
exports.doesUserHaveLock = async (devAppId, user) => { 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 "./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 status?: string
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now() createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
userGroups?: string[] userGroups?: string[]
forceResetPassword?: boolean
} }
export interface UserRoles { export interface UserRoles {
[key: string]: string [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 { Document } from "../document"
import { User } from "./user"
export interface UserGroup extends Document { export interface UserGroup extends Document {
name: string name: string
icon: string icon: string
color: string color: string
users: groupUser[] users: GroupUser[]
apps: string[] apps: string[]
roles: UserGroupRoles roles: UserGroupRoles
createdAt?: number createdAt?: number
} }
export interface groupUser { export interface GroupUser {
_id: string _id: string
email: string[] email: string
} }
export interface UserGroupRoles { export interface UserGroupRoles {
[key: string]: string [key: string]: string
} }

View file

@ -3,3 +3,4 @@ export * from "./app"
export * from "./global" export * from "./global"
export * from "./platform" export * from "./platform"
export * from "./document" 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 "./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 "./migrations"
export * from "./datasources" export * from "./datasources"
export * from "./search" export * from "./search"
export * from "./auth"

View file

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

View file

@ -14,3 +14,9 @@ const tk = require("timekeeper")
tk.freeze(mocks.date.MOCK_DATE) tk.freeze(mocks.date.MOCK_DATE)
global.console.log = jest.fn() // console.log are ignored in tests 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 { sendEmail } from "../../../utilities/email"
import { users } from "../../../sdk" import { users } from "../../../sdk"
import env from "../../../environment" import env from "../../../environment"
import { CloudAccount, User } from "@budibase/types" import { BulkDeleteUsersRequest, CloudAccount, User } from "@budibase/types"
import { import {
accounts, accounts,
cache, cache,
@ -46,8 +46,8 @@ export const bulkCreate = async (ctx: any) => {
} }
try { try {
let response = await users.bulkCreate(newUsersRequested, groups) const response = await users.bulkCreate(newUsersRequested, groups)
await groupUtils.bulkSaveGroupUsers(groupsToSave, response) await groupUtils.bulkSaveGroupUsers(groupsToSave, response.successful)
ctx.body = response ctx.body = response
} catch (err: any) { } catch (err: any) {
@ -138,17 +138,15 @@ export const destroy = async (ctx: any) => {
} }
export const bulkDelete = 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) { if (userIds?.indexOf(ctx.user._id) !== -1) {
ctx.throw(400, "Unable to delete self.") ctx.throw(400, "Unable to delete self.")
} }
try { try {
let usersResponse = await users.bulkDelete(userIds) let response = await users.bulkDelete(userIds)
ctx.body = { ctx.body = response
message: `${usersResponse.length} user(s) deleted`,
}
} catch (err) { } catch (err) {
ctx.throw(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 compress = require("koa-compress")
const zlib = require("zlib") const zlib = require("zlib")
const { routes } = require("./routes") import { routes } from "./routes"
const { import { middleware as pro } from "@budibase/pro"
buildAuthMiddleware, import { errors, auth, middleware } from "@budibase/backend-core"
auditLog, import { APIError } from "@budibase/types"
buildTenancyMiddleware,
buildCsrfMiddleware,
} = require("@budibase/backend-core/auth")
const { middleware: pro } = require("@budibase/pro")
const { errors } = require("@budibase/backend-core")
const PUBLIC_ENDPOINTS = [ const PUBLIC_ENDPOINTS = [
// old deprecated endpoints kept for backwards compat // old deprecated endpoints kept for backwards compat
@ -97,9 +92,9 @@ router
}) })
) )
.use("/health", ctx => (ctx.status = 200)) .use("/health", ctx => (ctx.status = 200))
.use(buildAuthMiddleware(PUBLIC_ENDPOINTS)) .use(auth.buildAuthMiddleware(PUBLIC_ENDPOINTS))
.use(buildTenancyMiddleware(PUBLIC_ENDPOINTS, NO_TENANCY_ENDPOINTS)) .use(auth.buildTenancyMiddleware(PUBLIC_ENDPOINTS, NO_TENANCY_ENDPOINTS))
.use(buildCsrfMiddleware({ noCsrfPatterns: NO_CSRF_ENDPOINTS })) .use(auth.buildCsrfMiddleware({ noCsrfPatterns: NO_CSRF_ENDPOINTS }))
.use(pro.licensing()) .use(pro.licensing())
// for now no public access is allowed to worker (bar health check) // for now no public access is allowed to worker (bar health check)
.use((ctx, next) => { .use((ctx, next) => {
@ -114,21 +109,22 @@ router
} }
return next() return next()
}) })
.use(auditLog) .use(middleware.auditLog)
// error handling middleware - TODO: This could be moved to backend-core // error handling middleware - TODO: This could be moved to backend-core
router.use(async (ctx, next) => { router.use(async (ctx, next) => {
try { try {
await next() await next()
} catch (err) { } catch (err: any) {
ctx.log.error(err) ctx.log.error(err)
ctx.status = err.status || err.statusCode || 500 ctx.status = err.status || err.statusCode || 500
const error = errors.getPublicError(err) const error = errors.getPublicError(err)
ctx.body = { const body: APIError = {
message: err.message, message: err.message,
status: ctx.status, status: ctx.status,
error, error,
} }
ctx.body = body
} }
}) })

View file

@ -1,11 +1,11 @@
jest.mock("nodemailer") jest.mock("nodemailer")
const { config, request, mocks, structures } = require("../../../tests") import { TestConfiguration, mocks, API } from "../../../../tests"
const sendMailMock = mocks.email.mock() const sendMailMock = mocks.email.mock()
const { events } = require("@budibase/backend-core") import { events } from "@budibase/backend-core"
const TENANT_ID = structures.TENANT_ID
describe("/api/global/auth", () => { describe("/api/global/auth", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => { beforeAll(async () => {
await config.beforeAll() await config.beforeAll()
@ -19,36 +19,18 @@ describe("/api/global/auth", () => {
jest.clearAllMocks() 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 () => { it("should logout", async () => {
await request await api.auth.logout()
.post("/api/global/auth/logout")
.set(config.defaultHeaders())
.expect(200)
expect(events.auth.logout).toBeCalledTimes(1) expect(events.auth.logout).toBeCalledTimes(1)
}) })
it("should be able to generate password reset email", async () => { 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") 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(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined() expect(code).toBeDefined()
@ -57,18 +39,12 @@ describe("/api/global/auth", () => {
}) })
it("should allow resetting user password with code", async () => { 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") const user = await config.getUser("test@test.com")
delete user.password delete user.password
const res = await request const res = await api.auth.updatePassword(code)
.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(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1) expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user) expect(events.user.passwordReset).toBeCalledWith(user)
@ -79,8 +55,8 @@ describe("/api/global/auth", () => {
const passportSpy = jest.spyOn(auth.passport, "authenticate") const passportSpy = jest.spyOn(auth.passport, "authenticate")
let oidcConf let oidcConf
let chosenConfig let chosenConfig: any
let configId let configId: string
// mock the oidc strategy implementation and return value // mock the oidc strategy implementation and return value
let strategyFactory = jest.fn() let strategyFactory = jest.fn()
@ -99,34 +75,34 @@ describe("/api/global/auth", () => {
}) })
afterEach(() => { afterEach(() => {
expect(strategyFactory).toBeCalledWith( expect(strategyFactory).toBeCalledWith(chosenConfig, expect.any(Function))
chosenConfig,
expect.any(Function)
)
}) })
describe("oidc configs", () => { describe("oidc configs", () => {
it("should load strategy and delegate to passport", async () => { 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, { 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", () => { describe("oidc callback", () => {
it("should load strategy and delegate to passport", async () => { it("should load strategy and delegate to passport", async () => {
await request.get(`/api/global/auth/${TENANT_ID}/oidc/callback`) await api.configs.OIDCCallback(configId)
.set(config.getOIDConfigCookie(configId))
expect(passportSpy).toBeCalledWith(mockStrategyReturn, { expect(passportSpy).toBeCalledWith(
successRedirect: "/", failureRedirect: "/error" mockStrategyReturn,
}, expect.anything()) {
expect(passportSpy.mock.calls.length).toBe(1); successRedirect: "/",
failureRedirect: "/error",
},
expect.anything()
)
expect(passportSpy.mock.calls.length).toBe(1)
}) })
}) })
}) })
}) })

View file

@ -1,11 +1,12 @@
// mock the email system // mock the email system
jest.mock("nodemailer") jest.mock("nodemailer")
const { config, structures, mocks, request } = require("../../../tests") import { TestConfiguration, structures, mocks, API } from "../../../../tests"
mocks.email.mock() mocks.email.mock()
const { Configs } = require("@budibase/backend-core/constants") import { Configs, events } from "@budibase/backend-core"
const { events } = require("@budibase/backend-core")
describe("configs", () => { describe("configs", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => { beforeAll(async () => {
await config.beforeAll() await config.beforeAll()
@ -20,29 +21,27 @@ describe("configs", () => {
}) })
describe("post /api/global/configs", () => { describe("post /api/global/configs", () => {
const saveConfig = async (conf: any, _id?: string, _rev?: string) => {
const saveConfig = async (conf, _id, _rev) => {
const data = { const data = {
...conf, ...conf,
_id, _id,
_rev _rev,
} }
const res = await request const res = await api.configs.saveConfig(data)
.post(`/api/global/configs`)
.send(data)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return { return {
...data, ...data,
...res.body ...res.body,
} }
} }
describe("google", () => { describe("google", () => {
const saveGoogleConfig = async (conf, _id, _rev) => { const saveGoogleConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const googleConfig = structures.configs.google(conf) const googleConfig = structures.configs.google(conf)
return saveConfig(googleConfig, _id, _rev) return saveConfig(googleConfig, _id, _rev)
} }
@ -72,7 +71,11 @@ describe("configs", () => {
it("should update google config to deactivated", async () => { it("should update google config to deactivated", async () => {
const googleConf = await saveGoogleConfig() const googleConf = await saveGoogleConfig()
jest.clearAllMocks() 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).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
@ -84,7 +87,11 @@ describe("configs", () => {
it("should update google config to activated", async () => { it("should update google config to activated", async () => {
const googleConf = await saveGoogleConfig({ activated: false }) const googleConf = await saveGoogleConfig({ activated: false })
jest.clearAllMocks() 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).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
@ -96,7 +103,11 @@ describe("configs", () => {
}) })
describe("oidc", () => { describe("oidc", () => {
const saveOIDCConfig = async (conf, _id, _rev) => { const saveOIDCConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const oidcConfig = structures.configs.oidc(conf) const oidcConfig = structures.configs.oidc(conf)
return saveConfig(oidcConfig, _id, _rev) return saveConfig(oidcConfig, _id, _rev)
} }
@ -126,7 +137,11 @@ describe("configs", () => {
it("should update OIDC config to deactivated", async () => { it("should update OIDC config to deactivated", async () => {
const oidcConf = await saveOIDCConfig() const oidcConf = await saveOIDCConfig()
jest.clearAllMocks() 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).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
@ -138,7 +153,11 @@ describe("configs", () => {
it("should update OIDC config to activated", async () => { it("should update OIDC config to activated", async () => {
const oidcConf = await saveOIDCConfig({ activated: false }) const oidcConf = await saveOIDCConfig({ activated: false })
jest.clearAllMocks() 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).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
@ -147,11 +166,14 @@ describe("configs", () => {
await config.deleteConfig(Configs.OIDC) await config.deleteConfig(Configs.OIDC)
}) })
}) })
}) })
describe("smtp", () => { describe("smtp", () => {
const saveSMTPConfig = async (conf, _id, _rev) => { const saveSMTPConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const smtpConfig = structures.configs.smtp(conf) const smtpConfig = structures.configs.smtp(conf)
return saveConfig(smtpConfig, _id, _rev) return saveConfig(smtpConfig, _id, _rev)
} }
@ -179,7 +201,11 @@ describe("configs", () => {
}) })
describe("settings", () => { describe("settings", () => {
const saveSettingsConfig = async (conf, _id, _rev) => { const saveSettingsConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const settingsConfig = structures.configs.settings(conf) const settingsConfig = structures.configs.settings(conf)
return saveConfig(settingsConfig, _id, _rev) return saveConfig(settingsConfig, _id, _rev)
} }
@ -196,11 +222,12 @@ describe("configs", () => {
}) })
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) await config.deleteConfig(Configs.SETTINGS)
const conf = { const conf = {
company: "acme", company: "acme",
logoUrl: "http://example.com", logoUrl: "http://example.com",
platformUrl: "http://example.com" platformUrl: "http://example.com",
} }
await saveSettingsConfig(conf) await saveSettingsConfig(conf)
@ -208,22 +235,29 @@ describe("configs", () => {
expect(events.org.nameUpdated).toBeCalledTimes(1) expect(events.org.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).toBeCalledTimes(1) expect(events.org.platformURLUpdated).toBeCalledTimes(1)
config.modeAccount()
}) })
}) })
describe("update", () => { describe("update", () => {
it("should update settings config", async () => { it("should update settings config", async () => {
config.modeSelf()
await config.deleteConfig(Configs.SETTINGS) await config.deleteConfig(Configs.SETTINGS)
const settingsConfig = await saveSettingsConfig() const settingsConfig = await saveSettingsConfig()
settingsConfig.config.company = "acme" settingsConfig.config.company = "acme"
settingsConfig.config.logoUrl = "http://example.com" settingsConfig.config.logoUrl = "http://example.com"
settingsConfig.config.platformUrl = "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.nameUpdated).toBeCalledTimes(1)
expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1)
expect(events.org.platformURLUpdated).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 () => { it("should return the correct checklist status based on the state of the budibase installation", async () => {
await config.saveSmtpConfig() await config.saveSmtpConfig()
const res = await request const res = await api.configs.getConfigChecklist()
.get(`/api/global/configs/checklist`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const checklist = res.body const checklist = res.body
expect(checklist.apps.checked).toBeFalsy() expect(checklist.apps.checked).toBeFalsy()

View file

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

View file

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

View file

@ -1,8 +1,10 @@
jest.mock("nodemailer") jest.mock("nodemailer")
const { config, request } = require("../../../tests") import { TestConfiguration, API } from "../../../../tests"
const { events } = require("@budibase/backend-core") import { events } from "@budibase/backend-core"
describe("/api/global/self", () => { describe("/api/global/self", () => {
const config = new TestConfiguration()
const api = new API(config)
beforeAll(async () => { beforeAll(async () => {
await config.beforeAll() await config.beforeAll()
@ -16,23 +18,13 @@ describe("/api/global/self", () => {
jest.clearAllMocks() 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", () => { describe("update", () => {
it("should update self", async () => { it("should update self", async () => {
const user = await config.createUser() const user = await config.createUser()
await config.createSession(user)
delete user.password delete user.password
const res = await updateSelf(user) const res = await api.self.updateSelf(user)
expect(res.body._id).toBe(user._id) expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1) expect(events.user.updated).toBeCalledTimes(1)
@ -42,10 +34,10 @@ describe("/api/global/self", () => {
it("should update password", async () => { it("should update password", async () => {
const user = await config.createUser() const user = await config.createUser()
const password = "newPassword" await config.createSession(user)
user.password = password
const res = await updateSelf(user) user.password = "newPassword"
const res = await api.self.updateSelf(user)
delete user.password delete user.password
expect(res.body._id).toBe(user._id) expect(res.body._id).toBe(user._id)

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 selfRoutes = require("./global/self")
const licenseRoutes = require("./global/license") const licenseRoutes = require("./global/license")
const migrationRoutes = require("./system/migrations") const migrationRoutes = require("./system/migrations")
const accountRoutes = require("./system/accounts")
let userGroupRoutes = api.groups let userGroupRoutes = api.groups
exports.routes = [ exports.routes = [
@ -29,4 +30,5 @@ exports.routes = [
licenseRoutes, licenseRoutes,
userGroupRoutes, userGroupRoutes,
migrationRoutes, 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 LOADED = true
} }
function parseIntSafe(number) { function parseIntSafe(number: any) {
if (number) { if (number) {
return parseInt(number) return parseInt(number)
} }
} }
module.exports = { const env = {
// auth // auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
@ -47,7 +47,7 @@ module.exports = {
CLUSTER_PORT: process.env.CLUSTER_PORT, CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags // flags
NODE_ENV: process.env.NODE_ENV, 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, LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
@ -62,7 +62,7 @@ module.exports = {
// other // other
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
_set(key, value) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
module.exports[key] = value module.exports[key] = value
}, },
@ -74,16 +74,17 @@ module.exports = {
} }
// if some var haven't been set, define them // if some var haven't been set, define them
if (!module.exports.APPS_URL) { if (!env.APPS_URL) {
module.exports.APPS_URL = isDev() env.APPS_URL = isDev() ? "http://localhost:4001" : "http://app-service:4002"
? "http://localhost:4001"
: "http://app-service:4002"
} }
// clean up any environment variable edge cases // clean up any environment variable edge cases
for (let [key, value] of Object.entries(module.exports)) { for (let [key, value] of Object.entries(module.exports)) {
// handle the edge case of "0" to disable an environment variable // handle the edge case of "0" to disable an environment variable
if (value === "0") { 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 return
} }
shuttingDown = true shuttingDown = true
if (!env.isTest()) {
console.log("Server Closed") console.log("Server Closed")
}
await redis.shutdown() await redis.shutdown()
await events.shutdown() await events.shutdown()
if (!env.isTest()) { if (!env.isTest()) {
@ -86,7 +84,7 @@ const shutdown = () => {
server.destroy() 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())}`) console.log(`Worker running on ${JSON.stringify(server.address())}`)
await redis.init() await redis.init()
}) })
@ -100,3 +98,7 @@ process.on("uncaughtException", err => {
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
shutdown() 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 users from "./users"
export * as accounts from "./accounts"

View file

@ -14,8 +14,23 @@ import {
HTTPError, HTTPError,
accounts, accounts,
migrations, migrations,
StaticDatabases,
ViewName,
} from "@budibase/backend-core" } 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" import { groups as groupUtils } from "@budibase/pro"
const PAGE_LIMIT = 8 const PAGE_LIMIT = 8
@ -98,7 +113,6 @@ export const getUser = async (userId: string) => {
interface SaveUserOpts { interface SaveUserOpts {
hashPassword?: boolean hashPassword?: boolean
requirePassword?: boolean requirePassword?: boolean
bulkCreate?: boolean
} }
const buildUser = async ( const buildUser = async (
@ -109,7 +123,7 @@ const buildUser = async (
}, },
tenantId: string, tenantId: string,
dbUser?: any dbUser?: any
) => { ): Promise<User> => {
let { password, _id } = user let { password, _id } = user
let hashedPassword let hashedPassword
@ -143,33 +157,12 @@ const buildUser = async (
return user return user
} }
export const save = async ( const validateUniqueUser = async (email: string, tenantId: string) => {
user: any,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
bulkCreate: false,
}
) => {
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
} else if (email) {
// check budibase users inside the tenant
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) {
throw `Email address ${email} already in use.`
}
// check budibase users in other tenants // check budibase users in other tenants
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {
const tenantUser = await tenancy.getTenantUser(email) const tenantUser = await tenancy.getTenantUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) { if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw `Email address ${email} already in use.` throw `Unavailable`
} }
} }
@ -177,28 +170,50 @@ export const save = async (
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(email) const account = await accounts.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) { if (account && account.verified && account.tenantId !== tenantId) {
throw `Email address ${email} already in use.` throw `Unavailable`
} }
} }
} else if (_id) {
dbUser = await db.get(_id)
} }
export const save = async (
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
}
): Promise<CreateUserResponse> => {
const tenantId = tenancy.getTenantId()
const db = tenancy.getGlobalDB()
let { email, _id } = user
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) {
// no id was specified - load from email instead
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser && dbUser._id !== _id) {
throw `Unavailable`
}
} else {
throw new Error("_id or email is required")
}
await validateUniqueUser(email, tenantId)
let builtUser = await buildUser(user, opts, tenantId, dbUser) let builtUser = await buildUser(user, opts, tenantId, dbUser)
// make sure we set the _id field for a new user // make sure we set the _id field for a new user
if (!_id) { if (!_id) {
_id = builtUser._id _id = builtUser._id!
} }
try { try {
const putOpts = {
password: builtUser.password,
...user,
}
if (opts.bulkCreate) {
return putOpts
}
// save the user to db // save the user to db
let response let response
const putUserFn = () => { 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 ( export const bulkCreate = async (
newUsersRequested: User[], newUsersRequested: User[],
groups: string[] groups: string[]
) => { ): Promise<BulkCreateUsersResponse> => {
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const tenantId = tenancy.getTenantId() const tenantId = tenancy.getTenantId()
let usersToSave: any[] = [] let usersToSave: any[] = []
let newUsers: any[] = [] let newUsers: any[] = []
const allUsers = await db.allDocs( const emails = newUsersRequested.map((user: User) => user.email)
dbUtils.getGlobalUserParams(null, { const existingEmails = await searchExistingEmails(emails)
include_docs: true, const unsuccessful: { email: string; reason: string }[] = []
})
)
let mapped = allUsers.rows.map((row: any) => row.id)
const currentUserEmails = mapped.map((x: any) => x.email) || []
for (const newUser of newUsersRequested) { for (const newUser of newUsersRequested) {
if ( if (
newUsers.find((x: any) => x.email === newUser.email) || newUsers.find((x: any) => x.email === newUser.email) ||
currentUserEmails.includes(newUser.email) existingEmails.includes(newUser.email)
) { ) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue continue
} }
newUser.userGroups = groups newUser.userGroups = groups
@ -307,26 +380,71 @@ export const bulkCreate = async (
await apps.syncUserInApps(user._id) await apps.syncUserInApps(user._id)
} }
return usersToBulkSave.map(user => { const saved = usersToBulkSave.map(user => {
return { return {
_id: user._id, _id: user._id,
email: user.email, 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 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 groupsToModify: any = {}
let builderCount = 0 let builderCount = 0
// Get users and delete // Get users and delete
let usersToDelete = ( const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
await db.allDocs({
include_docs: true, include_docs: true,
keys: userIds, keys: userIds,
}) })
).rows.map((user: any) => { const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
// if we find a user that has an associated group, add it to // if we find a user that has an associated group, add it to
// an array so we can easily use allDocs on them later. // an array so we can easily use allDocs on them later.
// This prevents us having to re-loop over all the users // This prevents us having to re-loop over all the users
@ -346,24 +464,46 @@ export const bulkDelete = async (userIds: any) => {
} }
return user.doc return user.doc
}) }
)
const response = await db.bulkDocs( // Delete from DB
usersToDelete.map((user: any) => ({ const dbResponse: BulkDocsResponse = await db.bulkDocs(
usersToDelete.map(user => ({
...user, ...user,
_deleted: true, _deleted: true,
})) }))
) )
await groupUtils.bulkDeleteGroupUsers(groupsToModify)
// Deletion post processing // Deletion post processing
await groupUtils.bulkDeleteGroupUsers(groupsToModify)
for (let user of usersToDelete) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await quotas.removeDevelopers(builderCount) 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 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" 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 { return {
email: "test@test.com", email: newEmail(),
password: "test", password: "test",
roles: {}, roles: {},
...userProps, ...userProps,
} }
} }
export const adminUser = (userProps: any) => { export const adminUser = (userProps?: any): AdminUser => {
return { return {
...user(userProps), ...user(userProps),
admin: { admin: {
global: true, global: true,
}, },
builder: {
global: true,
},
} }
} }
export const builderUser = (userProps: any) => { export const builderUser = (userProps?: any): BuilderUser => {
return { return {
...user(userProps), ...user(userProps),
builder: { builder: {

View file

@ -55,6 +55,7 @@ exports.init = async () => {
exports.shutdown = async () => { exports.shutdown = async () => {
if (pwResetClient) await pwResetClient.finish() if (pwResetClient) await pwResetClient.finish()
if (invitationClient) await invitationClient.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" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== 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@*": "@types/yargs-parser@*":
version "21.0.0" version "21.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"