diff --git a/packages/backend-core/src/auth.js b/packages/backend-core/src/auth.ts similarity index 73% rename from packages/backend-core/src/auth.js rename to packages/backend-core/src/auth.ts index d39b8426fb..23873b84e7 100644 --- a/packages/backend-core/src/auth.js +++ b/packages/backend-core/src/auth.ts @@ -1,11 +1,11 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -const { getGlobalDB } = require("./tenancy") +import { getGlobalDB } from "./tenancy" const refresh = require("passport-oauth2-refresh") -const { Configs } = require("./constants") -const { getScopedConfig } = require("./db/utils") -const { +import { Configs } from "./constants" +import { getScopedConfig } from "./db/utils" +import { jwt, local, authenticated, @@ -13,7 +13,6 @@ const { oidc, auditLog, tenancy, - appTenancy, authError, ssoCallbackUrl, csrf, @@ -22,32 +21,36 @@ const { builderOnly, builderOrAdmin, joiValidator, -} = require("./middleware") - -const { invalidateUser } = require("./cache/user") +} from "./middleware" +import { invalidateUser } from "./cache/user" +import { User } from "@budibase/types" // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) -passport.serializeUser((user, done) => done(null, user)) +passport.serializeUser((user: User, done: any) => done(null, user)) -passport.deserializeUser(async (user, done) => { +passport.deserializeUser(async (user: User, done: any) => { const db = getGlobalDB() try { - const user = await db.get(user._id) - return done(null, user) + const dbUser = await db.get(user._id) + return done(null, dbUser) } catch (err) { console.error(`User not found`, err) return done(null, false, { message: "User not found" }) } }) -async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { +async function refreshOIDCAccessToken( + db: any, + chosenConfig: any, + refreshToken: string +) { const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) - let enrichedConfig - let strategy + let enrichedConfig: any + let strategy: any try { enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl) @@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { refresh.requestNewAccessToken( Configs.OIDC, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: any, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshGoogleAccessToken(db, config, refreshToken) { +async function refreshGoogleAccessToken( + db: any, + config: any, + refreshToken: any +) { let callbackUrl = await google.getCallbackUrl(db, config) let strategy try { strategy = await google.strategyFactory(config, callbackUrl) - } catch (err) { + } catch (err: any) { console.error(err) - throw new Error("Error constructing OIDC refresh strategy", err) + throw new Error( + `Error constructing OIDC refresh strategy: message=${err.message}` + ) } refresh.use(strategy) @@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) { refresh.requestNewAccessToken( Configs.GOOGLE, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: string, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshOAuthToken(refreshToken, configType, configId) { +async function refreshOAuthToken( + refreshToken: string, + configType: string, + configId: string +) { const db = getGlobalDB() const config = await getScopedConfig(db, { @@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { let refreshResponse if (configType === Configs.OIDC) { // configId - retrieved from cookie. - chosenConfig = config.configs.filter(c => c.uuid === configId)[0] + chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0] if (!chosenConfig) { throw new Error("Invalid OIDC configuration") } @@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { return refreshResponse } -async function updateUserOAuth(userId, oAuthConfig) { +async function updateUserOAuth(userId: string, oAuthConfig: any) { const details = { accessToken: oAuthConfig.accessToken, refreshToken: oAuthConfig.refreshToken, @@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) { } } -module.exports = { +export = { buildAuthMiddleware: authenticated, passport, google, oidc, jwt: require("jsonwebtoken"), buildTenancyMiddleware: tenancy, - buildAppTenancyMiddleware: appTenancy, auditLog, authError, buildCsrfMiddleware: csrf, diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index b03382ad36..fd464ba5fb 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -42,7 +42,7 @@ export enum DocumentType { MIGRATIONS = "migrations", DEV_INFO = "devinfo", AUTOMATION_LOG = "log_au", - ACCOUNT = "acc", + ACCOUNT_METADATA = "acc_metadata", } export const StaticDatabases = { diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index c0a91581ac..b2562bdc71 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -6,7 +6,7 @@ const { } = require("./utils") const { getGlobalDB } = require("../tenancy") const { StaticDatabases } = require("./constants") -const { doWithDB } = require("./"); +const { doWithDB } = require("./") const DESIGN_DB = "_design/database" @@ -59,7 +59,7 @@ exports.createNewUserEmailView = async () => { } exports.createAccountEmailView = async () => { - await doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db) => { + await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { let designDoc try { designDoc = await db.get(DESIGN_DB) @@ -70,8 +70,8 @@ exports.createAccountEmailView = async () => { const view = { // if using variables in a map function need to inject them before use map: `function(doc) { - if (doc._id.startsWith("${DocumentType.ACCOUNT}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc.tenantId) + if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) } }`, } @@ -171,7 +171,7 @@ exports.queryView = async (viewName, params, db, CreateFuncByName) => { const createFunc = CreateFuncByName[viewName] await removeDeprecated(db, viewName) await createFunc() - return exports.queryGlobalView(viewName, params) + return exports.queryView(viewName, params, db, CreateFuncByName) } else { throw err } @@ -183,7 +183,7 @@ exports.queryPlatformView = async (viewName, params) => { [ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView, } - return doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db) => { + return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { return exports.queryView(viewName, params, db, CreateFuncByName) }) } diff --git a/packages/backend-core/src/events/index.ts b/packages/backend-core/src/events/index.ts index 814399655d..f94c8b0267 100644 --- a/packages/backend-core/src/events/index.ts +++ b/packages/backend-core/src/events/index.ts @@ -8,4 +8,5 @@ import { processors } from "./processors" export const shutdown = () => { processors.shutdown() + console.log("Events shutdown") } diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 6d2e8dcd10..74e79e7b95 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -17,6 +17,7 @@ import constants from "./constants" import * as dbConstants from "./db/constants" import logging from "./logging" import pino from "./pino" +import * as middleware from "./middleware" // mimic the outer package exports import * as db from "./pkg/db" @@ -57,6 +58,7 @@ const core = { roles, ...pino, ...errorClasses, + middleware, } export = core diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index b51ead46b9..062070785d 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) { * The tenancy modules should not be used here and it should be assumed that the tenancy context * has not yet been populated. */ -module.exports = ( +export = ( noAuthPatterns = [], opts: { publicAllowed: boolean; populateUser?: Function } = { publicAllowed: false, diff --git a/packages/backend-core/src/middleware/index.js b/packages/backend-core/src/middleware/index.ts similarity index 96% rename from packages/backend-core/src/middleware/index.js rename to packages/backend-core/src/middleware/index.ts index 7e7b8a2931..998c231b3d 100644 --- a/packages/backend-core/src/middleware/index.js +++ b/packages/backend-core/src/middleware/index.ts @@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly") const builderOrAdmin = require("./builderOrAdmin") const builderOnly = require("./builderOnly") const joiValidator = require("./joi-validator") -module.exports = { + +const pkg = { google, oidc, jwt, @@ -33,3 +34,5 @@ module.exports = { builderOrAdmin, joiValidator, } + +export = pkg diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index 284adbcd1f..f621b99dc2 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid") const { logWarn } = require("../logging") const env = require("../environment") -interface Session { - key: string - userId: string +interface CreateSession { sessionId: string - lastAccessedAt: string - createdAt: string + tenantId: string csrfToken?: string - value: string } -type SessionKey = { key: string }[] +interface Session extends CreateSession { + userId: string + lastAccessedAt: string + createdAt: string + // make optional attributes required + csrfToken: string +} + +interface SessionKey { + key: string +} + +interface ScannedSession { + value: Session +} // a week in seconds const EXPIRY_SECONDS = 86400 * 7 @@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) { return `${userId}/${sessionId}` } -export async function getSessionsForUser(userId: string) { +export async function getSessionsForUser(userId: string): Promise { if (!userId) { console.trace("Cannot get sessions for undefined userId") return [] } const client = await redis.getSessionClient() - const sessions = await client.scan(userId) - return sessions.map((session: Session) => session.value) + const sessions: ScannedSession[] = await client.scan(userId) + return sessions.map(session => session.value) } export async function invalidateSessions( @@ -39,33 +49,32 @@ export async function invalidateSessions( try { const reason = opts?.reason || "unknown" let sessionIds: string[] = opts.sessionIds || [] - let sessions: SessionKey + let sessionKeys: SessionKey[] // If no sessionIds, get all the sessions for the user if (sessionIds.length === 0) { - sessions = await getSessionsForUser(userId) - sessions.forEach( - (session: any) => - (session.key = makeSessionID(session.userId, session.sessionId)) - ) + const sessions = await getSessionsForUser(userId) + sessionKeys = sessions.map(session => ({ + key: makeSessionID(session.userId, session.sessionId), + })) } else { // use the passed array of sessionIds sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] - sessions = sessionIds.map((sessionId: string) => ({ + sessionKeys = sessionIds.map(sessionId => ({ key: makeSessionID(userId, sessionId), })) } - if (sessions && sessions.length > 0) { + if (sessionKeys && sessionKeys.length > 0) { const client = await redis.getSessionClient() const promises = [] - for (let session of sessions) { - promises.push(client.delete(session.key)) + for (let sessionKey of sessionKeys) { + promises.push(client.delete(sessionKey.key)) } if (!env.isTest()) { logWarn( - `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions - .map(session => session.key) + `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys + .map(sessionKey => sessionKey.key) .join(", ")}` ) } @@ -76,22 +85,26 @@ export async function invalidateSessions( } } -export async function createASession(userId: string, session: Session) { +export async function createASession( + userId: string, + createSession: CreateSession +) { // invalidate all other sessions await invalidateSessions(userId, { reason: "creation" }) const client = await redis.getSessionClient() - const sessionId = session.sessionId - if (!session.csrfToken) { - session.csrfToken = uuidv4() - } - session = { - ...session, + const sessionId = createSession.sessionId + const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4() + const key = makeSessionID(userId, sessionId) + + const session: Session = { + ...createSession, + csrfToken, createdAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(), userId, } - await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) + await client.store(key, session, EXPIRY_SECONDS) } export async function updateSessionTTL(session: Session) { @@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) { await client.delete(makeSessionID(userId, sessionId)) } -export async function getSession(userId: string, sessionId: string) { +export async function getSession( + userId: string, + sessionId: string +): Promise { if (!userId || !sessionId) { throw new Error(`Invalid session details - ${userId} - ${sessionId}`) } diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index de5ce238c1..81bf28bb46 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants") * Given an email address this will use a view to search through * all the users to find one with this email address. * @param {string} email the email to lookup the user by. - * @return {Promise} */ exports.getGlobalUserByEmail = async email => { if (email == null) { diff --git a/packages/backend-core/tests/utilities/mocks/accounts.ts b/packages/backend-core/tests/utilities/mocks/accounts.ts new file mode 100644 index 0000000000..79436443db --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/accounts.ts @@ -0,0 +1,7 @@ +export const getAccount = jest.fn() +export const getAccountByTenantId = jest.fn() + +jest.mock("../../../src/cloud/accounts", () => ({ + getAccount, + getAccountByTenantId, +})) diff --git a/packages/backend-core/tests/utilities/mocks/date.js b/packages/backend-core/tests/utilities/mocks/date.js deleted file mode 100644 index 19248c6f11..0000000000 --- a/packages/backend-core/tests/utilities/mocks/date.js +++ /dev/null @@ -1,2 +0,0 @@ -exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") -exports.MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/date.ts b/packages/backend-core/tests/utilities/mocks/date.ts new file mode 100644 index 0000000000..f580b68349 --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/date.ts @@ -0,0 +1,2 @@ +export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") +export const MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/events.js b/packages/backend-core/tests/utilities/mocks/events.ts similarity index 100% rename from packages/backend-core/tests/utilities/mocks/events.js rename to packages/backend-core/tests/utilities/mocks/events.ts diff --git a/packages/backend-core/tests/utilities/mocks/index.js b/packages/backend-core/tests/utilities/mocks/index.js deleted file mode 100644 index 6aa1c4a54f..0000000000 --- a/packages/backend-core/tests/utilities/mocks/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const posthog = require("./posthog") -const events = require("./events") -const date = require("./date") - -module.exports = { - posthog, - date, - events, -} diff --git a/packages/backend-core/tests/utilities/mocks/index.ts b/packages/backend-core/tests/utilities/mocks/index.ts new file mode 100644 index 0000000000..7031b225ec --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/index.ts @@ -0,0 +1,4 @@ +import "./posthog" +import "./events" +export * as accounts from "./accounts" +export * as date from "./date" diff --git a/packages/types/src/api/web/errors.ts b/packages/types/src/api/web/errors.ts new file mode 100644 index 0000000000..65870d6a29 --- /dev/null +++ b/packages/types/src/api/web/errors.ts @@ -0,0 +1,5 @@ +export interface APIError { + message: string + status: number + error?: any +} diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts index 2a3a01106f..0129fb38d9 100644 --- a/packages/types/src/api/web/index.ts +++ b/packages/types/src/api/web/index.ts @@ -1,2 +1,3 @@ export * from "./analytics" -export * from "./user" \ No newline at end of file +export * from "./user" +export * from "./errors" diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 3e9d410473..0351680f98 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -1,10 +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[] -} \ No newline at end of file +} + +export interface BulkDeleteUsersResponse { + successful: UserDetails[] + unsuccessful: { _id: string; email: string; reason: string }[] +} diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index 1dc1f4f228..ac16194a21 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -22,7 +22,6 @@ export interface UserRoles { [key: string]: string } - // utility types export interface BuilderUser extends User { @@ -34,8 +33,8 @@ export interface BuilderUser extends User { export interface AdminUser extends User { admin: { global: boolean - }, + } builder: { global: boolean } -} \ No newline at end of file +} diff --git a/packages/types/src/documents/global/userGroup.ts b/packages/types/src/documents/global/userGroup.ts index b822e5bb7b..86010d118b 100644 --- a/packages/types/src/documents/global/userGroup.ts +++ b/packages/types/src/documents/global/userGroup.ts @@ -1,19 +1,20 @@ import { Document } from "../document" -import { User } from "./user" + export interface UserGroup extends Document { name: string icon: string color: string - users: groupUser[] + users: GroupUser[] apps: string[] roles: UserGroupRoles createdAt?: number } -export interface groupUser { +export interface GroupUser { _id: string - email: string[] + email: string } + export interface UserGroupRoles { [key: string]: string } diff --git a/packages/types/src/documents/index.ts b/packages/types/src/documents/index.ts index 4f5b278a4b..47ec48f49c 100644 --- a/packages/types/src/documents/index.ts +++ b/packages/types/src/documents/index.ts @@ -3,3 +3,4 @@ export * from "./app" export * from "./global" export * from "./platform" export * from "./document" +export * from "./pouch" diff --git a/packages/types/src/documents/platform/accounts.ts b/packages/types/src/documents/platform/accounts.ts new file mode 100644 index 0000000000..ac6a027448 --- /dev/null +++ b/packages/types/src/documents/platform/accounts.ts @@ -0,0 +1,5 @@ +import { Document } from "../document" + +export interface AccountMetadata extends Document { + email: string +} diff --git a/packages/types/src/documents/platform/index.ts b/packages/types/src/documents/platform/index.ts index 0438f720f4..1a7cef91cf 100644 --- a/packages/types/src/documents/platform/index.ts +++ b/packages/types/src/documents/platform/index.ts @@ -1,2 +1,3 @@ export * from "./info" -export * from "./users" \ No newline at end of file +export * from "./users" +export * from "./accounts" diff --git a/packages/types/src/documents/platform/users.ts b/packages/types/src/documents/platform/users.ts index 1cf5377965..1b65ea42f2 100644 --- a/packages/types/src/documents/platform/users.ts +++ b/packages/types/src/documents/platform/users.ts @@ -1,4 +1,4 @@ -import { Document } from "../document"; +import { Document } from "../document" /** * doc id is user email diff --git a/packages/types/src/documents/pouch.ts b/packages/types/src/documents/pouch.ts new file mode 100644 index 0000000000..d6b7c2b003 --- /dev/null +++ b/packages/types/src/documents/pouch.ts @@ -0,0 +1,20 @@ +export interface RowResponse { + id: string + key: string + value: any + doc: T +} + +export interface AllDocsResponse { + offset: number + total_rows: number + rows: RowResponse[] +} + +export type BulkDocsResponse = BulkDocResponse[] + +interface BulkDocResponse { + ok: boolean + id: string + rev: string +} diff --git a/packages/types/src/sdk/auth.ts b/packages/types/src/sdk/auth.ts new file mode 100644 index 0000000000..dd3c2124b5 --- /dev/null +++ b/packages/types/src/sdk/auth.ts @@ -0,0 +1,5 @@ +export interface AuthToken { + userId: string + tenantId: string + sessionId: string +} diff --git a/packages/types/src/sdk/index.ts b/packages/types/src/sdk/index.ts index d6ca92f607..fdcfc0804e 100644 --- a/packages/types/src/sdk/index.ts +++ b/packages/types/src/sdk/index.ts @@ -5,3 +5,4 @@ export * from "./licensing" export * from "./migrations" export * from "./datasources" export * from "./search" +export * from "./auth" diff --git a/packages/worker/package.json b/packages/worker/package.json index 60885179ac..7b737e58d7 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -73,6 +73,7 @@ "@types/koa-router": "7.4.4", "@types/koa__router": "8.0.11", "@types/node": "14.18.20", + "@types/uuid": "8.3.4", "@typescript-eslint/parser": "5.12.0", "copyfiles": "2.4.1", "eslint": "6.8.0", diff --git a/packages/worker/scripts/jestSetup.js b/packages/worker/scripts/jestSetup.js index 765befd153..8ee2d33d70 100644 --- a/packages/worker/scripts/jestSetup.js +++ b/packages/worker/scripts/jestSetup.js @@ -14,3 +14,9 @@ const tk = require("timekeeper") tk.freeze(mocks.date.MOCK_DATE) global.console.log = jest.fn() // console.log are ignored in tests + +if (!process.env.CI) { + // set a longer timeout in dev for debugging + // 100 seconds + jest.setTimeout(100000) +} diff --git a/packages/worker/scripts/load/users.js b/packages/worker/scripts/load/users.js deleted file mode 100644 index 6caf67ade4..0000000000 --- a/packages/worker/scripts/load/users.js +++ /dev/null @@ -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) - }) diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 3938a6427a..97ee34b8f4 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -3,7 +3,7 @@ import { checkInviteCode } from "../../../utilities/redis" import { sendEmail } from "../../../utilities/email" import { users } from "../../../sdk" import env from "../../../environment" -import { CloudAccount, User } from "@budibase/types" +import { BulkDeleteUsersRequest, CloudAccount, User } from "@budibase/types" import { accounts, cache, @@ -138,17 +138,15 @@ export const destroy = async (ctx: any) => { } export const bulkDelete = async (ctx: any) => { - const { userIds } = ctx.request.body + const { userIds } = ctx.request.body as BulkDeleteUsersRequest if (userIds?.indexOf(ctx.user._id) !== -1) { ctx.throw(400, "Unable to delete self.") } try { - let usersResponse = await users.bulkDelete(userIds) + let response = await users.bulkDelete(userIds) - ctx.body = { - message: `${usersResponse.length} user(s) deleted`, - } + ctx.body = response } catch (err) { ctx.throw(err) } diff --git a/packages/worker/src/api/controllers/system/accounts.ts b/packages/worker/src/api/controllers/system/accounts.ts new file mode 100644 index 0000000000..5e72f35bab --- /dev/null +++ b/packages/worker/src/api/controllers/system/accounts.ts @@ -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 +} diff --git a/packages/worker/src/api/index.js b/packages/worker/src/api/index.ts similarity index 89% rename from packages/worker/src/api/index.js rename to packages/worker/src/api/index.ts index ca56e0c5d2..3acf0515d7 100644 --- a/packages/worker/src/api/index.js +++ b/packages/worker/src/api/index.ts @@ -1,15 +1,16 @@ -const Router = require("@koa/router") +import Router from "@koa/router" const compress = require("koa-compress") const zlib = require("zlib") -const { routes } = require("./routes") -const { +import { routes } from "./routes" +import { buildAuthMiddleware, auditLog, buildTenancyMiddleware, buildCsrfMiddleware, -} = require("@budibase/backend-core/auth") -const { middleware: pro } = require("@budibase/pro") -const { errors } = require("@budibase/backend-core") +} from "@budibase/backend-core/auth" +import { middleware as pro } from "@budibase/pro" +import { errors } from "@budibase/backend-core" +import { APIError } from "@budibase/types" const PUBLIC_ENDPOINTS = [ // old deprecated endpoints kept for backwards compat @@ -120,15 +121,16 @@ router router.use(async (ctx, next) => { try { await next() - } catch (err) { + } catch (err: any) { ctx.log.error(err) ctx.status = err.status || err.statusCode || 500 const error = errors.getPublicError(err) - ctx.body = { + const body: APIError = { message: err.message, status: ctx.status, error, } + ctx.body = body } }) diff --git a/packages/worker/src/api/routes/tests/auth.spec.js b/packages/worker/src/api/routes/global/tests/auth.spec.ts similarity index 51% rename from packages/worker/src/api/routes/tests/auth.spec.js rename to packages/worker/src/api/routes/global/tests/auth.spec.ts index 165ecd0f4a..69fa1b223c 100644 --- a/packages/worker/src/api/routes/tests/auth.spec.js +++ b/packages/worker/src/api/routes/global/tests/auth.spec.ts @@ -1,11 +1,11 @@ jest.mock("nodemailer") -const { config, request, mocks, structures } = require("../../../tests") +import { TestConfiguration, mocks, API } from "../../../../tests" const sendMailMock = mocks.email.mock() -const { events } = require("@budibase/backend-core") - -const TENANT_ID = structures.TENANT_ID +import { events } from "@budibase/backend-core" describe("/api/global/auth", () => { + const config = new TestConfiguration() + const api = new API(config) beforeAll(async () => { await config.beforeAll() @@ -19,56 +19,32 @@ describe("/api/global/auth", () => { jest.clearAllMocks() }) - const requestPasswordReset = async () => { - await config.saveSmtpConfig() - await config.saveSettingsConfig() - await config.createUser() - const res = await request - .post(`/api/global/auth/${TENANT_ID}/reset`) - .send({ - email: "test@test.com", - }) - .expect("Content-Type", /json/) - .expect(200) - const emailCall = sendMailMock.mock.calls[0][0] - const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`) - const code = parts[1].split("\"")[0].split("&")[0] - return { code, res } - } - it("should logout", async () => { - await request - .post("/api/global/auth/logout") - .set(config.defaultHeaders()) - .expect(200) + await api.auth.logout() expect(events.auth.logout).toBeCalledTimes(1) }) it("should be able to generate password reset email", async () => { - const { res, code } = await requestPasswordReset() + const { res, code } = await api.auth.requestPasswordReset(sendMailMock) const user = await config.getUser("test@test.com") - expect(res.body).toEqual({ message: "Please check your email for a reset link." }) + expect(res.body).toEqual({ + message: "Please check your email for a reset link.", + }) expect(sendMailMock).toHaveBeenCalled() - + expect(code).toBeDefined() expect(events.user.passwordResetRequested).toBeCalledTimes(1) expect(events.user.passwordResetRequested).toBeCalledWith(user) }) it("should allow resetting user password with code", async () => { - const { code } = await requestPasswordReset() + const { code } = await api.auth.requestPasswordReset(sendMailMock) const user = await config.getUser("test@test.com") - delete user.password + delete user.password + + const res = await api.auth.updatePassword(code) - const res = await request - .post(`/api/global/auth/${TENANT_ID}/reset/update`) - .send({ - password: "newpassword", - resetCode: code, - }) - .expect("Content-Type", /json/) - .expect(200) expect(res.body).toEqual({ message: "password reset successfully." }) expect(events.user.passwordReset).toBeCalledTimes(1) expect(events.user.passwordReset).toBeCalledWith(user) @@ -79,15 +55,15 @@ describe("/api/global/auth", () => { const passportSpy = jest.spyOn(auth.passport, "authenticate") let oidcConf - let chosenConfig - let configId + let chosenConfig: any + let configId: string // mock the oidc strategy implementation and return value let strategyFactory = jest.fn() let mockStrategyReturn = jest.fn() let mockStrategyConfig = jest.fn() auth.oidc.fetchStrategyConfig = mockStrategyConfig - + strategyFactory.mockReturnValue(mockStrategyReturn) auth.oidc.strategyFactory = strategyFactory @@ -99,34 +75,34 @@ describe("/api/global/auth", () => { }) afterEach(() => { - expect(strategyFactory).toBeCalledWith( - chosenConfig, - expect.any(Function) - ) + expect(strategyFactory).toBeCalledWith(chosenConfig, expect.any(Function)) }) describe("oidc configs", () => { it("should load strategy and delegate to passport", async () => { - await request.get(`/api/global/auth/${TENANT_ID}/oidc/configs/${configId}`) + await api.configs.getOIDCConfig(configId) expect(passportSpy).toBeCalledWith(mockStrategyReturn, { - scope: ["profile", "email", "offline_access"] + scope: ["profile", "email", "offline_access"], }) - expect(passportSpy.mock.calls.length).toBe(1); + expect(passportSpy.mock.calls.length).toBe(1) }) }) describe("oidc callback", () => { it("should load strategy and delegate to passport", async () => { - await request.get(`/api/global/auth/${TENANT_ID}/oidc/callback`) - .set(config.getOIDConfigCookie(configId)) - - expect(passportSpy).toBeCalledWith(mockStrategyReturn, { - successRedirect: "/", failureRedirect: "/error" - }, expect.anything()) - expect(passportSpy.mock.calls.length).toBe(1); + await api.configs.OIDCCallback(configId) + + expect(passportSpy).toBeCalledWith( + mockStrategyReturn, + { + successRedirect: "/", + failureRedirect: "/error", + }, + expect.anything() + ) + expect(passportSpy.mock.calls.length).toBe(1) }) }) - }) }) diff --git a/packages/worker/src/api/routes/tests/configs.spec.js b/packages/worker/src/api/routes/global/tests/configs.spec.ts similarity index 72% rename from packages/worker/src/api/routes/tests/configs.spec.js rename to packages/worker/src/api/routes/global/tests/configs.spec.ts index b2dc8124da..31510c03dd 100644 --- a/packages/worker/src/api/routes/tests/configs.spec.js +++ b/packages/worker/src/api/routes/global/tests/configs.spec.ts @@ -1,11 +1,12 @@ // mock the email system jest.mock("nodemailer") -const { config, structures, mocks, request } = require("../../../tests") +import { TestConfiguration, structures, mocks, API } from "../../../../tests" mocks.email.mock() -const { Configs } = require("@budibase/backend-core/constants") -const { events } = require("@budibase/backend-core") +import { Configs, events } from "@budibase/backend-core" describe("configs", () => { + const config = new TestConfiguration() + const api = new API(config) beforeAll(async () => { await config.beforeAll() @@ -20,35 +21,33 @@ describe("configs", () => { }) describe("post /api/global/configs", () => { - - const saveConfig = async (conf, _id, _rev) => { + const saveConfig = async (conf: any, _id?: string, _rev?: string) => { const data = { ...conf, _id, - _rev + _rev, } - const res = await request - .post(`/api/global/configs`) - .send(data) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const res = await api.configs.saveConfig(data) return { ...data, - ...res.body + ...res.body, } } describe("google", () => { - const saveGoogleConfig = async (conf, _id, _rev) => { + const saveGoogleConfig = async ( + conf?: any, + _id?: string, + _rev?: string + ) => { const googleConfig = structures.configs.google(conf) return saveConfig(googleConfig, _id, _rev) } - + describe("create", () => { - it ("should create activated google config", async () => { + it("should create activated google config", async () => { await saveGoogleConfig() expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledWith(Configs.GOOGLE) @@ -58,7 +57,7 @@ describe("configs", () => { await config.deleteConfig(Configs.GOOGLE) }) - it ("should create deactivated google config", async () => { + it("should create deactivated google config", async () => { await saveGoogleConfig({ activated: false }) expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledWith(Configs.GOOGLE) @@ -69,10 +68,14 @@ describe("configs", () => { }) describe("update", () => { - it ("should update google config to deactivated", async () => { + it("should update google config to deactivated", async () => { const googleConf = await saveGoogleConfig() jest.clearAllMocks() - await saveGoogleConfig({ ...googleConf.config, activated: false }, googleConf._id, googleConf._rev) + await saveGoogleConfig( + { ...googleConf.config, activated: false }, + googleConf._id, + googleConf._rev + ) expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE) expect(events.auth.SSOActivated).not.toBeCalled() @@ -81,10 +84,14 @@ describe("configs", () => { await config.deleteConfig(Configs.GOOGLE) }) - it ("should update google config to activated", async () => { + it("should update google config to activated", async () => { const googleConf = await saveGoogleConfig({ activated: false }) jest.clearAllMocks() - await saveGoogleConfig({ ...googleConf.config, activated: true}, googleConf._id, googleConf._rev) + await saveGoogleConfig( + { ...googleConf.config, activated: true }, + googleConf._id, + googleConf._rev + ) expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.GOOGLE) expect(events.auth.SSODeactivated).not.toBeCalled() @@ -92,17 +99,21 @@ describe("configs", () => { expect(events.auth.SSOActivated).toBeCalledWith(Configs.GOOGLE) await config.deleteConfig(Configs.GOOGLE) }) - }) + }) }) describe("oidc", () => { - const saveOIDCConfig = async (conf, _id, _rev) => { + const saveOIDCConfig = async ( + conf?: any, + _id?: string, + _rev?: string + ) => { const oidcConfig = structures.configs.oidc(conf) return saveConfig(oidcConfig, _id, _rev) } describe("create", () => { - it ("should create activated OIDC config", async () => { + it("should create activated OIDC config", async () => { await saveOIDCConfig() expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledWith(Configs.OIDC) @@ -112,7 +123,7 @@ describe("configs", () => { await config.deleteConfig(Configs.OIDC) }) - it ("should create deactivated OIDC config", async () => { + it("should create deactivated OIDC config", async () => { await saveOIDCConfig({ activated: false }) expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledWith(Configs.OIDC) @@ -123,10 +134,14 @@ describe("configs", () => { }) describe("update", () => { - it ("should update OIDC config to deactivated", async () => { + it("should update OIDC config to deactivated", async () => { const oidcConf = await saveOIDCConfig() jest.clearAllMocks() - await saveOIDCConfig({ ...oidcConf.config.configs[0], activated: false }, oidcConf._id, oidcConf._rev) + await saveOIDCConfig( + { ...oidcConf.config.configs[0], activated: false }, + oidcConf._id, + oidcConf._rev + ) expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC) expect(events.auth.SSOActivated).not.toBeCalled() @@ -135,10 +150,14 @@ describe("configs", () => { await config.deleteConfig(Configs.OIDC) }) - it ("should update OIDC config to activated", async () => { + it("should update OIDC config to activated", async () => { const oidcConf = await saveOIDCConfig({ activated: false }) jest.clearAllMocks() - await saveOIDCConfig({ ...oidcConf.config.configs[0], activated: true}, oidcConf._id, oidcConf._rev) + await saveOIDCConfig( + { ...oidcConf.config.configs[0], activated: true }, + oidcConf._id, + oidcConf._rev + ) expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledWith(Configs.OIDC) expect(events.auth.SSODeactivated).not.toBeCalled() @@ -147,17 +166,20 @@ describe("configs", () => { await config.deleteConfig(Configs.OIDC) }) }) - }) describe("smtp", () => { - const saveSMTPConfig = async (conf, _id, _rev) => { + const saveSMTPConfig = async ( + conf?: any, + _id?: string, + _rev?: string + ) => { const smtpConfig = structures.configs.smtp(conf) return saveConfig(smtpConfig, _id, _rev) } describe("create", () => { - it ("should create SMTP config", async () => { + it("should create SMTP config", async () => { await config.deleteConfig(Configs.SMTP) await saveSMTPConfig() expect(events.email.SMTPUpdated).not.toBeCalled() @@ -167,7 +189,7 @@ describe("configs", () => { }) describe("update", () => { - it ("should update SMTP config", async () => { + it("should update SMTP config", async () => { const smtpConf = await saveSMTPConfig() jest.clearAllMocks() await saveSMTPConfig(smtpConf.config, smtpConf._id, smtpConf._rev) @@ -179,15 +201,19 @@ describe("configs", () => { }) describe("settings", () => { - const saveSettingsConfig = async (conf, _id, _rev) => { + const saveSettingsConfig = async ( + conf?: any, + _id?: string, + _rev?: string + ) => { const settingsConfig = structures.configs.settings(conf) return saveConfig(settingsConfig, _id, _rev) } describe("create", () => { - it ("should create settings config with default settings", async () => { + it("should create settings config with default settings", async () => { await config.deleteConfig(Configs.SETTINGS) - + await saveSettingsConfig() expect(events.org.nameUpdated).not.toBeCalled() @@ -195,35 +221,43 @@ describe("configs", () => { expect(events.org.platformURLUpdated).not.toBeCalled() }) - it ("should create settings config with non-default settings", async () => { + it("should create settings config with non-default settings", async () => { + config.modeSelf() await config.deleteConfig(Configs.SETTINGS) const conf = { company: "acme", logoUrl: "http://example.com", - platformUrl: "http://example.com" + platformUrl: "http://example.com", } await saveSettingsConfig(conf) - + expect(events.org.nameUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.platformURLUpdated).toBeCalledTimes(1) + config.modeAccount() }) }) describe("update", () => { - it ("should update settings config", async () => { + it("should update settings config", async () => { + config.modeSelf() await config.deleteConfig(Configs.SETTINGS) const settingsConfig = await saveSettingsConfig() settingsConfig.config.company = "acme" settingsConfig.config.logoUrl = "http://example.com" settingsConfig.config.platformUrl = "http://example.com" - await saveSettingsConfig(settingsConfig.config, settingsConfig._id, settingsConfig._rev) + await saveSettingsConfig( + settingsConfig.config, + settingsConfig._id, + settingsConfig._rev + ) expect(events.org.nameUpdated).toBeCalledTimes(1) expect(events.org.logoUpdated).toBeCalledTimes(1) expect(events.org.platformURLUpdated).toBeCalledTimes(1) + config.modeAccount() }) }) }) @@ -232,12 +266,7 @@ describe("configs", () => { it("should return the correct checklist status based on the state of the budibase installation", async () => { await config.saveSmtpConfig() - const res = await request - .get(`/api/global/configs/checklist`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - + const res = await api.configs.getConfigChecklist() const checklist = res.body expect(checklist.apps.checked).toBeFalsy() diff --git a/packages/worker/src/api/routes/tests/email.spec.js b/packages/worker/src/api/routes/global/tests/email.spec.ts similarity index 58% rename from packages/worker/src/api/routes/tests/email.spec.js rename to packages/worker/src/api/routes/global/tests/email.spec.ts index e90d953880..608f4094f8 100644 --- a/packages/worker/src/api/routes/tests/email.spec.js +++ b/packages/worker/src/api/routes/global/tests/email.spec.ts @@ -1,12 +1,11 @@ jest.mock("nodemailer") -const { config, mocks, structures, request } = require("../../../tests") +import { TestConfiguration, mocks, API } from "../../../../tests" const sendMailMock = mocks.email.mock() - -const { EmailTemplatePurpose } = require("../../../constants") - -const TENANT_ID = structures.TENANT_ID +import { EmailTemplatePurpose } from "../../../../constants" describe("/api/global/email", () => { + const config = new TestConfiguration() + const api = new API(config) beforeAll(async () => { await config.beforeAll() @@ -20,16 +19,9 @@ describe("/api/global/email", () => { // initially configure settings await config.saveSmtpConfig() await config.saveSettingsConfig() - const res = await request - .post(`/api/global/email/send`) - .send({ - email: "test@test.com", - purpose: EmailTemplatePurpose.INVITATION, - tenantId: TENANT_ID, - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + + const res = await api.emails.sendEmail(EmailTemplatePurpose.INVITATION) + expect(res.body.message).toBeDefined() expect(sendMailMock).toHaveBeenCalled() const emailCall = sendMailMock.mock.calls[0][0] diff --git a/packages/worker/src/api/routes/tests/realEmail.spec.js b/packages/worker/src/api/routes/global/tests/realEmail.spec.ts similarity index 75% rename from packages/worker/src/api/routes/tests/realEmail.spec.js rename to packages/worker/src/api/routes/global/tests/realEmail.spec.ts index 548c6ee59e..135367e0d8 100644 --- a/packages/worker/src/api/routes/tests/realEmail.spec.js +++ b/packages/worker/src/api/routes/global/tests/realEmail.spec.ts @@ -1,5 +1,5 @@ -const { config, request } = require("../../../tests") -const { EmailTemplatePurpose } = require("../../../constants") +import { TestConfiguration, API } from "../../../../tests" +import { EmailTemplatePurpose } from "../../../../constants" const nodemailer = require("nodemailer") const fetch = require("node-fetch") @@ -7,6 +7,8 @@ const fetch = require("node-fetch") jest.setTimeout(30000) describe("/api/global/email", () => { + const config = new TestConfiguration() + const api = new API(config) beforeAll(async () => { await config.beforeAll() @@ -16,27 +18,24 @@ describe("/api/global/email", () => { await config.afterAll() }) - async function sendRealEmail(purpose) { + async function sendRealEmail(purpose: string) { let response, text try { - const timeout = () => new Promise((resolve, reject) => - setTimeout(() => reject({ - status: 301, - errno: "ETIME" - }), 20000) - ) + const timeout = () => + new Promise((resolve, reject) => + setTimeout( + () => + reject({ + status: 301, + errno: "ETIME", + }), + 20000 + ) + ) await Promise.race([config.saveEtherealSmtpConfig(), timeout()]) await Promise.race([config.saveSettingsConfig(), timeout()]) - const user = await config.getUser("test@test.com") - const res = await request - .post(`/api/global/email/send`) - .send({ - email: "test@test.com", - purpose, - userId: user._id, - }) - .set(config.defaultHeaders()) - .timeout(20000) + + const res = await api.emails.sendEmail(purpose).timeout(20000) // ethereal hiccup, can't test right now if (res.status >= 300) { return @@ -47,7 +46,7 @@ describe("/api/global/email", () => { expect(testUrl).toBeDefined() response = await fetch(testUrl) text = await response.text() - } catch (err) { + } catch (err: any) { // ethereal hiccup, can't test right now if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) { return @@ -81,4 +80,4 @@ describe("/api/global/email", () => { it("should be able to send a password recovery email", async () => { await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY) }) -}) \ No newline at end of file +}) diff --git a/packages/worker/src/api/routes/tests/self.spec.js b/packages/worker/src/api/routes/global/tests/self.spec.ts similarity index 67% rename from packages/worker/src/api/routes/tests/self.spec.js rename to packages/worker/src/api/routes/global/tests/self.spec.ts index 64fce5ae1d..b3c91a9306 100644 --- a/packages/worker/src/api/routes/tests/self.spec.js +++ b/packages/worker/src/api/routes/global/tests/self.spec.ts @@ -1,8 +1,10 @@ jest.mock("nodemailer") -const { config, request } = require("../../../tests") -const { events } = require("@budibase/backend-core") +import { TestConfiguration, API } from "../../../../tests" +import { events } from "@budibase/backend-core" describe("/api/global/self", () => { + const config = new TestConfiguration() + const api = new API(config) beforeAll(async () => { await config.beforeAll() @@ -16,23 +18,13 @@ describe("/api/global/self", () => { jest.clearAllMocks() }) - const updateSelf = async (user) => { - const res = await request - .post(`/api/global/self`) - .send(user) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - return res - } - describe("update", () => { - it("should update self", async () => { const user = await config.createUser() + await config.createSession(user) delete user.password - const res = await updateSelf(user) + const res = await api.self.updateSelf(user) expect(res.body._id).toBe(user._id) expect(events.user.updated).toBeCalledTimes(1) @@ -42,10 +34,10 @@ describe("/api/global/self", () => { it("should update password", async () => { const user = await config.createUser() - const password = "newPassword" - user.password = password + await config.createSession(user) - const res = await updateSelf(user) + user.password = "newPassword" + const res = await api.self.updateSelf(user) delete user.password expect(res.body._id).toBe(user._id) @@ -55,4 +47,4 @@ describe("/api/global/self", () => { expect(events.user.passwordUpdated).toBeCalledWith(user) }) }) -}) \ No newline at end of file +}) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts new file mode 100644 index 0000000000..a89a843443 --- /dev/null +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -0,0 +1,470 @@ +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( + `Email address ${user.email} already in use.` + ) + 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( + `Email address ${user.email} already in use.` + ) + 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( + `Email address ${user.email} already in use.` + ) + 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.") + }) + }) +}) diff --git a/packages/worker/src/api/routes/index.js b/packages/worker/src/api/routes/index.js index 550d14a9a3..7f5c783caa 100644 --- a/packages/worker/src/api/routes/index.js +++ b/packages/worker/src/api/routes/index.js @@ -12,6 +12,7 @@ const statusRoutes = require("./system/status") const selfRoutes = require("./global/self") const licenseRoutes = require("./global/license") const migrationRoutes = require("./system/migrations") +const accountRoutes = require("./system/accounts") let userGroupRoutes = api.groups exports.routes = [ @@ -29,4 +30,5 @@ exports.routes = [ licenseRoutes, userGroupRoutes, migrationRoutes, + accountRoutes, ] diff --git a/packages/worker/src/api/routes/system/accounts.ts b/packages/worker/src/api/routes/system/accounts.ts new file mode 100644 index 0000000000..61a46ae437 --- /dev/null +++ b/packages/worker/src/api/routes/system/accounts.ts @@ -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 diff --git a/packages/worker/src/api/routes/system/tests/accounts.spec.ts b/packages/worker/src/api/routes/system/tests/accounts.spec.ts new file mode 100644 index 0000000000..b20b7a6472 --- /dev/null +++ b/packages/worker/src/api/routes/system/tests/accounts.spec.ts @@ -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` + ) + }) + }) + }) +}) diff --git a/packages/worker/src/api/routes/tests/users.spec.ts b/packages/worker/src/api/routes/tests/users.spec.ts deleted file mode 100644 index c95a7a7c4d..0000000000 --- a/packages/worker/src/api/routes/tests/users.spec.ts +++ /dev/null @@ -1,424 +0,0 @@ -jest.mock("nodemailer") -import { config, request, mocks, structures } from "../../../tests" -const sendMailMock = mocks.email.mock() -import { events } from "@budibase/backend-core" -import { User, BulkCreateUsersRequest, BulkDeleteUsersRequest } from "@budibase/types" - -describe("/api/global/users", () => { - - beforeAll(async () => { - await config.beforeAll() - }) - - afterAll(async () => { - await config.afterAll() - }) - - beforeEach(() => { - jest.clearAllMocks() - }) - - 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 } - } - - describe("invite", () => { - 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 bulkCreateUsers = async (users: User[], groups: any[] = []) => { - const body: BulkCreateUsersRequest = { users, groups } - const res = await request - .post(`/api/global/users/bulkCreate`) - .send(body) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - return res.body - } - - describe("bulkCreate", () => { - - it("should ignore users existing in the same tenant", async () => { - await bulkCreateUsers(toCreate) - }) - - it("should ignore users existing in other tenants", async () => { - await bulkCreateUsers(toCreate) - }) - - it("should ignore accounts using the same email", async () => { - await bulkCreateUsers(toCreate) - }) - - it("should be able to bulkCreate users with different permissions", async () => { - 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" }) - - await bulkCreateUsers([builder, admin, user]) - - expect(events.user.created).toBeCalledTimes(3) - expect(events.user.permissionAdminAssigned).toBeCalledTimes(1) - expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1) - }) - }) - - const createUser = async (user: User) => { - const existing = await config.getUser(user.email) - if (existing) { - await deleteUser(existing._id) - } - return saveUser(user) - } - - const updateUser = async (user: User) => { - const existing = await config.getUser(user.email) - user._id = existing._id - return saveUser(user) - } - - const saveUser = async (user: User) => { - const res = await request - .post(`/api/global/users`) - .send(user) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - return res.body - } - - const bulkDeleteUsers = async (users: User[]) => { - const body: BulkDeleteUsersRequest = { - userIds: users.map(u => u._id!) - } - const res = await request - .post(`/api/global/users/bulkDelete`) - .send(body) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - return res.body - } - - const deleteUser = async (email: string) => { - 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 () => { - 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 create an admin user", async () => { - 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 () => { - 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 () => { - 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 () => { - const 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 () => { - const 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 () => { - const 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("bulkDelete", () => { - - it("should not be able to bulkDelete account admin as admin", async () => { - - }) - - it("should not be able to bulkDelete account owner as account owner", async () => { - - }) - - it("should be able to bulk delete users with different permissions", async () => { - 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" }) - - const createdUsers = await bulkCreateUsers([builder, admin, user]) - await bulkDeleteUsers(createdUsers) - expect(events.user.deleted).toBeCalledTimes(3) - expect(events.user.permissionAdminRemoved).toBeCalledTimes(1) - expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1) - }) - }) - - 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 not be able to destroy account admin as admin", async () => { - - }) - - it("should not be able to destroy account owner as account owner", async () => { - - }) - - }) -}) \ No newline at end of file diff --git a/packages/worker/src/environment.js b/packages/worker/src/environment.ts similarity index 88% rename from packages/worker/src/environment.js rename to packages/worker/src/environment.ts index bb45c1dd78..fd6749a0f7 100644 --- a/packages/worker/src/environment.js +++ b/packages/worker/src/environment.ts @@ -20,13 +20,13 @@ if (!LOADED && isDev() && !isTest()) { LOADED = true } -function parseIntSafe(number) { +function parseIntSafe(number: any) { if (number) { return parseInt(number) } } -module.exports = { +const env = { // auth MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, @@ -47,7 +47,7 @@ module.exports = { CLUSTER_PORT: process.env.CLUSTER_PORT, // flags NODE_ENV: process.env.NODE_ENV, - SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), + SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), LOG_LEVEL: process.env.LOG_LEVEL, MULTI_TENANCY: process.env.MULTI_TENANCY, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, @@ -62,7 +62,7 @@ module.exports = { // other CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, - _set(key, value) { + _set(key: any, value: any) { process.env[key] = value module.exports[key] = value }, @@ -74,16 +74,17 @@ module.exports = { } // if some var haven't been set, define them -if (!module.exports.APPS_URL) { - module.exports.APPS_URL = isDev() - ? "http://localhost:4001" - : "http://app-service:4002" +if (!env.APPS_URL) { + env.APPS_URL = isDev() ? "http://localhost:4001" : "http://app-service:4002" } // clean up any environment variable edge cases for (let [key, value] of Object.entries(module.exports)) { // handle the edge case of "0" to disable an environment variable if (value === "0") { - module.exports[key] = 0 + // @ts-ignore + env[key] = 0 } } + +export = env diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 6fb954a1b5..7819fedeed 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -78,7 +78,7 @@ const shutdown = () => { server.destroy() } -module.exports = server.listen(parseInt(env.PORT || 4002), async () => { +export = server.listen(parseInt(env.PORT || 4002), async () => { console.log(`Worker running on ${JSON.stringify(server.address())}`) await redis.init() }) @@ -92,3 +92,7 @@ process.on("uncaughtException", err => { process.on("SIGTERM", () => { shutdown() }) + +process.on("SIGINT", () => { + shutdown() +}) diff --git a/packages/worker/src/sdk/accounts/accounts.ts b/packages/worker/src/sdk/accounts/accounts.ts new file mode 100644 index 0000000000..d18317aeb2 --- /dev/null +++ b/packages/worker/src/sdk/accounts/accounts.ts @@ -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 => { + 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 => { + 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) + }) +} diff --git a/packages/worker/src/sdk/accounts/index.ts b/packages/worker/src/sdk/accounts/index.ts new file mode 100644 index 0000000000..f2ae03040e --- /dev/null +++ b/packages/worker/src/sdk/accounts/index.ts @@ -0,0 +1 @@ +export * from "./accounts" diff --git a/packages/worker/src/sdk/index.ts b/packages/worker/src/sdk/index.ts index 1b1c47b924..fdc1098361 100644 --- a/packages/worker/src/sdk/index.ts +++ b/packages/worker/src/sdk/index.ts @@ -1 +1,2 @@ export * as users from "./users" +export * as accounts from "./accounts" diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index cc55c0b70b..237eff2078 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -15,9 +15,21 @@ import { accounts, migrations, StaticDatabases, - ViewName + ViewName, } from "@budibase/backend-core" -import { MigrationType, PlatformUserByEmail, User, Account } from "@budibase/types" +import { + MigrationType, + PlatformUserByEmail, + User, + Account, + BulkCreateUsersResponse, + CreateUserResponse, + BulkDeleteUsersResponse, + CloudAccount, + AllDocsResponse, + RowResponse, + BulkDocsResponse, +} from "@budibase/types" import { groups as groupUtils } from "@budibase/pro" const PAGE_LIMIT = 8 @@ -100,7 +112,6 @@ export const getUser = async (userId: string) => { interface SaveUserOpts { hashPassword?: boolean requirePassword?: boolean - bulkCreate?: boolean } const buildUser = async ( @@ -111,7 +122,7 @@ const buildUser = async ( }, tenantId: string, dbUser?: any -) => { +): Promise => { let { password, _id } = user let hashedPassword @@ -145,62 +156,63 @@ const buildUser = async ( return user } +const validateUniqueUser = async (email: string, tenantId: string) => { + // check budibase users in other tenants + if (env.MULTI_TENANCY) { + const tenantUser = await tenancy.getTenantUser(email) + if (tenantUser != null && tenantUser.tenantId !== tenantId) { + throw `Email address ${email} already in use.` + } + } + + // check root account users in account portal + if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { + const account = await accounts.getAccount(email) + if (account && account.verified && account.tenantId !== tenantId) { + throw `Email address ${email} already in use.` + } + } +} + export const save = async ( - user: any, + user: User, opts: SaveUserOpts = { hashPassword: true, requirePassword: true, - bulkCreate: false, } -) => { +): Promise => { const tenantId = tenancy.getTenantId() const db = tenancy.getGlobalDB() let { email, _id } = user - // make sure another user isn't using the same email - let dbUser: any - if (opts.bulkCreate) { - dbUser = null + + let dbUser: User | undefined + if (_id) { + // try to get existing user from db + dbUser = (await db.get(_id)) as User + if (email && dbUser.email !== email) { + throw "Email address cannot be changed" + } + email = dbUser.email } else if (email) { - // check budibase users inside the tenant + // no id was specified - load from email instead dbUser = await usersCore.getGlobalUserByEmail(email) - if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) { + if (dbUser && dbUser._id !== _id) { throw `Email address ${email} already in use.` } - - // check budibase users in other tenants - if (env.MULTI_TENANCY) { - const tenantUser = await tenancy.getTenantUser(email) - if (tenantUser != null && tenantUser.tenantId !== tenantId) { - throw `Email address ${email} already in use.` - } - } - - // check root account users in account portal - if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { - const account = await accounts.getAccount(email) - if (account && account.verified && account.tenantId !== tenantId) { - throw `Email address ${email} already in use.` - } - } - } else if (_id) { - dbUser = await db.get(_id) + } else { + throw new Error("_id or email is required") } + await validateUniqueUser(email, tenantId) + let builtUser = await buildUser(user, opts, tenantId, dbUser) // make sure we set the _id field for a new user if (!_id) { - _id = builtUser._id + _id = builtUser._id! } try { - const putOpts = { - password: builtUser.password, - ...user, - } - if (opts.bulkCreate) { - return putOpts - } // save the user to db let response const putUserFn = () => { @@ -253,25 +265,32 @@ const getExistingTenantUsers = async (emails: string[]): Promise => { return dbUtils.queryGlobalView(ViewName.USER_BY_EMAIL, { keys: emails, include_docs: true, - arrayResponse: true + arrayResponse: true, }) } -const getExistingPlatformUsers = async (emails: string[]): Promise => { - return dbUtils.doWithDB(StaticDatabases.PLATFORM_INFO.name, async (infoDb: any) => { - const response = await infoDb.allDocs({ - keys: emails, - include_docs: true, - }) - return response.rows.map((row: any) => row.doc) - }) +const getExistingPlatformUsers = async ( + emails: string[] +): Promise => { + return dbUtils.doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (infoDb: any) => { + const response = await infoDb.allDocs({ + keys: emails, + include_docs: true, + }) + return response.rows + .filter((row: any) => row.error !== "not_found") + .map((row: any) => row.doc) + } + ) } const getExistingAccounts = async (emails: string[]): Promise => { return dbUtils.queryPlatformView(ViewName.ACCOUNT_BY_EMAIL, { keys: emails, include_docs: true, - arrayResponse: true + arrayResponse: true, }) } @@ -289,18 +308,22 @@ const searchExistingEmails = async (emails: string[]) => { matchedEmails.push(...existingTenantUsers.map((user: User) => user.email)) const existingPlatformUsers = await getExistingPlatformUsers(emails) - matchedEmails.push(...existingPlatformUsers.map((user: PlatformUserByEmail) => user._id!)) + matchedEmails.push( + ...existingPlatformUsers.map((user: PlatformUserByEmail) => user._id!) + ) const existingAccounts = await getExistingAccounts(emails) - matchedEmails.push(...existingAccounts.map((account: Account) => account.email)) + matchedEmails.push( + ...existingAccounts.map((account: Account) => account.email) + ) - return matchedEmails + return [...new Set(matchedEmails)] } export const bulkCreate = async ( newUsersRequested: User[], groups: string[] -) => { +): Promise => { const db = tenancy.getGlobalDB() const tenantId = tenancy.getTenantId() @@ -309,14 +332,17 @@ export const bulkCreate = async ( const emails = newUsersRequested.map((user: User) => user.email) const existingEmails = await searchExistingEmails(emails) - const unsuccessful: { email: string, reason: string }[] = [] + const unsuccessful: { email: string; reason: string }[] = [] for (const newUser of newUsersRequested) { if ( newUsers.find((x: any) => x.email === newUser.email) || existingEmails.includes(newUser.email) ) { - unsuccessful.push({ email: newUser.email, reason: `Email address ${newUser.email} already in use.` }) + unsuccessful.push({ + email: newUser.email, + reason: `Email address ${newUser.email} already in use.`, + }) continue } newUser.userGroups = groups @@ -363,59 +389,121 @@ export const bulkCreate = async ( return { successful: saved, - unsuccessful + 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 => { + 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 => { const db = tenancy.getGlobalDB() + const response: BulkDeleteUsersResponse = { + successful: [], + unsuccessful: [], + } + + // remove the account holder from the delete request if present + const account = await getAccountHolderFromUserIds(userIds) + if (account) { + userIds = userIds.filter(u => u !== account.budibaseUserId) + // mark user as unsuccessful + response.unsuccessful.push({ + _id: account.budibaseUserId, + email: account.email, + reason: "Account holder cannot be deleted", + }) + } + let groupsToModify: any = {} let builderCount = 0 + // Get users and delete - let usersToDelete = ( - await db.allDocs({ - include_docs: true, - keys: userIds, - }) - ).rows.map((user: any) => { - // if we find a user that has an associated group, add it to - // an array so we can easily use allDocs on them later. - // This prevents us having to re-loop over all the users - if (user.doc.userGroups) { - for (let groupId of user.doc.userGroups) { - if (!Object.keys(groupsToModify).includes(groupId)) { - groupsToModify[groupId] = [user.id] - } else { - groupsToModify[groupId] = [...groupsToModify[groupId], user.id] + const allDocsResponse: AllDocsResponse = await db.allDocs({ + include_docs: true, + keys: userIds, + }) + const usersToDelete: User[] = allDocsResponse.rows.map( + (user: RowResponse) => { + // if we find a user that has an associated group, add it to + // an array so we can easily use allDocs on them later. + // This prevents us having to re-loop over all the users + if (user.doc.userGroups) { + for (let groupId of user.doc.userGroups) { + if (!Object.keys(groupsToModify).includes(groupId)) { + groupsToModify[groupId] = [user.id] + } else { + groupsToModify[groupId] = [...groupsToModify[groupId], user.id] + } } } + + // Also figure out how many builders are being deleted + if (eventHelpers.isAddingBuilder(user.doc, null)) { + builderCount++ + } + + return user.doc } + ) - // Also figure out how many builders are being deleted - if (eventHelpers.isAddingBuilder(user.doc, null)) { - builderCount++ - } - - return user.doc - }) - - const response = await db.bulkDocs( - usersToDelete.map((user: any) => ({ + // Delete from DB + const dbResponse: BulkDocsResponse = await db.bulkDocs( + usersToDelete.map(user => ({ ...user, _deleted: true, })) ) + // Deletion post processing await groupUtils.bulkDeleteGroupUsers(groupsToModify) - - //Deletion post processing for (let user of usersToDelete) { await bulkDeleteProcessing(user) } - await quotas.removeDevelopers(builderCount) + // Build Response + // index users by id + const userIndex: { [key: string]: User } = {} + usersToDelete.reduce((prev, current) => { + prev[current._id!] = current + return prev + }, userIndex) + + // add the successful and unsuccessful users to response + dbResponse.forEach(item => { + const email = userIndex[item.id].email + if (item.ok) { + response.successful.push({ _id: item.id, email }) + } else { + response.unsuccessful.push({ + _id: item.id, + email, + reason: "Database error", + }) + } + }) + return response } diff --git a/packages/worker/src/tests/TestConfiguration.js b/packages/worker/src/tests/TestConfiguration.js deleted file mode 100644 index 694c56bfcd..0000000000 --- a/packages/worker/src/tests/TestConfiguration.js +++ /dev/null @@ -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 diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts new file mode 100644 index 0000000000..7dabecc6f0 --- /dev/null +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -0,0 +1,269 @@ +import "./mocks" +import dbConfig from "../db" +dbConfig.init() +import env from "../environment" +import controllers from "./controllers" +const supertest = require("supertest") +import { jwt } from "@budibase/backend-core/auth" +import { Cookies, Headers } from "@budibase/backend-core/constants" +import { Configs } from "../constants" +import { users, tenancy } from "@budibase/backend-core" +import { createASession } from "@budibase/backend-core/sessions" +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 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 = 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 { + 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 = 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 diff --git a/packages/worker/src/tests/api/accounts.ts b/packages/worker/src/tests/api/accounts.ts new file mode 100644 index 0000000000..fe6bf31192 --- /dev/null +++ b/packages/worker/src/tests/api/accounts.ts @@ -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()) + } +} diff --git a/packages/worker/src/tests/api/auth.ts b/packages/worker/src/tests/api/auth.ts new file mode 100644 index 0000000000..204ae9f5dd --- /dev/null +++ b/packages/worker/src/tests/api/auth.ts @@ -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 } + } +} diff --git a/packages/worker/src/tests/api/configs.ts b/packages/worker/src/tests/api/configs.ts new file mode 100644 index 0000000000..3a3c433fa0 --- /dev/null +++ b/packages/worker/src/tests/api/configs.ts @@ -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}` + ) + } +} diff --git a/packages/worker/src/tests/api/email.ts b/packages/worker/src/tests/api/email.ts new file mode 100644 index 0000000000..ea026c22ac --- /dev/null +++ b/packages/worker/src/tests/api/email.ts @@ -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) + } +} diff --git a/packages/worker/src/tests/api/index.ts b/packages/worker/src/tests/api/index.ts new file mode 100644 index 0000000000..a12e489a78 --- /dev/null +++ b/packages/worker/src/tests/api/index.ts @@ -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) + } +} diff --git a/packages/worker/src/tests/api/self.ts b/packages/worker/src/tests/api/self.ts new file mode 100644 index 0000000000..b1cd4a48c6 --- /dev/null +++ b/packages/worker/src/tests/api/self.ts @@ -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) + } +} diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts new file mode 100644 index 0000000000..6f29b39da3 --- /dev/null +++ b/packages/worker/src/tests/api/users.ts @@ -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) + } +} diff --git a/packages/worker/src/tests/index.ts b/packages/worker/src/tests/index.ts index de4c1b7bcd..6ab1e83955 100644 --- a/packages/worker/src/tests/index.ts +++ b/packages/worker/src/tests/index.ts @@ -1,15 +1,14 @@ import TestConfiguration from "./TestConfiguration" import structures from "./structures" import mocks from "./mocks" +import API from "./api" -const config = new TestConfiguration() -const request = config.getRequest() - -const pkg = { +const pkg = { structures, + TENANT_1: structures.TENANT_1, mocks, - config, - request, + TestConfiguration, + API, } export = pkg diff --git a/packages/worker/src/tests/mocks/index.js b/packages/worker/src/tests/mocks/index.js deleted file mode 100644 index 5a85348350..0000000000 --- a/packages/worker/src/tests/mocks/index.js +++ /dev/null @@ -1,5 +0,0 @@ -const email = require("./email") - -module.exports = { - email, -} diff --git a/packages/worker/src/tests/mocks/index.ts b/packages/worker/src/tests/mocks/index.ts new file mode 100644 index 0000000000..e4b68bbfd4 --- /dev/null +++ b/packages/worker/src/tests/mocks/index.ts @@ -0,0 +1,7 @@ +const email = require("./email") +import { mocks as coreMocks } from "@budibase/backend-core/tests" + +export = { + email, + ...coreMocks, +} diff --git a/packages/worker/src/tests/structures/accounts.ts b/packages/worker/src/tests/structures/accounts.ts new file mode 100644 index 0000000000..df6b993684 --- /dev/null +++ b/packages/worker/src/tests/structures/accounts.ts @@ -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(), + } +} diff --git a/packages/worker/src/tests/structures/index.ts b/packages/worker/src/tests/structures/index.ts index 61e683f621..a3029b0105 100644 --- a/packages/worker/src/tests/structures/index.ts +++ b/packages/worker/src/tests/structures/index.ts @@ -1,14 +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" -export = { +const pkg = { configs, users, + accounts, TENANT_ID, + TENANT_1, CSRF_TOKEN, groups, } + +export = pkg diff --git a/packages/worker/src/tests/structures/users.ts b/packages/worker/src/tests/structures/users.ts index 21aa86663c..4bf24ec780 100644 --- a/packages/worker/src/tests/structures/users.ts +++ b/packages/worker/src/tests/structures/users.ts @@ -1,28 +1,32 @@ export const email = "test@test.com" import { AdminUser, BuilderUser, User } from "@budibase/types" +import { v4 as uuid } from "uuid" -export const user = (userProps: any): User => { +export const newEmail = () => { + return `${uuid()}@test.com` +} +export const user = (userProps?: any): User => { return { - email: "test@test.com", + email: newEmail(), password: "test", roles: {}, ...userProps, } } -export const adminUser = (userProps: any): AdminUser => { +export const adminUser = (userProps?: any): AdminUser => { return { ...user(userProps), admin: { global: true, }, builder: { - global: true - } + global: true, + }, } } -export const builderUser = (userProps: any): BuilderUser => { +export const builderUser = (userProps?: any): BuilderUser => { return { ...user(userProps), builder: { diff --git a/packages/worker/src/utilities/redis.js b/packages/worker/src/utilities/redis.js index 97adafa195..7e474b2c28 100644 --- a/packages/worker/src/utilities/redis.js +++ b/packages/worker/src/utilities/redis.js @@ -55,6 +55,7 @@ exports.init = async () => { exports.shutdown = async () => { if (pwResetClient) await pwResetClient.finish() if (invitationClient) await invitationClient.finish() + console.log("Redis shutdown") } /** diff --git a/packages/worker/yarn.lock b/packages/worker/yarn.lock index ada95a1087..bd6d364e8c 100644 --- a/packages/worker/yarn.lock +++ b/packages/worker/yarn.lock @@ -1074,6 +1074,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/uuid@8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"