Fully type the worker redis utils file.

This commit is contained in:
Sam Rose 2023-11-09 11:05:42 +00:00
parent cfecff479d
commit 3f69b17c94
No known key found for this signature in database
7 changed files with 124 additions and 86 deletions

View File

@ -12,7 +12,7 @@ import { generator } from "./generator"
import { tenant } from "." import { tenant } from "."
export const newEmail = () => { export const newEmail = () => {
return `${uuid()}@test.com` return `${uuid()}@example.com`
} }
export const user = (userProps?: Partial<Omit<User, "userId">>): User => { export const user = (userProps?: Partial<Omit<User, "userId">>): User => {

View File

@ -1,5 +1,6 @@
import { import {
checkInviteCode, getInviteCode,
deleteInviteCode,
getInviteCodes, getInviteCodes,
updateInviteCode, updateInviteCode,
} from "../../../utilities/redis" } from "../../../utilities/redis"
@ -336,10 +337,11 @@ export const checkInvite = async (ctx: any) => {
const { code } = ctx.params const { code } = ctx.params
let invite let invite
try { try {
invite = await checkInviteCode(code, false) invite = await getInviteCode(code)
} catch (e) { } catch (e) {
console.warn("Error getting invite from code", e) console.warn("Error getting invite from code", e)
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
ctx.body = { ctx.body = {
email: invite.email, email: invite.email,
@ -365,12 +367,10 @@ export const updateInvite = async (ctx: any) => {
let invite let invite
try { try {
invite = await checkInviteCode(code, false) invite = await getInviteCode(code)
if (!invite) {
throw new Error("The invite could not be retrieved")
}
} catch (e) { } catch (e) {
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
let updated = { let updated = {
@ -405,7 +405,8 @@ export const inviteAccept = async (
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
// info is an extension of the user object that was stored by global // info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode) const { email, info }: any = await getInviteCode(inviteCode)
await deleteInviteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => { const user = await tenancy.doInTenant(info.tenantId, async () => {
let request: any = { let request: any = {
firstName, firstName,

View File

@ -1,11 +1,12 @@
import { InviteUsersResponse, User } from "@budibase/types" import { InviteUsersResponse, User } from "@budibase/types"
jest.mock("nodemailer")
import { TestConfiguration, mocks, structures } from "../../../../tests" import { TestConfiguration, mocks, structures } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core" import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
import * as userSdk from "../../../../sdk/users" import * as userSdk from "../../../../sdk/users"
jest.mock("nodemailer")
const sendMailMock = mocks.email.mock()
const accounts = jest.mocked(_accounts) const accounts = jest.mocked(_accounts)
describe("/api/global/users", () => { describe("/api/global/users", () => {
@ -54,6 +55,22 @@ describe("/api/global/users", () => {
expect(events.user.invited).toBeCalledTimes(0) expect(events.user.invited).toBeCalledTimes(0)
}) })
it("should not invite the same user twice", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
const { code, res } = await config.api.users.sendUserInvite(
sendMailMock,
email,
400
)
expect(res.body.message).toBe(`Unavailable`)
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(code).toBeUndefined()
expect(events.user.invited).toBeCalledTimes(0)
})
it("should be able to create new user from invite", async () => { it("should be able to create new user from invite", async () => {
const email = structures.users.newEmail() const email = structures.users.newEmail()
const { code } = await config.api.users.sendUserInvite( const { code } = await config.api.users.sendUserInvite(

View File

@ -73,7 +73,7 @@ export const reset = async (email: string) => {
* Perform the user password update if the provided reset code is valid. * Perform the user password update if the provided reset code is valid.
*/ */
export const resetUpdate = async (resetCode: string, password: string) => { export const resetUpdate = async (resetCode: string, password: string) => {
const { userId } = await redis.checkResetPasswordCode(resetCode) const { userId } = await redis.getResetPasswordCode(resetCode)
let user = await userSdk.db.getUser(userId) let user = await userSdk.db.getUser(userId)
user.password = password user.password = password

View File

@ -2,6 +2,7 @@ import { events, tenancy, users as usersCore } from "@budibase/backend-core"
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
import { getInviteCodes } from "../..//utilities/redis"
export async function invite( export async function invite(
users: InviteUsersRequest users: InviteUsersRequest
@ -14,6 +15,7 @@ export async function invite(
const matchedEmails = await usersCore.searchExistingEmails( const matchedEmails = await usersCore.searchExistingEmails(
users.map(u => u.email) users.map(u => u.email)
) )
const existingInvites = await getInviteCodes()
const newUsers = [] const newUsers = []
// separate duplicates from new users // separate duplicates from new users

View File

@ -3,7 +3,7 @@ import { EmailTemplatePurpose, TemplateType } from "../constants"
import { getTemplateByPurpose, EmailTemplates } from "../constants/templates" import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates" import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis" import { createResetPasswordCode, createInviteCode } from "./redis"
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
import ical from "ical-generator" import ical from "ical-generator"
@ -61,9 +61,9 @@ async function getLinkCode(
) { ) {
switch (purpose) { switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY: case EmailTemplatePurpose.PASSWORD_RECOVERY:
return getResetPasswordCode(user._id!, info) return createResetPasswordCode(user._id!, info)
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION:
return getInviteCode(email, info) return createInviteCode(email, info)
default: default:
return null return null
} }

View File

@ -1,60 +1,24 @@
import { redis, utils, tenancy } from "@budibase/backend-core" import { redis, utils, tenancy } from "@budibase/backend-core"
import env from "../environment" import env from "../environment"
function getExpirySecondsForDB(db: string) { interface Invite {
switch (db) { email: string
case redis.utils.Databases.PW_RESETS: info: any
// a hour
return 3600
case redis.utils.Databases.INVITATIONS:
// a week
return 604800
}
} }
let pwResetClient: any, invitationClient: any interface InviteWithCode extends Invite {
code: string
function getClient(db: string) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
return pwResetClient
case redis.utils.Databases.INVITATIONS:
return invitationClient
}
} }
async function writeACode(db: string, value: any) { interface PasswordReset {
const client = await getClient(db) userId: string
const code = utils.newid() info: any
await client.store(code, value, getExpirySecondsForDB(db))
return code
} }
async function updateACode(db: string, code: string, value: any) { type RedisDBName =
const client = await getClient(db) | redis.utils.Databases.PW_RESETS
await client.store(code, value, getExpirySecondsForDB(db)) | redis.utils.Databases.INVITATIONS
} let pwResetClient: redis.Client, invitationClient: redis.Client
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateInviteCode(inviteCode: string, value: string) {
await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
}
async function getACode(db: string, code: string, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
if (!value) {
throw new Error("Invalid code.")
}
if (deleteCode) {
await client.delete(code)
}
return value
}
export async function init() { export async function init() {
pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS) pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS)
@ -63,9 +27,6 @@ export async function init() {
await invitationClient.init() await invitationClient.init()
} }
/**
* make sure redis connection is closed.
*/
export async function shutdown() { export async function shutdown() {
if (pwResetClient) await pwResetClient.finish() if (pwResetClient) await pwResetClient.finish()
if (invitationClient) await invitationClient.finish() if (invitationClient) await invitationClient.finish()
@ -74,6 +35,65 @@ export async function shutdown() {
console.log("Redis shutdown") console.log("Redis shutdown")
} }
function getExpirySecondsForDB(db: RedisDBName) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
// a hour
return 3600
case redis.utils.Databases.INVITATIONS:
// a week
return 604800
default:
throw new Error(`Unknown redis database: ${db}`)
}
}
function getClient(db: RedisDBName): redis.Client {
switch (db) {
case redis.utils.Databases.PW_RESETS:
return pwResetClient
case redis.utils.Databases.INVITATIONS:
return invitationClient
default:
throw new Error(`Unknown redis database: ${db}`)
}
}
async function writeCode(db: RedisDBName, value: Invite | PasswordReset) {
const client = getClient(db)
const code = utils.newid()
await client.store(code, value, getExpirySecondsForDB(db))
return code
}
async function updateCode(db: RedisDBName, code: string, value: any) {
const client = getClient(db)
await client.store(code, value, getExpirySecondsForDB(db))
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateInviteCode(code: string, value: Invite) {
await updateCode(redis.utils.Databases.INVITATIONS, code, value)
}
async function deleteCode(db: RedisDBName, code: string) {
const client = getClient(db)
await client.delete(code)
}
async function getCode(db: RedisDBName, code: string) {
const client = getClient(db)
const value = await client.get(code)
if (!value) {
throw new Error(`Could not find code: ${code}`)
}
return value
}
/** /**
* Given a user ID this will store a code (that is returned) for an hour in redis. * Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link). * The user can then return this code for resetting their password (through their reset link).
@ -81,22 +101,20 @@ export async function shutdown() {
* @param info Info about the user/the reset process. * @param info Info about the user/the reset process.
* @return returns the code that was stored to redis. * @return returns the code that was stored to redis.
*/ */
export async function getResetPasswordCode(userId: string, info: any) { export async function createResetPasswordCode(userId: string, info: any) {
return writeACode(redis.utils.Databases.PW_RESETS, { userId, info }) return writeCode(redis.utils.Databases.PW_RESETS, { userId, info })
} }
/** /**
* Given a reset code this will lookup to redis, check if the code is valid and delete if required. * Given a reset code this will lookup to redis, check if the code is valid.
* @param resetCode The code provided via the email link. * @param resetCode The code provided via the email link.
* @param deleteCode If the code is used/finished with this will delete it - defaults to true.
* @return returns the user ID if it is found * @return returns the user ID if it is found
*/ */
export async function checkResetPasswordCode( export async function getResetPasswordCode(
resetCode: string, code: string
deleteCode = true ): Promise<PasswordReset> {
) {
try { try {
return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode) return getCode(redis.utils.Databases.PW_RESETS, code)
} catch (err) { } catch (err) {
throw "Provided information is not valid, cannot reset password - please try again." throw "Provided information is not valid, cannot reset password - please try again."
} }
@ -108,35 +126,35 @@ export async function checkResetPasswordCode(
* @param info Information to be carried along with the invitation. * @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis. * @return returns the code that was stored to redis.
*/ */
export async function getInviteCode(email: string, info: any) { export async function createInviteCode(email: string, info: any) {
return writeACode(redis.utils.Databases.INVITATIONS, { email, info }) return writeCode(redis.utils.Databases.INVITATIONS, { email, info })
} }
/** /**
* Checks that the provided invite code is valid - will return the email address of user that was invited. * Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param inviteCode the invite code that was provided as part of the link. * @param inviteCode the invite code that was provided as part of the link.
* @param deleteCode whether or not the code should be deleted after retrieval - defaults to true.
* @return If the code is valid then an email address will be returned. * @return If the code is valid then an email address will be returned.
*/ */
export async function checkInviteCode( export async function getInviteCode(code: string): Promise<Invite> {
inviteCode: string,
deleteCode: boolean = true
) {
try { try {
return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode) return getCode(redis.utils.Databases.INVITATIONS, code)
} catch (err) { } catch (err) {
throw "Invitation is not valid or has expired, please request a new one." throw "Invitation is not valid or has expired, please request a new one."
} }
} }
export async function deleteInviteCode(code: string) {
return deleteCode(redis.utils.Databases.INVITATIONS, code)
}
/** /**
Get all currently available user invitations for the current tenant. Get all currently available user invitations for the current tenant.
**/ **/
export async function getInviteCodes() { export async function getInviteCodes(): Promise<InviteWithCode[]> {
const client = await getClient(redis.utils.Databases.INVITATIONS) const client = getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan() const invites: { key: string; value: Invite }[] = await client.scan()
const results = invites.map(invite => { const results: InviteWithCode[] = invites.map(invite => {
return { return {
...invite.value, ...invite.value,
code: invite.key, code: invite.key,