diff --git a/backend/src/graphql/resolver/EmailOptinCodes.test.ts b/backend/src/graphql/resolver/EmailOptinCodes.test.ts new file mode 100644 index 000000000..d7c0b9bd6 --- /dev/null +++ b/backend/src/graphql/resolver/EmailOptinCodes.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { testEnvironment, cleanDB } from '@test/helpers' +import { User as DbUser } from '@entity/User' +import { createUser, setPassword, forgotPassword } from '@/seeds/graphql/mutations' +import { queryOptIn } from '@/seeds/graphql/queries' +import CONFIG from '@/config' +import { GraphQLError } from 'graphql' + +let mutate: any, query: any, con: any +let testEnv: any + +CONFIG.EMAIL_CODE_VALID_TIME = 1440 +CONFIG.EMAIL_CODE_REQUEST_TIME = 10 +CONFIG.EMAIL = false + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('EmailOptinCodes', () => { + let optinCode: string + beforeAll(async () => { + const variables = { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + } + const { + data: { createUser: user }, + } = await mutate({ mutation: createUser, variables }) + const dbObject = await DbUser.findOneOrFail({ + where: { id: user.id }, + relations: ['emailContact'], + }) + optinCode = dbObject.emailContact.emailVerificationCode.toString() + }) + + describe('queryOptIn', () => { + it('has a valid optin code', async () => { + await expect( + query({ query: queryOptIn, variables: { optIn: optinCode } }), + ).resolves.toMatchObject({ + data: { + queryOptIn: true, + }, + errors: undefined, + }) + }) + + describe('run time forward until code must be expired', () => { + beforeAll(() => { + jest.useFakeTimers() + setTimeout(jest.fn(), CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000) + jest.runAllTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('throws an error', async () => { + await expect( + query({ query: queryOptIn, variables: { optIn: optinCode } }), + ).resolves.toMatchObject({ + data: null, + errors: [new GraphQLError('email was sent more than 24 hours ago')], + }) + }) + + it('does not allow to set password', async () => { + await expect( + mutate({ mutation: setPassword, variables: { code: optinCode, password: 'Aa12345_' } }), + ).resolves.toMatchObject({ + data: null, + errors: [new GraphQLError('email was sent more than 24 hours ago')], + }) + }) + }) + }) + + describe('forgotPassword', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }), + ).resolves.toMatchObject({ + data: null, + errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')], + }) + }) + + describe('run time forward until code can be resent', () => { + beforeAll(() => { + jest.useFakeTimers() + setTimeout(jest.fn(), CONFIG.EMAIL_CODE_REQUEST_TIME * 60 * 1000) + jest.runAllTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('cann send email again', async () => { + await expect( + mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }), + ).resolves.toMatchObject({ + data: { + forgotPassword: true, + }, + errors: undefined, + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index aa2b21d2e..484aabca6 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -29,7 +29,6 @@ import { sendAccountMultiRegistrationEmail, sendResetPasswordEmail, } from '@/emails/sendEmailVariants' -import { activationLink } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink' import { ContributionLink } from '@model/ContributionLink' @@ -815,12 +814,8 @@ describe('UserResolver', () => { }) describe('user exists in DB', () => { - let emailContact: UserContact - beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - // await resetEntity(LoginEmailOptIn) - emailContact = await UserContact.findOneOrFail(variables) }) afterAll(async () => { @@ -829,7 +824,7 @@ describe('UserResolver', () => { }) describe('duration not expired', () => { - it('returns true', async () => { + it('throws an error', async () => { await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( expect.objectContaining({ errors: [ @@ -855,19 +850,19 @@ describe('UserResolver', () => { }), ) }) - }) - it('sends reset password email', () => { - expect(sendResetPasswordEmail).toBeCalledWith({ - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - language: 'de', - resetLink: activationLink(emailContact.emailVerificationCode), - timeDurationObject: expect.objectContaining({ - hours: expect.any(Number), - minutes: expect.any(Number), - }), + it('sends reset password email', () => { + expect(sendResetPasswordEmail).toBeCalledWith({ + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + language: 'de', + resetLink: expect.any(String), + timeDurationObject: expect.objectContaining({ + hours: expect.any(Number), + minutes: expect.any(Number), + }), + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 711dc48af..a1e70b019 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -126,16 +126,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { return [pubKey, privKey] } -/* -const getEmailHash = (email: string): Buffer => { - logger.trace('getEmailHash...') - const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) - sodium.crypto_generichash(emailHash, Buffer.from(email)) - logger.debug(`getEmailHash...successful: ${emailHash}`) - return emailHash -} -*/ - const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { logger.trace('SecretKeyCryptographyEncrypt...') const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES) @@ -171,91 +161,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => { logger.debug(`newEmailContact...successful: ${emailContact}`) return emailContact } -/* -const newEmailOptIn = (userId: number): LoginEmailOptIn => { - logger.trace('newEmailOptIn...') - const emailOptIn = new LoginEmailOptIn() - emailOptIn.verificationCode = random(64) - emailOptIn.userId = userId - emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER - logger.debug(`newEmailOptIn...successful: ${emailOptIn}`) - return emailOptIn -} -*/ -/* -// needed by AdminResolver -// checks if given code exists and can be resent -// if optIn does not exits, it is created -export const checkOptInCode = async ( - optInCode: LoginEmailOptIn | undefined, - user: DbUser, - optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER, -): Promise => { - logger.info(`checkOptInCode... ${optInCode}`) - if (optInCode) { - if (!canResendOptIn(optInCode)) { - logger.error( - `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, - ) - throw new Error( - `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, - ) - } - optInCode.updatedAt = new Date() - optInCode.resendCount++ - } else { - logger.trace('create new OptIn for userId=' + user.id) - optInCode = newEmailOptIn(user.id) - } - - if (user.emailChecked) { - optInCode.emailOptInTypeId = optInType - } - await LoginEmailOptIn.save(optInCode).catch(() => { - logger.error('Unable to save optin code= ' + optInCode) - throw new Error('Unable to save optin code.') - }) - logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`) - return optInCode -} -*/ -export const checkEmailVerificationCode = async ( - emailContact: DbUserContact, - optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER, -): Promise => { - logger.info(`checkEmailVerificationCode... ${emailContact}`) - if (emailContact.updatedAt) { - if (!canEmailResend(emailContact.updatedAt)) { - logger.error( - `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, - ) - throw new Error( - `email already sent less than ${printTimeDuration( - CONFIG.EMAIL_CODE_REQUEST_TIME, - )} minutes ago`, - ) - } - emailContact.updatedAt = new Date() - emailContact.emailResendCount++ - } else { - logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId) - emailContact.emailChecked = false - emailContact.emailVerificationCode = random(64) - } - emailContact.emailOptInTypeId = optInType - await DbUserContact.save(emailContact).catch(() => { - logger.error('Unable to save email verification code= ' + emailContact) - throw new Error('Unable to save email verification code.') - }) - logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`) - return emailContact -} export const activationLink = (verificationCode: BigInt): string => { logger.debug(`activationLink(${verificationCode})...`) @@ -366,6 +271,7 @@ export class UserResolver { @Authorized([RIGHTS.LOGOUT]) @Mutation(() => String) async logout(): Promise { + // TODO: Event still missing here!! // TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token. // Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login) // The functionality is fully client side - the client just needs to delete his token with the current implementation. @@ -579,32 +485,45 @@ export class UserResolver { return true } - // can be both types: REGISTER and RESET_PASSWORD - // let optInCode = await LoginEmailOptIn.findOne({ - // userId: user.id, - // }) - // let optInCode = user.emailContact.emailVerificationCode - const dbUserContact = await checkEmailVerificationCode( - user.emailContact, - OptInType.EMAIL_OPT_IN_RESET_PASSWORD, - ) + if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) { + logger.error( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ) + throw new Error( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ) + } - // optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) - logger.info(`optInCode for ${email}=${dbUserContact}`) + user.emailContact.updatedAt = new Date() + user.emailContact.emailResendCount++ + user.emailContact.emailVerificationCode = random(64) + user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD + await user.emailContact.save().catch(() => { + logger.error('Unable to save email verification code= ' + user.emailContact) + throw new Error('Unable to save email verification code.') + }) + + logger.info(`optInCode for ${email}=${user.emailContact}`) // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmail({ firstName: user.firstName, lastName: user.lastName, email, language: user.language, - resetLink: activationLink(dbUserContact.emailVerificationCode), + resetLink: activationLink(user.emailContact.emailVerificationCode), timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), }) /* uncomment this, when you need the activation link on the console */ // In case EMails are disabled log the activation link for the user if (!emailSent) { - logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`) + logger.debug( + `Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`, + ) } logger.info(`forgotPassword(${email}) successful...`) @@ -642,7 +561,7 @@ export class UserResolver { }) logger.debug('userContact loaded...') // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes - if (!isEmailVerificationCodeValid(userContact.updatedAt)) { + if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) { logger.error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) @@ -746,7 +665,7 @@ export class UserResolver { const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn }) logger.debug(`found optInCode=${userContact}`) // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes - if (!isEmailVerificationCodeValid(userContact.updatedAt)) { + if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) { logger.error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) @@ -1055,6 +974,9 @@ export class UserResolver { throw new Error(`The emailContact: ${email} of this User is deleted.`) } + emailContact.emailResendCount++ + await emailContact.save() + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ firstName: user.firstName, @@ -1119,10 +1041,7 @@ const isOptInValid = (optIn: LoginEmailOptIn): boolean => { return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME) } */ -const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => { - if (updatedAt == null) { - return true - } +const isEmailVerificationCodeValid = (updatedAt: Date): boolean => { return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME) } /* diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 7ee8e6052..1935b01a0 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -5,6 +5,7 @@ import { createTestClient } from 'apollo-server-testing' import createServer from '../src/server/createServer' import { initialize } from '@dbTools/helpers' import { entities } from '@entity/index' +import { i18n, logger } from './testSetup' export const headerPushMock = jest.fn((t) => { context.token = t.value @@ -26,8 +27,8 @@ export const cleanDB = async () => { } } -export const testEnvironment = async (logger?: any, localization?: any) => { - const server = await createServer(context, logger, localization) +export const testEnvironment = async (testLogger: any = logger, testI18n: any = i18n) => { + const server = await createServer(context, testLogger, testI18n) const con = server.con const testClient = createTestClient(server.apollo) const mutate = testClient.mutate