Refactored email resolvers

* If the email middleware does not send mails, it will give a warning
similar to sentry middleware
* Eliminated GrapphQLClient in one more test
* Rename code => nonce
This commit is contained in:
roschaefer 2019-09-06 18:41:10 +02:00
parent 80d1e03c03
commit e751571981
8 changed files with 201 additions and 149 deletions

View File

@ -3,6 +3,19 @@ import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset'
import { signupTemplate } from './templates/signup'
let sendMail
if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) {
sendMail = async template => {
await transporter().sendMail(template)
}
} else {
sendMail = () => {}
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.log('Warning: Email middleware will not try to send mails.')
}
}
const transporter = () => {
const configs = {
host: CONFIG.SMTP_HOST,
@ -17,41 +30,24 @@ const transporter = () => {
return nodemailer.createTransport(configs)
}
const returnResponse = async (resolve, root, args, context, resolveInfo) => {
const { response } = await resolve(root, args, context, resolveInfo)
delete response.nonce
return response
}
const sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
const { email } = args
const { response, nonce } = await resolve(root, args, context, resolveInfo)
const response = await resolve(root, args, context, resolveInfo)
const { email, nonce } = response
await sendMail(signupTemplate({ email, nonce }))
delete response.nonce
await transporter().sendMail(signupTemplate({ email, nonce }))
return response
}
export default function({ isEnabled }) {
if (!isEnabled)
return {
Mutation: {
requestPasswordReset: returnResponse,
Signup: returnResponse,
SignupByInvitation: returnResponse,
},
}
return {
Mutation: {
requestPasswordReset: async (resolve, root, args, context, resolveInfo) => {
const { email } = args
const { response, user, code, name } = await resolve(root, args, context, resolveInfo)
const mailTemplate = user ? resetPasswordMail : wrongAccountMail
await transporter().sendMail(mailTemplate({ email, code, name }))
return response
},
Signup: sendSignupMail,
SignupByInvitation: sendSignupMail,
export default {
Mutation: {
requestPasswordReset: async (resolve, root, args, context, resolveInfo) => {
const { email } = args
const { email: emailFound, nonce, name } = await resolve(root, args, context, resolveInfo)
const mailTemplate = emailFound ? resetPasswordMail : wrongAccountMail
await sendMail(mailTemplate({ email, nonce, name }))
return true
},
}
Signup: sendSignupMail,
SignupByInvitation: sendSignupMail,
},
}

View File

@ -6,12 +6,12 @@ export const resetPasswordMail = options => {
const {
name,
email,
code,
nonce,
subject = 'Use this link to reset your password. The link is only valid for 24 hours.',
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('code', code)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
@ -37,7 +37,7 @@ The Human Connection Team
If you're having trouble with the link above, you can manually copy and
paste the following code into your browser window:
${code}
${nonce}
Human Connection gemeinnützige GmbH
Bahnhofstr. 11

View File

@ -22,7 +22,7 @@ and create a user account:
${actionUrl}
You can also copy+paste this verification code in your browser window:
You can also copy+paste this verification nonce in your browser window:
${nonce}

View File

@ -33,9 +33,7 @@ export default schema => {
user,
includedFields,
orderBy,
email: email({
isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT,
}),
email,
}
let order = [

View File

@ -2,39 +2,47 @@ import uuid from 'uuid/v4'
import bcrypt from 'bcryptjs'
export async function createPasswordReset(options) {
const { driver, code, email, issuedAt = new Date() } = options
const { driver, nonce, email, issuedAt = new Date() } = options
const session = driver.session()
const cypher = `
let response = {}
try {
const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN u
RETURN e, pr, u
`
const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(),
code,
email,
})
const users = transactionRes.records.map(record => record.get('u'))
session.close()
return users
const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(),
nonce,
email,
})
const records = transactionRes.records.map(record => {
const { email } = record.get('e').properties
const { nonce } = record.get('pr').properties
const { name } = record.get('u').properties
return { email, nonce, name }
})
response = records[0] || {}
} finally {
session.close()
}
return response
}
export default {
Mutation: {
requestPasswordReset: async (_, { email }, { driver }) => {
const code = uuid().substring(0, 6)
const [user] = await createPasswordReset({ driver, code, email })
const name = (user && user.name) || ''
return { user, code, name, response: true }
const nonce = uuid().substring(0, 6)
return createPasswordReset({ driver, nonce, email })
},
resetPassword: async (_, { email, code, newPassword }, { driver }) => {
resetPassword: async (_, { email, nonce, newPassword }, { driver }) => {
const session = driver.session()
const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = `
MATCH (pr:PasswordReset {code: $code})
MATCH (pr:PasswordReset {nonce: $nonce})
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime()
@ -44,13 +52,13 @@ export default {
const transactionRes = await session.run(cypher, {
stillValid,
email,
code,
nonce,
encryptedNewPassword,
})
const [reset] = transactionRes.records.map(record => record.get('pr'))
const result = !!(reset && reset.properties.usedAt)
const response = !!(reset && reset.properties.usedAt)
session.close()
return result
return response
},
},
}

View File

@ -1,12 +1,17 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories'
import { host } from '../../jest/helpers'
import { getDriver } from '../../bootstrap/neo4j'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import { createPasswordReset } from './passwordReset'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
const factory = Factory()
let client
const neode = getNeode()
const driver = getDriver()
const factory = Factory()
let mutate
let authenticatedUser
let variables
const getAllPasswordResets = async () => {
const session = driver.session()
@ -16,120 +21,168 @@ const getAllPasswordResets = async () => {
return resets
}
beforeEach(() => {
variables = {}
})
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('passwordReset', () => {
beforeEach(async () => {
client = new GraphQLClient(host)
await factory.create('User', {
email: 'user@example.org',
role: 'user',
password: '1234',
describe('given a user', () => {
beforeEach(async () => {
await factory.create('User', {
email: 'user@example.org',
})
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('requestPasswordReset', () => {
const mutation = gql`
mutation($email: String!) {
requestPasswordReset(email: $email)
}
`
describe('requestPasswordReset', () => {
const mutation = `mutation($email: String!) { requestPasswordReset(email: $email) }`
describe('with invalid email', () => {
beforeEach(() => {
variables = { ...variables, email: 'non-existent@example.org' }
})
describe('with invalid email', () => {
const variables = { email: 'non-existent@example.org' }
it('resolves anyways', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { requestPasswordReset: true },
})
})
it('resolves anyways', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({
requestPasswordReset: true,
it('creates no node', async () => {
await mutate({ mutation, variables })
const resets = await getAllPasswordResets()
expect(resets).toHaveLength(0)
})
})
it('creates no node', async () => {
await client.request(mutation, variables)
const resets = await getAllPasswordResets()
expect(resets).toHaveLength(0)
})
})
describe('with a valid email', () => {
const variables = { email: 'user@example.org' }
it('resolves', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({
requestPasswordReset: true,
describe('with a valid email', () => {
beforeEach(() => {
variables = { ...variables, email: 'user@example.org' }
})
})
it('creates node with label `PasswordReset`', async () => {
await client.request(mutation, variables)
const resets = await getAllPasswordResets()
expect(resets).toHaveLength(1)
})
it('resolves', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { requestPasswordReset: true },
})
})
it('creates a reset code', async () => {
await client.request(mutation, variables)
const resets = await getAllPasswordResets()
const [reset] = resets
const { code } = reset.properties
expect(code).toHaveLength(6)
it('creates node with label `PasswordReset`', async () => {
let resets = await getAllPasswordResets()
expect(resets).toHaveLength(0)
await mutate({ mutation, variables })
resets = await getAllPasswordResets()
expect(resets).toHaveLength(1)
})
it('creates a reset nonce', async () => {
await mutate({ mutation, variables })
const resets = await getAllPasswordResets()
const [reset] = resets
const { nonce } = reset.properties
expect(nonce).toHaveLength(6)
})
})
})
})
})
describe('resetPassword', () => {
const setup = async (options = {}) => {
const { email = 'user@example.org', issuedAt = new Date(), code = 'abcdef' } = options
describe('resetPassword', () => {
const setup = async (options = {}) => {
const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options
const session = driver.session()
await createPasswordReset({ driver, email, issuedAt, code })
session.close()
const session = driver.session()
await createPasswordReset({ driver, email, issuedAt, nonce })
session.close()
}
const mutation = gql`
mutation($nonce: String!, $email: String!, $newPassword: String!) {
resetPassword(nonce: $nonce, email: $email, newPassword: $newPassword)
}
`
const nonce = 'abcdef'
beforeEach(() => {
variables = { ...variables, newPassword: 'supersecret' }
})
const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }`
const email = 'user@example.org'
const code = 'abcdef'
const newPassword = 'supersecret'
let variables
describe('given a user', () => {
beforeEach(async () => {
await factory.create('User', {
email: 'user@example.org',
role: 'user',
password: '1234',
})
})
describe('invalid email', () => {
it('resolves to false', async () => {
await setup()
variables = { newPassword, email: 'non-existent@example.org', code }
await expect(client.request(mutation, variables)).resolves.toEqual({ resetPassword: false })
variables = { ...variables, email: 'non-existent@example.org', nonce }
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { resetPassword: false },
})
})
})
describe('valid email', () => {
describe('but invalid code', () => {
beforeEach(() => {
variables = { ...variables, email: 'user@example.org' }
})
describe('but invalid nonce', () => {
beforeEach(() => {
variables = { ...variables, nonce: 'slkdjf' }
})
it('resolves to false', async () => {
await setup()
variables = { newPassword, email, code: 'slkdjf' }
await expect(client.request(mutation, variables)).resolves.toEqual({
resetPassword: false,
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { resetPassword: false },
})
})
})
describe('and valid code', () => {
describe('and valid nonce', () => {
beforeEach(() => {
variables = {
newPassword,
email: 'user@example.org',
code: 'abcdef',
...variables,
nonce: 'abcdef',
}
})
describe('and code not expired', () => {
describe('and nonce not expired', () => {
beforeEach(async () => {
await setup()
})
it('resolves to true', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({
resetPassword: true,
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { resetPassword: true },
})
})
it('updates PasswordReset `usedAt` property', async () => {
await client.request(mutation, variables)
await mutate({ mutation, variables })
const requests = await getAllPasswordResets()
const [request] = requests
const { usedAt } = request.properties
@ -137,23 +190,20 @@ describe('passwordReset', () => {
})
it('updates password of the user', async () => {
await client.request(mutation, variables)
const checkLoginMutation = `
mutation($email: String!, $password: String!) {
login(email: $email, password: $password)
}
await mutate({ mutation, variables })
const checkLoginMutation = gql`
mutation($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const expected = expect.objectContaining({ login: expect.any(String) })
variables = { ...variables, email: 'user@example.org', password: 'supersecret' }
await expect(
client.request(checkLoginMutation, {
email: 'user@example.org',
password: 'supersecret',
}),
).resolves.toEqual(expected)
mutate({ mutation: checkLoginMutation, variables }),
).resolves.toMatchObject({ data: { login: expect.any(String) } })
})
})
describe('but expired code', () => {
describe('but expired nonce', () => {
beforeEach(async () => {
const issuedAt = new Date()
issuedAt.setDate(issuedAt.getDate() - 1)
@ -161,13 +211,13 @@ describe('passwordReset', () => {
})
it('resolves to false', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({
resetPassword: false,
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { resetPassword: false },
})
})
it('does not update PasswordReset `usedAt` property', async () => {
await client.request(mutation, variables)
await mutate({ mutation, variables })
const requests = await getAllPasswordResets()
const [request] = requests
const { usedAt } = request.properties

View File

@ -43,7 +43,7 @@ export default {
await checkEmailDoesNotExist({ email: args.email })
try {
const emailAddress = await instance.create('EmailAddress', args)
return { response: emailAddress.toJson(), nonce }
return emailAddress.toJson()
} catch (e) {
throw new UserInputError(e.message)
}
@ -71,7 +71,7 @@ export default {
throw new UserInputError('Invitation code already used or does not exist.')
const emailAddress = await instance.create('EmailAddress', args)
await validInvitationCode.relateTo(emailAddress, 'activated')
return { response: emailAddress.toJson(), nonce }
return emailAddress.toJson()
} catch (e) {
throw new UserInputError(e)
}

View File

@ -23,7 +23,7 @@ type Mutation {
login(email: String!, password: String!): String!
changePassword(oldPassword: String!, newPassword: String!): String!
requestPasswordReset(email: String!): Boolean!
resetPassword(email: String!, code: String!, newPassword: String!): Boolean!
resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean!
report(id: ID!, description: String): Report
disable(id: ID!): ID
enable(id: ID!): ID