Use neode to bring User mutations under control

This commit takes all backend changes for signup and invite feature. I
was working on these features and removed the generated mutations for
type user along the way.
This commit is contained in:
Robert Schäfer 2019-07-03 15:58:42 +02:00
parent 324330ad8e
commit 10ae4abaae
47 changed files with 1520 additions and 458 deletions

View File

@ -29,9 +29,10 @@ script:
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run test:cucumber --tags "not @wip"
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
# ActivityPub cucumber testing temporarily disabled because it's too buggy
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
# - docker-compose exec backend yarn run db:reset
# - docker-compose exec backend yarn run db:seed
# Frontend
- docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage

View File

@ -42,17 +42,18 @@
]
},
"dependencies": {
"@hapi/joi": "^15.1.0",
"activitystrea.ms": "~2.1.3",
"apollo-cache-inmemory": "~1.6.2",
"apollo-client": "~2.6.3",
"apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15",
"apollo-server": "~2.6.7",
"apollo-server": "~2.6.6",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.2",
"date-fns": "2.0.0-beta.1",
"debug": "~4.1.1",
"dotenv": "~8.0.0",
"express": "~4.17.1",
@ -71,6 +72,7 @@
"merge-graphql-schemas": "^1.5.8",
"neo4j-driver": "~1.7.4",
"neo4j-graphql-js": "^2.6.3",
"neode": "^0.2.16",
"node-fetch": "~2.6.0",
"nodemailer": "^6.2.1",
"npm-run-all": "~4.1.5",

View File

@ -1,5 +1,6 @@
import { v1 as neo4j } from 'neo4j-driver'
import CONFIG from './../config'
import setupNeode from './neode'
let driver
@ -14,3 +15,12 @@ export function getDriver(options = {}) {
}
return driver
}
let neodeInstance
export function neode() {
if (!neodeInstance) {
const { NEO4J_URI: uri, NEO4J_USERNAME: username, NEO4J_PASSWORD: password } = CONFIG
neodeInstance = setupNeode({ uri, username, password })
}
return neodeInstance
}

View File

@ -0,0 +1,88 @@
import Neode from 'neode'
import uuid from 'uuid/v4'
export default function setupNeode(options) {
const { uri, username, password } = options
const neodeInstance = new Neode(uri, username, password)
neodeInstance.model('InvitationCode', {
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
token: { type: 'string', primary: true, token: true },
generatedBy: {
type: 'relationship',
relationship: 'GENERATED',
target: 'User',
direction: 'in',
},
activated: {
type: 'relationship',
relationship: 'ACTIVATED',
target: 'EmailAddress',
direction: 'out',
},
})
neodeInstance.model('EmailAddress', {
email: { type: 'string', primary: true, lowercase: true, email: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
verifiedAt: { type: 'string', isoDate: true },
nonce: { type: 'string', token: true },
belongsTo: {
type: 'relationship',
relationship: 'BELONGS_TO',
target: 'User',
direction: 'out',
},
})
neodeInstance.model('User', {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] },
name: { type: 'string', min: 3 },
email: { type: 'string', lowercase: true, email: true },
slug: 'string',
encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] },
coverImg: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
role: 'string',
publicKey: 'string',
privateKey: 'string',
wasInvited: 'boolean',
wasSeeded: 'boolean',
locationName: { type: 'string', allow: [null] },
about: { type: 'string', allow: [null] },
primaryEmail: {
type: 'relationship',
relationship: 'PRIMARY_EMAIL',
target: 'EmailAddress',
direction: 'out',
},
following: {
type: 'relationship',
relationship: 'FOLLOWS',
target: 'User',
direction: 'out',
},
followedBy: {
type: 'relationship',
relationship: 'FOLLOWS',
target: 'User',
direction: 'in',
},
friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' },
disabledBy: {
type: 'relationship',
relationship: 'DISABLED',
target: 'User',
direction: 'in',
},
invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
})
return neodeInstance
}

View File

@ -0,0 +1,7 @@
import { hashSync } from 'bcryptjs'
export default function(args) {
args.encryptedPassword = hashSync(args.password, 10)
delete args.password
return args
}

View File

@ -4,12 +4,13 @@ import { request } from 'graphql-request'
// not to be confused with the seeder host
export const host = 'http://127.0.0.1:4123'
export async function login({ email, password }) {
export async function login(variables) {
const mutation = `
mutation {
login(email:"${email}", password:"${password}")
}`
const response = await request(host, mutation)
mutation($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const response = await request(host, mutation, variables)
return {
authorization: `Bearer ${response.login}`,
}

View File

@ -46,7 +46,7 @@ export default {
}
return post
},
CreateUser: async (resolve, root, args, context, info) => {
SignupVerification: async (resolve, root, args, context, info) => {
const keys = generateRsaKeyPair()
Object.assign(args, keys)
args.actorId = `${activityPub.host}/activitypub/users/${args.slug}`

View File

@ -9,7 +9,6 @@ const setUpdatedAt = (resolve, root, args, context, info) => {
export default {
Mutation: {
CreateUser: setCreatedAt,
CreatePost: setCreatedAt,
CreateComment: setCreatedAt,
CreateOrganization: setCreatedAt,

View File

@ -0,0 +1,57 @@
import CONFIG from '../../config'
import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset'
import { signupTemplate } from './templates/signup'
const transporter = () => {
const configs = {
host: CONFIG.SMTP_HOST,
port: CONFIG.SMTP_PORT,
ignoreTLS: CONFIG.SMTP_IGNORE_TLS,
secure: false, // true for 465, false for other ports
}
const { SMTP_USERNAME: user, SMTP_PASSWORD: pass } = CONFIG
if (user && pass) {
configs.auth = { user, pass }
}
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)
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,
},
}
}

View File

@ -0,0 +1,85 @@
import CONFIG from '../../../config'
export const from = '"Human Connection" <info@human-connection.org>'
export const resetPasswordMail = options => {
const {
name,
email,
code,
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('email', email)
return {
to: email,
subject,
text: `
Hi ${name}!
You recently requested to reset your password for your Human Connection account.
Use the link below to reset it. This password reset is only valid for the next
24 hours.
${actionUrl}
If you did not request a password reset, please ignore this email or contact
support if you have questions:
${supportUrl}
Thanks,
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}
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}
export const wrongAccountMail = options => {
const {
email,
subject = `We received a request to reset your password with this email address (${email})`,
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI)
return {
to: email,
subject,
text: `
We received a request to reset the password to access Human Connection with your
email address, but we were unable to find an account associated with this
address.
If you use Human Connection and were expecting this email, consider trying to
request a password reset using the email address associated with your account.
Try a different email:
${actionUrl}
If you do not use Human Connection or did not request a password reset, please
ignore this email. Feel free to contact support if you have further questions:
${supportUrl}
Thanks,
The Human Connection Team
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}

View File

@ -0,0 +1,42 @@
import CONFIG from '../../../config'
export const from = '"Human Connection" <info@human-connection.org>'
export const signupTemplate = options => {
const {
email,
nonce,
subject = 'Signup link',
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
return {
to: email,
subject,
text: `
Welcome to Human Connection! Use this link to complete the registration process
and create a user account:
${actionUrl}
You can also copy+paste this verification code in your browser window:
${nonce}
If you did not signed up for Human Connection, please ignore this email or
contact support if you have questions:
${supportUrl}
Thanks,
The Human Connection Team
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}

View File

@ -1,6 +1,5 @@
import CONFIG from './../config'
import activityPub from './activityPubMiddleware'
import password from './passwordMiddleware'
import softDelete from './softDeleteMiddleware'
import sluggify from './sluggifyMiddleware'
import excerpt from './excerptMiddleware'
@ -10,14 +9,14 @@ import permissions from './permissionsMiddleware'
import user from './userMiddleware'
import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation'
import validation from './validation/validationMiddleware'
import notifications from './notifications'
import email from './email/emailMiddleware'
export default schema => {
const middlewares = {
permissions: permissions,
activityPub: activityPub,
password: password,
dateTime: dateTime,
validation: validation,
sluggify: sluggify,
@ -28,16 +27,17 @@ export default schema => {
user: user,
includedFields: includedFields,
orderBy: orderBy,
email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }),
}
let order = [
'permissions',
'activityPub',
'password',
// 'activityPub', disabled temporarily
'dateTime',
'validation',
'sluggify',
'excerpt',
'email',
'notifications',
'xss',
'softDelete',

View File

@ -1,21 +0,0 @@
import bcrypt from 'bcryptjs'
import walkRecursive from '../helpers/walkRecursive'
export default {
Mutation: {
CreateUser: async (resolve, root, args, context, info) => {
args.password = await bcrypt.hashSync(args.password, 10)
const result = await resolve(root, args, context, info)
result.password = '*****'
return result
},
},
Query: async (resolve, root, args, context, info) => {
let result = await resolve(root, args, context, info)
result = walkRecursive(result, ['password', 'privateKey'], () => {
// replace password with asterisk
return '*****'
})
return result
},
}

View File

@ -1,4 +1,4 @@
import { rule, shield, deny, allow, or } from 'graphql-shield'
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
/*
* TODO: implement
@ -70,6 +70,29 @@ const onlyEnabledContent = rule({
return !(disabled || deleted)
})
const invitationLimitReached = rule({
cache: 'no_cache',
})(async (parent, args, { user, driver }) => {
const session = driver.session()
try {
const result = await session.run(
`
MATCH (user:User {id:$id})-[:GENERATED]->(i:InvitationCode)
RETURN COUNT(i) as count
`,
{ id: user.id },
)
const [count] = result.records.map(record => {
return record.get('count').toNumber()
})
return count >= 3
} catch (e) {
throw e
} finally {
session.close()
}
})
const isAuthor = rule({
cache: 'no_cache',
})(async (parent, args, { user, driver }) => {
@ -101,6 +124,12 @@ const isDeletingOwnAccount = rule({
return context.user.id === args.id
})
const noEmailFilter = rule({
cache: 'no_cache',
})(async (_, args) => {
return !('email' in args)
})
// Permissions
const permissions = shield(
{
@ -115,14 +144,17 @@ const permissions = shield(
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),
Comment: allow,
User: allow,
User: or(noEmailFilter, isAdmin),
isLoggedIn: allow,
},
Mutation: {
'*': deny,
login: allow,
SignupByInvitation: allow,
Signup: isAdmin,
SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
UpdateNotification: belongsToMe,
CreateUser: isAdmin,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
@ -131,7 +163,6 @@ const permissions = shield(
CreateBadge: isAdmin,
UpdateBadge: isAdmin,
DeleteBadge: isAdmin,
AddUserBadges: isAdmin,
CreateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated,
// AddBadgeRewarded: isAdmin,
@ -154,8 +185,6 @@ const permissions = shield(
},
User: {
email: isMyOwn,
password: isMyOwn,
privateKey: isMyOwn,
},
},
{

View File

@ -13,6 +13,10 @@ const isUniqueFor = (context, type) => {
export default {
Mutation: {
SignupVerification: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
return resolve(root, args, context, info)
},
CreatePost: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
@ -21,10 +25,6 @@ export default {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info)
},
CreateUser: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
return resolve(root, args, context, info)
},
CreateOrganization: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Organization')))
return resolve(root, args, context, info)

View File

@ -1,10 +1,12 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../seed/factories'
import { host, login } from '../jest/helpers'
import { neode } from '../bootstrap/neo4j'
let authenticatedClient
let headers
const factory = Factory()
const instance = neode()
beforeEach(async () => {
const adminParams = { role: 'admin', email: 'admin@example.org', password: '1234' }
@ -76,33 +78,41 @@ describe('slugify', () => {
})
})
describe('CreateUser', () => {
const action = async (mutation, params) => {
return authenticatedClient.request(`mutation {
${mutation}(password: "yo", email: "123@123.de", ${params}) { slug }
}`)
describe('SignupVerification', () => {
const mutation = `mutation($password: String!, $email: String!, $name: String!, $slug: String, $nonce: String!) {
SignupVerification(email: $email, password: $password, name: $name, slug: $slug, nonce: $nonce) { slug }
}
`
const action = async variables => {
// required for SignupVerification
await instance.create('EmailAddress', { email: '123@example.org', nonce: '123456' })
const defaultVariables = { nonce: '123456', password: 'yo', email: '123@example.org' }
return authenticatedClient.request(mutation, { ...defaultVariables, ...variables })
}
it('generates a slug based on name', async () => {
await expect(action('CreateUser', 'name: "I am a user"')).resolves.toEqual({
CreateUser: { slug: 'i-am-a-user' },
await expect(action({ name: 'I am a user' })).resolves.toEqual({
SignupVerification: { slug: 'i-am-a-user' },
})
})
describe('if slug exists', () => {
beforeEach(async () => {
await action('CreateUser', 'name: "Pre-existing user", slug: "pre-existing-user"')
await factory.create('User', { name: 'pre-existing user', slug: 'pre-existing-user' })
})
it('chooses another slug', async () => {
await expect(action('CreateUser', 'name: "pre-existing-user"')).resolves.toEqual({
CreateUser: { slug: 'pre-existing-user-1' },
await expect(action({ name: 'pre-existing-user' })).resolves.toEqual({
SignupVerification: { slug: 'pre-existing-user-1' },
})
})
describe('but if the client specifies a slug', () => {
it('rejects CreateUser', async () => {
it('rejects SignupVerification', async () => {
await expect(
action('CreateUser', 'name: "Pre-existing user", slug: "pre-existing-user"'),
action({ name: 'Pre-existing user', slug: 'pre-existing-user' }),
).rejects.toThrow('already exists')
})
})

View File

@ -2,7 +2,7 @@ import createOrUpdateLocations from './nodes/locations'
export default {
Mutation: {
CreateUser: async (resolve, root, args, context, info) => {
SignupVerification: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver)
return result

View File

@ -1,16 +1,5 @@
import { UserInputError } from 'apollo-server'
const USERNAME_MIN_LENGTH = 3
const validateUsername = async (resolve, root, args, context, info) => {
if (!('name' in args) || (args.name && args.name.length >= USERNAME_MIN_LENGTH)) {
/* eslint-disable-next-line no-return-await */
return await resolve(root, args, context, info)
} else {
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} characters long!`)
}
}
const validateUrl = async (resolve, root, args, context, info) => {
const { url } = args
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
@ -24,8 +13,6 @@ const validateUrl = async (resolve, root, args, context, info) => {
export default {
Mutation: {
CreateUser: validateUsername,
UpdateUser: validateUsername,
CreateSocialMedia: validateUrl,
},
}

View File

@ -0,0 +1,22 @@
import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const validate = schema => {
return async (resolve, root, args, context, info) => {
const validation = schema.validate(args)
if (validation.error) throw new UserInputError(validation.error)
return resolve(root, args, context, info)
}
}
const socialMediaSchema = Joi.object().keys({
url: Joi.string()
.uri()
.required(),
})
export default {
Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
},
}

View File

@ -12,10 +12,12 @@ export default applyScalars(
resolvers,
config: {
query: {
exclude: ['Notfication', 'Statistics', 'LoggedInUser'],
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
// add 'User' here as soon as possible
},
mutation: {
exclude: ['Notfication', 'Statistics', 'LoggedInUser'],
exclude: ['InvitationCode', 'EmailAddress', 'Notfication', 'Statistics', 'LoggedInUser'],
// add 'User' here as soon as possible
},
debug: CONFIG.DEBUG,
},

View File

@ -9,12 +9,16 @@ let createCommentVariables
let createPostVariables
let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost
let userParams
let authorParams
beforeEach(async () => {
await factory.create('User', {
userParams = {
name: 'TestUser',
email: 'test@example.org',
password: '1234',
})
}
await factory.create('User', userParams)
})
afterEach(async () => {
@ -53,10 +57,7 @@ describe('CreateComment', () => {
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
headers = await login(userParams)
client = new GraphQLClient(host, {
headers,
})
@ -89,7 +90,7 @@ describe('CreateComment', () => {
const { User } = await client.request(gql`
{
User(email: "test@example.org") {
User(name: "TestUser") {
comments {
content
}
@ -201,15 +202,13 @@ describe('DeleteComment', () => {
}
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
password: '1234',
}
const asAuthor = Factory()
await asAuthor.create('User', {
email: 'author@example.org',
password: '1234',
})
await asAuthor.authenticateAs({
email: 'author@example.org',
password: '1234',
})
await asAuthor.create('User', authorParams)
await asAuthor.authenticateAs(authorParams)
await asAuthor.create('Post', {
id: 'p1',
content: 'Post to be commented',
@ -233,13 +232,8 @@ describe('DeleteComment', () => {
describe('authenticated but not the author', () => {
beforeEach(async () => {
let headers
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
it('throws authorization error', async () => {
@ -252,13 +246,8 @@ describe('DeleteComment', () => {
describe('authenticated as author', () => {
beforeEach(async () => {
let headers
headers = await login({
email: 'author@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})
it('deletes the comment', async () => {

View File

@ -254,7 +254,7 @@ describe('enable', () => {
beforeEach(async () => {
authenticateClient = setupAuthenticateClient({
role: 'moderator',
email: 'someUser@example.org',
email: 'someuser@example.org',
password: '1234',
})
})

View File

@ -1,22 +1,5 @@
import uuid from 'uuid/v4'
import bcrypt from 'bcryptjs'
import CONFIG from '../../config'
import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './passwordReset/emailTemplates'
const transporter = () => {
const configs = {
host: CONFIG.SMTP_HOST,
port: CONFIG.SMTP_PORT,
ignoreTLS: CONFIG.SMTP_IGNORE_TLS,
secure: false, // true for 465, false for other ports
}
const { SMTP_USERNAME: user, SMTP_PASSWORD: pass } = CONFIG
if (user && pass) {
configs.auth = { user, pass }
}
return nodemailer.createTransport(configs)
}
export async function createPasswordReset(options) {
const { driver, code, email, issuedAt = new Date() } = options
@ -42,27 +25,28 @@ export default {
requestPasswordReset: async (_, { email }, { driver }) => {
const code = uuid().substring(0, 6)
const [user] = await createPasswordReset({ driver, code, email })
if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) {
const name = (user && user.name) || ''
const mailTemplate = user ? resetPasswordMail : wrongAccountMail
await transporter().sendMail(mailTemplate({ email, code, name }))
}
return true
const name = (user && user.name) || ''
return { user, code, name, response: true }
},
resetPassword: async (_, { email, code, newPassword }, { driver }) => {
const session = driver.session()
const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1)
const newHashedPassword = await bcrypt.hashSync(newPassword, 10)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = `
MATCH (pr:PasswordReset {code: $code})
MATCH (u:User {email: $email})-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime()
SET u.password = $newHashedPassword
SET u.encryptedPassword = $encryptedNewPassword
RETURN pr
`
let transactionRes = await session.run(cypher, { stillValid, email, code, newHashedPassword })
let transactionRes = await session.run(cypher, {
stillValid,
email,
code,
encryptedNewPassword,
})
const [reset] = transactionRes.records.map(record => record.get('pr'))
const result = !!(reset && reset.properties.usedAt)
session.close()

View File

@ -4,6 +4,9 @@ import { host, login } from '../../jest/helpers'
const factory = Factory()
let client
let userParams
let authorParams
const postTitle = 'I am a title'
const postContent = 'Some content'
const oldTitle = 'Old title'
@ -33,10 +36,16 @@ const postQueryWithCategories = `
}
`
beforeEach(async () => {
await factory.create('User', {
userParams = {
name: 'TestUser',
email: 'test@example.org',
password: '1234',
})
}
authorParams = {
email: 'author@example.org',
password: '1234',
}
await factory.create('User', userParams)
})
afterEach(async () => {
@ -66,7 +75,7 @@ describe('CreatePost', () => {
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
@ -84,7 +93,7 @@ describe('CreatePost', () => {
await client.request(mutation, createPostVariables)
const { User } = await client.request(
`{
User(email:"test@example.org") {
User(name: "TestUser") {
contributions {
title
}
@ -163,14 +172,8 @@ describe('UpdatePost', () => {
let updatePostVariables
beforeEach(async () => {
const asAuthor = Factory()
await asAuthor.create('User', {
email: 'author@example.org',
password: '1234',
})
await asAuthor.authenticateAs({
email: 'author@example.org',
password: '1234',
})
await asAuthor.create('User', authorParams)
await asAuthor.authenticateAs(authorParams)
await asAuthor.create('Post', {
id: 'p1',
title: oldTitle,
@ -205,7 +208,7 @@ describe('UpdatePost', () => {
describe('authenticated but not the author', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
@ -219,7 +222,7 @@ describe('UpdatePost', () => {
describe('authenticated as author', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'author@example.org', password: '1234' })
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})
@ -297,14 +300,8 @@ describe('DeletePost', () => {
beforeEach(async () => {
const asAuthor = Factory()
await asAuthor.create('User', {
email: 'author@example.org',
password: '1234',
})
await asAuthor.authenticateAs({
email: 'author@example.org',
password: '1234',
})
await asAuthor.create('User', authorParams)
await asAuthor.authenticateAs(authorParams)
await asAuthor.create('Post', {
id: 'p1',
content: 'To be deleted',
@ -321,7 +318,7 @@ describe('DeletePost', () => {
describe('authenticated but not the author', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
@ -333,7 +330,7 @@ describe('DeletePost', () => {
describe('authenticated as author', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'author@example.org', password: '1234' })
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})

View File

@ -0,0 +1,107 @@
import { UserInputError } from 'apollo-server'
import uuid from 'uuid/v4'
import { neode } from '../../bootstrap/neo4j'
import fileUpload from './fileUpload'
import encryptPassword from '../../helpers/encryptPassword'
const instance = neode()
/*
* TODO: remove this function as soon type `User` has no `email` property
* anymore
*/
const checkEmailDoesNotExist = async ({ email }) => {
email = email.toLowerCase()
const users = await instance.all('User', { email })
if (users.length > 0) throw new UserInputError('User account with this email already exists.')
}
export default {
Mutation: {
CreateInvitationCode: async (parent, args, context, resolveInfo) => {
args.token = uuid().substring(0, 6)
const {
user: { id: userId },
} = context
let response
try {
const [user, invitationCode] = await Promise.all([
instance.find('User', userId),
instance.create('InvitationCode', args),
])
await invitationCode.relateTo(user, 'generatedBy')
response = invitationCode.toJson()
response.generatedBy = user.toJson()
} catch (e) {
throw new UserInputError(e)
}
return response
},
Signup: async (parent, args, context, resolveInfo) => {
const nonce = uuid().substring(0, 6)
args.nonce = nonce
await checkEmailDoesNotExist({ email: args.email })
try {
const emailAddress = await instance.create('EmailAddress', args)
return { response: emailAddress.toJson(), nonce }
} catch (e) {
throw new UserInputError(e.message)
}
},
SignupByInvitation: async (parent, args, context, resolveInfo) => {
const { token } = args
const nonce = uuid().substring(0, 6)
args.nonce = nonce
await checkEmailDoesNotExist({ email: args.email })
try {
const result = await instance.cypher(
`
MATCH (invitationCode:InvitationCode {token:{token}})
WHERE NOT (invitationCode)-[:ACTIVATED]->()
RETURN invitationCode
`,
{ token },
)
const validInvitationCode = instance.hydrateFirst(
result,
'invitationCode',
instance.model('InvitationCode'),
)
if (!validInvitationCode)
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 }
} catch (e) {
throw new UserInputError(e)
}
},
SignupVerification: async (object, args, context, resolveInfo) => {
let { nonce, email } = args
email = email.toLowerCase()
const result = await instance.cypher(
`
MATCH(email:EmailAddress {nonce: {nonce}, email: {email}})
WHERE NOT (email)-[:BELONGS_TO]->()
RETURN email
`,
{ nonce, email },
)
const emailAddress = await instance.hydrateFirst(result, 'email', instance.model('Email'))
if (!emailAddress) throw new UserInputError('Invalid email or nonce')
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
args = await encryptPassword(args)
try {
const user = await instance.create('User', args)
await Promise.all([
user.relateTo(emailAddress, 'primaryEmail'),
emailAddress.relateTo(user, 'belongsTo'),
emailAddress.update({ verifiedAt: new Date().toISOString() }),
])
return user.toJson()
} catch (e) {
throw new UserInputError(e.message)
}
},
},
}

View File

@ -0,0 +1,395 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories'
import { host, login } from '../../jest/helpers'
import { neode } from '../../bootstrap/neo4j'
let factory
let client
let variables
let action
let userParams
const instance = neode()
beforeEach(async () => {
variables = {}
factory = Factory()
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('CreateInvitationCode', () => {
const mutation = `mutation { CreateInvitationCode { token } }`
it('throws Authorization error', async () => {
const client = new GraphQLClient(host)
await expect(client.request(mutation)).rejects.toThrow('Not Authorised!')
})
describe('authenticated', () => {
beforeEach(async () => {
userParams = {
id: 'i123',
name: 'Inviter',
email: 'inviter@example.org',
password: '1234',
}
action = async () => {
const factory = Factory()
await factory.create('User', userParams)
const headers = await login(userParams)
client = new GraphQLClient(host, { headers })
return client.request(mutation)
}
})
it('resolves', async () => {
await expect(action()).resolves.toEqual({
CreateInvitationCode: { token: expect.any(String) },
})
})
it('creates an InvitationCode with a `createdAt` attribute', async () => {
await action()
const codes = await instance.all('InvitationCode')
const invitation = await codes.first().toJson()
expect(invitation.createdAt).toBeTruthy()
expect(Date.parse(invitation.createdAt)).toEqual(expect.any(Number))
})
it('relates inviting User to InvitationCode', async () => {
await action()
const result = await instance.cypher(
'MATCH(code:InvitationCode)<-[:GENERATED]-(user:User) RETURN user',
)
const inviter = instance.hydrateFirst(result, 'user', instance.model('User'))
await expect(inviter.toJson()).resolves.toEqual(expect.objectContaining({ name: 'Inviter' }))
})
describe('who has invited a lot of users already', () => {
beforeEach(() => {
action = async () => {
const factory = Factory()
await factory.create('User', userParams)
const headers = await login(userParams)
client = new GraphQLClient(host, { headers })
await Promise.all(
[1, 2, 3].map(() => {
return client.request(mutation)
}),
)
return client.request(mutation, variables)
}
})
describe('as ordinary `user`', () => {
it('throws `Not Authorised` because of maximum number of invitations', async () => {
await expect(action()).rejects.toThrow('Not Authorised')
})
it('creates no additional invitation codes', async done => {
try {
await action()
} catch (e) {
const invitationCodes = await instance.all('InvitationCode')
await expect(invitationCodes.toJson()).resolves.toHaveLength(3)
done()
}
})
})
describe('as a strong donator', () => {
beforeEach(() => {
// What is the setup?
})
it.todo('can invite more people')
// it('can invite more people', async () => {
// await action()
// const invitationQuery = `{ User { createdAt } }`
// const { User: users } = await client.request(invitationQuery )
// expect(users).toHaveLength(3 + 1 + 1)
// })
})
})
})
})
describe('SignupByInvitation', () => {
const mutation = `mutation($email: String!, $token: String!) {
SignupByInvitation(email: $email, token: $token) { email }
}`
beforeEach(() => {
client = new GraphQLClient(host)
action = async () => {
return client.request(mutation, variables)
}
})
it.todo('throws Authorization error')
describe('with invalid InvitationCode', () => {
beforeEach(() => {
variables.token = 'wut?'
})
it.todo('throws UserInputError')
})
describe('with valid InvitationCode', () => {
beforeEach(async () => {
const inviterParams = {
name: 'Inviter',
email: 'inviter@example.org',
password: '1234',
}
const factory = Factory()
await factory.create('User', inviterParams)
const headersOfInviter = await login(inviterParams)
const anotherClient = new GraphQLClient(host, { headers: headersOfInviter })
const invitationMutation = `mutation { CreateInvitationCode { token } }`
const {
CreateInvitationCode: { token },
} = await anotherClient.request(invitationMutation)
variables.token = token
})
describe('given an invalid email', () => {
beforeEach(() => {
variables.email = 'someuser'
})
it('throws `email is not a valid email`', async () => {
await expect(action()).rejects.toThrow('"email" must be a valid email')
})
it('creates no EmailAddress node', async done => {
try {
await action()
} catch (e) {
const emailAddresses = await instance.all('EmailAddress')
expect(emailAddresses).toHaveLength(0)
done()
}
})
})
describe('given a valid email', () => {
beforeEach(() => {
variables.email = 'someUser@example.org'
})
it('resolves', async () => {
await expect(action()).resolves.toEqual({
SignupByInvitation: { email: 'someuser@example.org' },
})
})
describe('creates a EmailAddress node', () => {
it('with a `createdAt` attribute', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
expect(emailAddress.createdAt).toBeTruthy()
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
})
it('with a cryptographic `nonce`', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
expect(emailAddress.nonce).toEqual(expect.any(String))
})
it('connects inviter through invitation code', async () => {
await action()
const result = await instance.cypher(
'MATCH(inviter:User)-[:GENERATED]->(:InvitationCode)-[:ACTIVATED]->(email:EmailAddress {email: {email}}) RETURN inviter',
{ email: 'someuser@example.org' },
)
const inviter = instance.hydrateFirst(result, 'inviter', instance.model('User'))
await expect(inviter.toJson()).resolves.toEqual(
expect.objectContaining({ name: 'Inviter' }),
)
})
describe('using the same InvitationCode twice', () => {
it('rejects because codes can be used only once', async done => {
await action()
try {
await action()
} catch (e) {
expect(e.message).toMatch(/Invitation code already used/)
done()
}
})
})
describe('if a user account with the given email already exists', () => {
beforeEach(async () => {
await factory.create('User', { email: 'someuser@example.org' })
})
it('throws unique violation error', async () => {
await expect(action()).rejects.toThrow('User account with this email already exists.')
})
})
describe('if the EmailAddress already exists but without user account', () => {
it.todo('decide what to do')
})
})
})
})
})
describe('Signup', () => {
const mutation = `mutation($email: String!) {
Signup(email: $email) { email }
}`
it.todo('throws AuthorizationError')
describe('as admin', () => {
beforeEach(async () => {
userParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
variables.email = 'someuser@example.org'
const factory = Factory()
await factory.create('User', userParams)
const headers = await login(userParams)
client = new GraphQLClient(host, { headers })
action = async () => {
return client.request(mutation, variables)
}
})
it('is allowed to signup users by email', async () => {
await expect(action()).resolves.toEqual({ Signup: { email: 'someuser@example.org' } })
})
it('creates a Signup with a cryptographic `nonce`', async () => {
await action()
const emailAddresses = await instance.all('EmailAddress')
const emailAddress = await emailAddresses.first().toJson()
expect(emailAddress.nonce).toEqual(expect.any(String))
})
})
})
describe('SignupVerification', () => {
const mutation = `
mutation($name: String!, $password: String!, $email: String!, $nonce: String!) {
SignupVerification(name: $name, password: $password, email: $email, nonce: $nonce) {
id
}
}
`
describe('given valid password and email', () => {
let variables = {
nonce: '123456',
name: 'John Doe',
password: '123',
email: 'john@example.org',
}
describe('unauthenticated', () => {
beforeEach(async () => {
client = new GraphQLClient(host)
})
describe('EmailAddress exists, but is already related to a user account', () => {
beforeEach(async () => {
const { email, nonce } = variables
const [emailAddress, user] = await Promise.all([
instance.model('EmailAddress').create({ email, nonce }),
instance
.model('User')
.create({ name: 'Somebody', password: '1234', email: 'john@example.org' }),
])
await emailAddress.relateTo(user, 'belongsTo')
})
describe('sending a valid nonce', () => {
beforeEach(() => {
variables.nonce = '123456'
})
it('rejects', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow(
'Invalid email or nonce',
)
})
})
})
describe('disconnected EmailAddress exists', () => {
beforeEach(async () => {
const args = {
email: 'john@example.org',
nonce: '123456',
}
await instance.model('EmailAddress').create(args)
})
describe('sending a valid nonce', () => {
it('creates a user account', async () => {
const expected = {
SignupVerification: {
id: expect.any(String),
},
}
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
it('sets `verifiedAt` attribute of EmailAddress', async () => {
await client.request(mutation, variables)
const email = await instance.first('EmailAddress', { email: 'john@example.org' })
await expect(email.toJson()).resolves.toEqual(
expect.objectContaining({
verifiedAt: expect.any(String),
}),
)
})
it('connects User with EmailAddress', async () => {
const cypher = `
MATCH(email:EmailAddress)-[:BELONGS_TO]->(u:User {name: {name}})
RETURN email
`
await client.request(mutation, variables)
const { records: emails } = await instance.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
it('marks the EmailAddress as primary', async () => {
const cypher = `
MATCH(email:EmailAddress)<-[:PRIMARY_EMAIL]-(u:User {name: {name}})
RETURN email
`
await client.request(mutation, variables)
const { records: emails } = await instance.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
})
describe('sending invalid nonce', () => {
beforeEach(() => {
variables.nonce = 'wut2'
})
it('rejects', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow(
'Invalid email or nonce',
)
})
})
})
})
})
})

View File

@ -98,14 +98,19 @@ describe('SocialMedia', () => {
const variables = {
url: '',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL')
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" is not allowed to be empty',
)
})
it('validates URLs', async () => {
const variables = {
url: 'not-a-url',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL')
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" must be a valid uri',
)
})
})
})

View File

@ -5,50 +5,39 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Query: {
isLoggedIn: (parent, args, { driver, user }) => {
isLoggedIn: (_, args, { driver, user }) => {
return Boolean(user && user.id)
},
currentUser: async (object, params, ctx, resolveInfo) => {
const { user } = ctx
if (!user) return null
return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, false)
return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, true)
},
},
Mutation: {
signup: async (parent, { email, password }, { req }) => {
// if (data[email]) {
// throw new Error('Another User with same email exists.')
// }
// data[email] = {
// password: await bcrypt.hashSync(password, 10),
// }
return true
},
login: async (parent, { email, password }, { driver, req, user }) => {
login: async (_, { email, password }, { driver, req, user }) => {
// if (user && user.id) {
// throw new Error('Already logged in.')
// }
const session = driver.session()
const result = await session.run(
'MATCH (user:User {email: $userEmail}) ' +
'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role, .disabled} as user LIMIT 1',
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1',
{
userEmail: email,
},
)
session.close()
const [currentUser] = await result.records.map(function(record) {
const [currentUser] = await result.records.map(record => {
return record.get('user')
})
if (
currentUser &&
(await bcrypt.compareSync(password, currentUser.password)) &&
(await bcrypt.compareSync(password, currentUser.encryptedPassword)) &&
!currentUser.disabled
) {
delete currentUser.password
delete currentUser.encryptedPassword
return encode(currentUser)
} else if (currentUser && currentUser.disabled) {
throw new AuthenticationError('Your account has been disabled.')
@ -60,7 +49,7 @@ export default {
const session = driver.session()
let result = await session.run(
`MATCH (user:User {email: $userEmail})
RETURN user {.id, .email, .password}`,
RETURN user {.id, .email, .encryptedPassword}`,
{
userEmail: user.email,
},
@ -70,22 +59,22 @@ export default {
return record.get('user')
})
if (!(await bcrypt.compareSync(oldPassword, currentUser.password))) {
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
throw new AuthenticationError('Old password is not correct')
}
if (await bcrypt.compareSync(newPassword, currentUser.password)) {
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) {
throw new AuthenticationError('Old password and new password should be different')
} else {
const newHashedPassword = await bcrypt.hashSync(newPassword, 10)
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
session.run(
`MATCH (user:User {email: $userEmail})
SET user.password = $newHashedPassword
SET user.encryptedPassword = $newEncryptedPassword
RETURN user
`,
{
userEmail: user.email,
newHashedPassword,
newEncryptedPassword,
},
)
session.close()

View File

@ -1,4 +1,3 @@
import gql from 'graphql-tag'
import { GraphQLClient, request } from 'graphql-request'
import jwt from 'jsonwebtoken'
import CONFIG from './../../config'
@ -311,121 +310,3 @@ describe('change password', () => {
})
})
})
describe('do not expose private RSA key', () => {
let headers
let client
let authenticatedClient
const queryUserPuplicKey = gql`
query($queriedUserSlug: String) {
User(slug: $queriedUserSlug) {
id
publicKey
}
}
`
const queryUserPrivateKey = gql`
query($queriedUserSlug: String) {
User(slug: $queriedUserSlug) {
id
privateKey
}
}
`
const generateUserWithKeys = async authenticatedClient => {
// Generate user with "privateKey" via 'CreateUser' mutation instead of using the factories "factory.create('User', {...})", see above.
const variables = {
id: 'bcb2d923-f3af-479e-9f00-61b12e864667',
password: 'xYz',
slug: 'apfel-strudel',
name: 'Apfel Strudel',
email: 'apfel-strudel@test.org',
}
await authenticatedClient.request(
gql`
mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String!) {
CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) {
id
}
}
`,
variables,
)
}
beforeEach(async () => {
const adminParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
// create an admin user who has enough permissions to create other users
await factory.create('User', adminParams)
const headers = await login(adminParams)
authenticatedClient = new GraphQLClient(host, { headers })
// but also create an unauthenticated client to issue the `User` query
client = new GraphQLClient(host)
})
describe('unauthenticated query of "publicKey" (does the RSA key pair get generated at all?)', () => {
it('returns publicKey', async () => {
await generateUserWithKeys(authenticatedClient)
await expect(
await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }),
).toEqual(
expect.objectContaining({
User: [
{
id: 'bcb2d923-f3af-479e-9f00-61b12e864667',
publicKey: expect.any(String),
},
],
}),
)
})
})
describe('unauthenticated query of "privateKey"', () => {
it('throws "Not Authorised!"', async () => {
await generateUserWithKeys(authenticatedClient)
await expect(
client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }),
).rejects.toThrow('Not Authorised')
})
})
// authenticate
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
describe('authenticated query of "publicKey"', () => {
it('returns publicKey', async () => {
await generateUserWithKeys(authenticatedClient)
await expect(
await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }),
).toEqual(
expect.objectContaining({
User: [
{
id: 'bcb2d923-f3af-479e-9f00-61b12e864667',
publicKey: expect.any(String),
},
],
}),
)
})
})
describe('authenticated query of "privateKey"', () => {
it('throws "Not Authorised!"', async () => {
await generateUserWithKeys(authenticatedClient)
await expect(
client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }),
).rejects.toThrow('Not Authorised')
})
})
})

View File

@ -1,15 +1,84 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server'
const instance = neode()
const _has = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const count = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `
MATCH(u:User {id: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export const hasMany = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
export const hasOne = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
export default {
Mutation: {
UpdateUser: async (object, params, context, resolveInfo) => {
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
return neo4jgraphql(object, params, context, resolveInfo, false)
Query: {
User: async (object, args, context, resolveInfo) => {
return neo4jgraphql(object, args, context, resolveInfo, false)
},
CreateUser: async (object, params, context, resolveInfo) => {
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
return neo4jgraphql(object, params, context, resolveInfo, false)
},
Mutation: {
UpdateUser: async (object, args, context, resolveInfo) => {
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
try {
let user = await instance.find('User', args.id)
if (!user) return null
await user.update(args)
return user.toJson()
} catch (e) {
throw new UserInputError(e.message)
}
},
DeleteUser: async (object, params, context, resolveInfo) => {
const { resource } = params
@ -34,4 +103,43 @@ export default {
return neo4jgraphql(object, params, context, resolveInfo, false)
},
},
User: {
...undefinedToNull([
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
]),
...count({
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
}),
...hasOne({
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
}),
...hasMany({
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED]->(related:SocialMedia)',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '-[:REWARDED]->(related:Badge)',
}),
},
}

View File

@ -11,51 +11,32 @@ afterEach(async () => {
})
describe('users', () => {
describe('CreateUser', () => {
const mutation = `
mutation($name: String, $password: String!, $email: String!) {
CreateUser(name: $name, password: $password, email: $email) {
id
describe('User', () => {
const query = `query($email: String) { User(email: $email) { id } }`
const variables = { email: 'any-email-address@example.org' }
beforeEach(() => {
client = new GraphQLClient(host)
})
it('is forbidden', async () => {
await expect(client.request(query, variables)).rejects.toThrow('Not Authorised')
})
describe('as admin', () => {
beforeEach(async () => {
const userParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
}
`
describe('given valid password and email', () => {
const variables = {
name: 'John Doe',
password: '123',
email: '123@123.de',
}
describe('unauthenticated', () => {
beforeEach(async () => {
client = new GraphQLClient(host)
})
it('is not allowed to create users', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
})
const factory = Factory()
await factory.create('User', userParams)
const headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
describe('authenticated admin', () => {
beforeEach(async () => {
const adminParams = {
role: 'admin',
email: 'admin@example.org',
password: '1234',
}
await factory.create('User', adminParams)
const headers = await login(adminParams)
client = new GraphQLClient(host, { headers })
})
it('is allowed to create new users', async () => {
const expected = {
CreateUser: {
id: expect.any(String),
},
}
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
it('is permitted', async () => {
await expect(client.request(query, variables)).resolves.toEqual({ User: [] })
})
})
})
@ -88,7 +69,7 @@ describe('users', () => {
describe('as another user', () => {
beforeEach(async () => {
const someoneElseParams = {
email: 'someoneElse@example.org',
email: 'someone-else@example.org',
password: '1234',
name: 'James Doe',
}
@ -119,12 +100,12 @@ describe('users', () => {
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
})
it('with no name', async () => {
it('with `null` as name', async () => {
const variables = {
id: 'u47',
name: null,
}
const expected = 'Username must be at least 3 characters long!'
const expected = '"name" must be a string'
await expect(client.request(mutation, variables)).rejects.toThrow(expected)
})
@ -133,7 +114,7 @@ describe('users', () => {
id: 'u47',
name: ' ',
}
const expected = 'Username must be at least 3 characters long!'
const expected = '"name" length must be at least 3 characters long'
await expect(client.request(mutation, variables)).rejects.toThrow(expected)
})
})
@ -164,7 +145,7 @@ describe('users', () => {
id: 'u343',
})
await factory.create('User', {
email: 'friendsAccount@example.org',
email: 'friends-account@example.org',
password: '1234',
id: 'u565',
})

View File

@ -23,7 +23,6 @@ type Query {
type Mutation {
# Get a JWT Token for the given Email and password
login(email: String!, password: String!): String!
signup(email: String!, password: String!): Boolean!
changePassword(oldPassword: String!, newPassword: String!): String!
requestPasswordReset(email: String!): Boolean!
resetPassword(email: String!, code: String!, newPassword: String!): Boolean!
@ -40,7 +39,6 @@ type Mutation {
follow(id: ID!, type: FollowTypeEnum): Boolean!
# Unfollow the given Type and ID
unfollow(id: ID!, type: FollowTypeEnum): Boolean!
DeleteUser(id: ID!, resource: [Deletable]): User
}
type Statistics {

View File

@ -0,0 +1,23 @@
type EmailAddress {
id: ID!
email: String!
verifiedAt: String
createdAt: String
}
type Mutation {
Signup(email: String!): EmailAddress
SignupByInvitation(email: String!, token: String!): EmailAddress
SignupVerification(
nonce: String!
name: String!
email: String!
password: String!
slug: String
avatar: String
coverImg: String
avatarUpload: Upload
locationName: String
about: String
): User
}

View File

@ -0,0 +1,13 @@
type InvitationCode {
id: ID!
token: String
generatedBy: User @relation(name: "GENERATED", direction: "IN")
#createdAt: DateTime
#usedAt: DateTime
createdAt: String
}
type Mutation {
CreateInvitationCode: InvitationCode
}

View File

@ -3,20 +3,16 @@ type User {
actorId: String
name: String
email: String!
slug: String
password: String!
slug: String!
avatar: String
coverImg: String
avatarUpload: Upload
deleted: Boolean
disabled: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
role: UserGroup
publicKey: String
privateKey: String
wasInvited: Boolean
wasSeeded: Boolean
invitedBy: User @relation(name: "INVITED", direction: "IN")
invited: [User] @relation(name: "INVITED", direction: "OUT")
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
@ -78,3 +74,89 @@ type User {
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
}
input _UserFilter {
AND: [_UserFilter!]
OR: [_UserFilter!]
id: ID
id_not: ID
id_in: [ID!]
id_not_in: [ID!]
id_contains: ID
id_not_contains: ID
id_starts_with: ID
id_not_starts_with: ID
id_ends_with: ID
id_not_ends_with: ID
friends: _UserFilter
friends_not: _UserFilter
friends_in: [_UserFilter!]
friends_not_in: [_UserFilter!]
friends_some: _UserFilter
friends_none: _UserFilter
friends_single: _UserFilter
friends_every: _UserFilter
following: _UserFilter
following_not: _UserFilter
following_in: [_UserFilter!]
following_not_in: [_UserFilter!]
following_some: _UserFilter
following_none: _UserFilter
following_single: _UserFilter
following_every: _UserFilter
followedBy: _UserFilter
followedBy_not: _UserFilter
followedBy_in: [_UserFilter!]
followedBy_not_in: [_UserFilter!]
followedBy_some: _UserFilter
followedBy_none: _UserFilter
followedBy_single: _UserFilter
followedBy_every: _UserFilter
}
type Query {
User(
id: ID
email: String
actorId: String
name: String
slug: String
avatar: String
coverImg: String
role: UserGroup
locationName: String
about: String
createdAt: String
updatedAt: String
friendsCount: Int
followingCount: Int
followedByCount: Int
followedByCurrentUser: Boolean
contributionsCount: Int
commentsCount: Int
commentedCount: Int
shoutedCount: Int
badgesCount: Int
first: Int
offset: Int
orderBy: [_UserOrdering]
filter: _UserFilter # adding this would expose email
): [User]
}
type Mutation {
UpdateUser (
id: ID!
name: String
email: String
slug: String
avatar: String
coverImg: String
avatarUpload: Upload
locationName: String
about: String
): User
DeleteUser(id: ID!, resource: [Deletable]): User
}

View File

@ -1,5 +1,5 @@
import { GraphQLClient, request } from 'graphql-request'
import { getDriver } from '../../bootstrap/neo4j'
import { getDriver, neode } from '../../bootstrap/neo4j'
import createBadge from './badges.js'
import createUser from './users.js'
import createOrganization from './organizations.js'
@ -48,7 +48,11 @@ export const cleanDatabase = async (options = {}) => {
}
export default function Factory(options = {}) {
const { neo4jDriver = getDriver(), seedServerHost = 'http://127.0.0.1:4001' } = options
let {
seedServerHost = 'http://127.0.0.1:4001',
neo4jDriver = getDriver(),
neodeInstance = neode(),
} = options
const graphQLClient = new GraphQLClient(seedServerHost)
@ -58,19 +62,23 @@ export default function Factory(options = {}) {
graphQLClient,
factories,
lastResponse: null,
neodeInstance,
async authenticateAs({ email, password }) {
const headers = await authenticatedHeaders({ email, password }, seedServerHost)
this.lastResponse = headers
this.graphQLClient = new GraphQLClient(seedServerHost, { headers })
return this
},
async create(node, properties) {
const { mutation, variables } = this.factories[node](properties)
this.lastResponse = await this.graphQLClient.request(mutation, variables)
async create(node, args = {}) {
const { factory, mutation, variables } = this.factories[node](args)
if (factory) {
this.lastResponse = await factory({ args, neodeInstance })
} else {
this.lastResponse = await this.graphQLClient.request(mutation, variables)
}
return this
},
async relate(node, relationship, properties) {
const { from, to } = properties
async relate(node, relationship, { from, to }) {
const mutation = `
mutation {
Add${node}${relationship}(
@ -112,6 +120,11 @@ export default function Factory(options = {}) {
this.lastResponse = await this.graphQLClient.request(mutation)
return this
},
async invite({ email }) {
const mutation = ` mutation($email: String!) { invite( email: $email) } `
this.lastResponse = await this.graphQLClient.request(mutation, { email })
return this
},
async cleanDatabase() {
this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver })
return this
@ -121,6 +134,9 @@ export default function Factory(options = {}) {
result.create.bind(result)
result.relate.bind(result)
result.mutate.bind(result)
result.shout.bind(result)
result.follow.bind(result)
result.invite.bind(result)
result.cleanDatabase.bind(result)
return result
}

View File

@ -1,51 +1,28 @@
import faker from 'faker'
import uuid from 'uuid/v4'
import encryptPassword from '../../helpers/encryptPassword'
import slugify from 'slug'
export default function create(params) {
const {
id = uuid(),
name = faker.name.findName(),
slug = '',
email = faker.internet.email(),
password = '1234',
role = 'user',
avatar = faker.internet.avatar(),
about = faker.lorem.paragraph(),
} = params
return {
mutation: `
mutation(
$id: ID!
$name: String
$slug: String
$password: String!
$email: String!
$avatar: String
$about: String
$role: UserGroup
) {
CreateUser(
id: $id
name: $name
slug: $slug
password: $password
email: $email
avatar: $avatar
about: $about
role: $role
) {
id
name
slug
email
avatar
role
deleted
disabled
}
factory: async ({ args, neodeInstance }) => {
const defaults = {
id: uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
password: '1234',
role: 'user',
avatar: faker.internet.avatar(),
about: faker.lorem.paragraph(),
}
`,
variables: { id, name, slug, password, email, avatar, about, role },
defaults.slug = slugify(defaults.name, { lower: true })
args = {
...defaults,
...args,
}
args = await encryptPassword(args)
const user = await neodeInstance.create('User', args)
return user.toJson()
},
}
}

View File

@ -349,6 +349,7 @@ import Factory from './factories'
])
/* eslint-disable-next-line no-console */
console.log('Seeded Data...')
process.exit(0)
} catch (err) {
/* eslint-disable-next-line no-console */
console.error(err)

View File

@ -704,6 +704,13 @@
dependencies:
regenerator-runtime "^0.13.2"
"@babel/runtime@^7.4.4":
version "7.4.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.5.tgz#582bb531f5f9dc67d2fcb682979894f75e253f12"
integrity sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==
dependencies:
regenerator-runtime "^0.13.2"
"@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237"
@ -745,6 +752,43 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@hapi/address@2.x.x":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.0.0.tgz#9f05469c88cb2fd3dcd624776b54ee95c312126a"
integrity sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==
"@hapi/hoek@6.x.x":
version "6.2.4"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-6.2.4.tgz#4b95fbaccbfba90185690890bdf1a2fbbda10595"
integrity sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==
"@hapi/hoek@8.x.x":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.0.1.tgz#9712fa2ad124ac64668ab06ba847b1eaf83a03fd"
integrity sha512-cctMYH5RLbElaUpZn3IJaUj9QNQD8iXDnl7xNY6KB1aFD2ciJrwpo3kvZowIT75uA+silJFDnSR2kGakALUymg==
"@hapi/joi@^15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-15.1.0.tgz#940cb749b5c55c26ab3b34ce362e82b6162c8e7a"
integrity sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==
dependencies:
"@hapi/address" "2.x.x"
"@hapi/hoek" "6.x.x"
"@hapi/marker" "1.x.x"
"@hapi/topo" "3.x.x"
"@hapi/marker@1.x.x":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@hapi/marker/-/marker-1.0.0.tgz#65b0b2b01d1be06304886ce9b4b77b1bfb21a769"
integrity sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==
"@hapi/topo@3.x.x":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.2.tgz#57cc1317be1a8c5f47c124f9b0e3c49cd78424d2"
integrity sha512-r+aumOqJ5QbD6aLPJWqVjMAPsx5pZKz+F5yPqXZ/WWG9JTtHbQqlzrJoknJ0iJxLj9vlXtmpSdjlkszseeG8OA==
dependencies:
"@hapi/hoek" "8.x.x"
"@jest/console@^24.7.1":
version "24.7.1"
resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.7.1.tgz#32a9e42535a97aedfe037e725bd67e954b459545"
@ -1342,6 +1386,18 @@ apollo-engine-reporting-protobuf@0.3.1:
dependencies:
protobufjs "^6.8.6"
apollo-engine-reporting@1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.4.tgz#65e12f94221d80b3b1740c26e82ce9bb6bdfb7ee"
integrity sha512-DJdYghyUBzT0/LcPLwuQNXDCw06r1RfxkVfNTGKoTv6a+leVvjhDJmXvc+jSuBPwaNsc+RYRnfyQ2qUn9fmfyA==
dependencies:
apollo-engine-reporting-protobuf "0.3.1"
apollo-graphql "^0.3.2"
apollo-server-core "2.6.6"
apollo-server-env "2.4.0"
async-retry "^1.2.1"
graphql-extensions "0.7.5"
apollo-engine-reporting@1.3.5:
version "1.3.5"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.5.tgz#075424d39dfe77a20f96e8e33b7ae52d58c38e1e"
@ -1371,6 +1427,14 @@ apollo-errors@^1.9.0:
assert "^1.4.1"
extendable-error "^0.1.5"
apollo-graphql@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.3.2.tgz#8881a87f1d5fcf80837b34dba90737e664eabe9a"
integrity sha512-YbzYGR14GV0023m//EU66vOzZ3i7c04V/SF8Qk+60vf1sOWyKgO6mxZJ4BKhw10qWUayirhSDxq3frYE+qSG0A==
dependencies:
apollo-env "0.5.1"
lodash.sortby "^4.7.0"
apollo-graphql@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.3.3.tgz#ce1df194f6e547ad3ce1e35b42f9c211766e1658"
@ -1422,6 +1486,32 @@ apollo-server-caching@0.4.0:
dependencies:
lru-cache "^5.0.0"
apollo-server-core@2.6.6:
version "2.6.6"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.6.tgz#55fea7980a943948c49dea20d81b9bbfc0e04f87"
integrity sha512-PFSjJbqkV1eetfFJxu11gzklQYC8BrF0RZfvC1d1mhvtxAOKl25uhPHxltN0Omyjp7LW4YeoC6zwl9rLWuhZFQ==
dependencies:
"@apollographql/apollo-tools" "^0.3.6"
"@apollographql/graphql-playground-html" "1.6.20"
"@types/ws" "^6.0.0"
apollo-cache-control "0.7.4"
apollo-datasource "0.5.0"
apollo-engine-reporting "1.3.4"
apollo-server-caching "0.4.0"
apollo-server-env "2.4.0"
apollo-server-errors "2.3.0"
apollo-server-plugin-base "0.5.5"
apollo-tracing "0.7.3"
fast-json-stable-stringify "^2.0.0"
graphql-extensions "0.7.5"
graphql-subscriptions "^1.0.0"
graphql-tag "^2.9.2"
graphql-tools "^4.0.0"
graphql-upload "^8.0.2"
sha.js "^2.4.11"
subscriptions-transport-ws "^0.9.11"
ws "^6.0.0"
apollo-server-core@2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.7.tgz#85b0310f40cfec43a702569c73af16d88776a6f0"
@ -1470,10 +1560,10 @@ apollo-server-errors@2.3.0:
resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.0.tgz#700622b66a16dffcad3b017e4796749814edc061"
integrity sha512-rUvzwMo2ZQgzzPh2kcJyfbRSfVKRMhfIlhY7BzUfM4x6ZT0aijlgsf714Ll3Mbf5Fxii32kD0A/DmKsTecpccw==
apollo-server-express@2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.7.tgz#22307e08b75be1553f4099d00028abe52597767d"
integrity sha512-qbCQM+8LxXpwPNN5Sdvcb+Sne8zuCORFt25HJtPJRkHlyBUzOd7JA7SEnUn5e2geTiiGoVIU5leh+++C51udTw==
apollo-server-express@2.6.6:
version "2.6.6"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.6.tgz#ec2b955354d7dd4d12fe01ea7e983d302071d5b9"
integrity sha512-bY/xrr9lZH+hsjchiQuSXpW3ivXfL1h81M5VE9Ppus1PVwwEIar/irBN+PFp97WxERZPDjVZzrRKa+lRHjtJsA==
dependencies:
"@apollographql/graphql-playground-html" "1.6.20"
"@types/accepts" "^1.3.5"
@ -1481,7 +1571,7 @@ apollo-server-express@2.6.7:
"@types/cors" "^2.8.4"
"@types/express" "4.17.0"
accepts "^1.3.5"
apollo-server-core "2.6.7"
apollo-server-core "2.6.6"
body-parser "^1.18.3"
cors "^2.8.4"
graphql-subscriptions "^1.0.0"
@ -1509,6 +1599,11 @@ apollo-server-module-graphiql@^1.3.4, apollo-server-module-graphiql@^1.4.0:
resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec"
integrity sha512-GmkOcb5he2x5gat+TuiTvabnBf1m4jzdecal3XbXBh/Jg+kx4hcvO3TTDFQ9CuTprtzdcVyA11iqG7iOMOt7vA==
apollo-server-plugin-base@0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.5.tgz#364e4a2fca4d95ddeb9fd3e78940ed1da58865c2"
integrity sha512-agiuhknyu3lnnEsqUh99tzxwPCGp+TuDK+TSRTkXU1RUG6lY4C3uJp0JGJw03cP+M6ze73TbRjMA4E68g/ks5A==
apollo-server-plugin-base@0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.6.tgz#3a7128437a0f845e7d873fa43ef091ff7bf27975"
@ -1521,13 +1616,13 @@ apollo-server-testing@~2.6.7:
dependencies:
apollo-server-core "2.6.7"
apollo-server@~2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.7.tgz#b707ede529b4d45f2f00a74f3b457658b0e62e83"
integrity sha512-4wk9JykURLed6CnNIj9jhU6ueeTVmGBTyAnnvnlhRrOf50JAFszUErZIKg6lw5vVr5riaByrGFIkMBTySCHgPQ==
apollo-server@~2.6.6:
version "2.6.6"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.6.tgz#0570fce4a682eb1de8bc1b86dbe2543de440cd4e"
integrity sha512-7Bulb3RnOO4/SGA66LXu3ZHCXIK8MYMrsxy4yti1/adDIUmcniolDqJwOYUGoTmv1AQjRxgJb4TVZ0Dk9nrrYg==
dependencies:
apollo-server-core "2.6.7"
apollo-server-express "2.6.7"
apollo-server-core "2.6.6"
apollo-server-express "2.6.6"
express "^4.0.0"
graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0"
@ -2584,10 +2679,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
date-fns@2.0.0-beta.2:
version "2.0.0-beta.2"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-beta.2.tgz#ccd556df832ef761baa88c600f53d2e829245999"
integrity sha512-4cicZF707RNerr3/Q3CcdLo+3OHMCfrRXE7h5iFgn7AMvX07sqKLxSf8Yp+WJW5bvKr2cy9/PkctXLv4iFtOaA==
date-fns@2.0.0-beta.1:
version "2.0.0-beta.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-beta.1.tgz#6f3209ea8be559211be5160e0a6379a7eade227b"
integrity sha512-ls5W/PUZmrtck53HD3Sd0564NlnNoQtcxNCwWcIzULJMNNgAPVKHoylVXPau7vdyu5/JTd25ljtan+iWnnUKkw==
debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
@ -2816,6 +2911,11 @@ dotenv@^0.4.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-0.4.0.tgz#f6fb351363c2d92207245c737802c9ab5ae1495a"
integrity sha1-9vs1E2PC2SIHJFxzeALJq1rhSVo=
dotenv@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0=
dotenv@~8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440"
@ -3727,6 +3827,13 @@ graphql-extensions@0.7.4:
dependencies:
"@apollographql/apollo-tools" "^0.3.6"
graphql-extensions@0.7.5:
version "0.7.5"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.5.tgz#fab2b9e53cf6014952e6547456d50680ff0ea579"
integrity sha512-B1m+/WEJa3IYKWqBPS9W/7OasfPmlHOSz5hpEAq2Jbn6T0FQ/d2YWFf2HBETHR3RR2qfT+55VMiYovl2ga3qcg==
dependencies:
"@apollographql/apollo-tools" "^0.3.6"
graphql-extensions@0.7.6:
version "0.7.6"
resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.6.tgz#80cdddf08b0af12525529d1922ee2ea0d0cc8ecf"
@ -4916,7 +5023,7 @@ jmespath@0.15.0:
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=
joi@^13.0.0:
joi@^13.0.0, joi@^13.7.0:
version "13.7.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-13.7.0.tgz#cfd85ebfe67e8a1900432400b4d03bbd93fb879f"
integrity sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==
@ -5604,6 +5711,15 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
neo4j-driver@^1.6.3:
version "1.7.5"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4"
integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw==
dependencies:
"@babel/runtime" "^7.4.4"
text-encoding-utf-8 "^1.0.2"
uri-js "^4.2.2"
neo4j-driver@^1.7.3, neo4j-driver@~1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.4.tgz#9661cf643b63818bff85e82c4691918e75098c1e"
@ -5623,6 +5739,16 @@ neo4j-graphql-js@^2.6.3:
lodash "^4.17.11"
neo4j-driver "^1.7.3"
neode@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/neode/-/neode-0.2.16.tgz#20532cc67604fd00cc88de841d422f5238ae5bd3"
integrity sha512-L9p55IDKGzAZsQgHdXrfd2xasDuB46RipcrPw6NP7ESgkmfJMaMWRZ1F3Kv+f4V4U1WnhZ1IILvwVFhYPnpXEg==
dependencies:
dotenv "^4.0.0"
joi "^13.7.0"
neo4j-driver "^1.6.3"
uuid "^3.3.2"
next-tick@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -7411,6 +7537,11 @@ test-exclude@^5.0.0:
read-pkg-up "^4.0.0"
require-main-filename "^1.0.1"
text-encoding-utf-8@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13"
integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==
text-encoding@^0.6.4:
version "0.6.4"
resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"

View File

@ -1,5 +1,6 @@
import { Given, When, Then } from "cypress-cucumber-preprocessor/steps";
import { getLangByName } from "../../support/helpers";
import slugify from 'slug'
/* global cy */
@ -11,6 +12,7 @@ let loginCredentials = {
};
const narratorParams = {
name: "Peter Pan",
slug: 'peter-pan',
avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg",
...loginCredentials
};
@ -171,10 +173,11 @@ When("I press {string}", label => {
});
Given("we have the following posts in our database:", table => {
table.hashes().forEach(({ Author, ...postAttributes }) => {
table.hashes().forEach(({ Author, ...postAttributes }, i) => {
Author = Author || `author-${i}`
const userAttributes = {
name: Author,
email: `${Author}@example.org`,
email: `${slugify(Author, {lower: true})}@example.org`,
password: "1234"
};
postAttributes.deleted = Boolean(postAttributes.deleted);

View File

@ -6,9 +6,9 @@ Feature: Search
Background:
Given I have a user account
And we have the following posts in our database:
| Author | id | title | content |
| Brianna Wiest | p1 | 101 Essays that will change the way you think | 101 Essays, of course! |
| Brianna Wiest | p2 | No searched for content | will be found in this post, I guarantee |
| id | title | content |
| p1 | 101 Essays that will change the way you think | 101 Essays, of course! |
| p2 | No searched for content | will be found in this post, I guarantee |
Given I am logged in
Scenario: Search for specific words

View File

@ -1,12 +1,15 @@
import Factory from '../../backend/src/seed/factories'
import { getDriver } from '../../backend/src/bootstrap/neo4j'
import setupNeode from '../../backend/src/bootstrap/neode'
import neode from 'neode'
const neo4jDriver = getDriver({
const neo4jConfigs = {
uri: Cypress.env('NEO4J_URI'),
username: Cypress.env('NEO4J_USERNAME'),
password: Cypress.env('NEO4J_PASSWORD')
})
const factory = Factory({ neo4jDriver })
}
const neo4jDriver = getDriver(neo4jConfigs)
const factory = Factory({ seedServerHost, neo4jDriver, neodeInstance: setupNeode(neo4jConfigs)})
const seedServerHost = Cypress.env('SEED_SERVER_HOST')
beforeEach(async () => {
@ -14,7 +17,7 @@ beforeEach(async () => {
})
Cypress.Commands.add('factory', () => {
return Factory({ seedServerHost })
return Factory({ seedServerHost, neo4jDriver, neodeInstance: setupNeode(neo4jConfigs) })
})
Cypress.Commands.add(

View File

@ -19,6 +19,7 @@
"test:jest": "cd webapp && yarn test && cd ../backend && yarn test:jest && codecov"
},
"devDependencies": {
"bcryptjs": "^2.4.3",
"codecov": "^3.5.0",
"cross-env": "^5.2.0",
"cypress": "^3.3.2",
@ -29,6 +30,8 @@
"faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.5",
"npm-run-all": "^4.1.5"
"neode": "^0.2.16",
"npm-run-all": "^4.1.5",
"slug": "^1.1.0"
}
}

View File

@ -73,7 +73,7 @@
"name": "Your data",
"labelName": "Your Name",
"namePlaceholder": "Femanon Funny",
"labelCity": "Su ciudad o región",
"labelCity": "Your City or Region",
"labelBio": "About You",
"success": "Your data was successfully updated!"
},

View File

@ -43,7 +43,7 @@
"name": "Sus datos",
"labelName": "Su nombre",
"namePlaceholder": "Femanon Funny",
"labelCity": "Your City or Region",
"labelCity": "Su ciudad o región",
"labelBio": "Acerca de usted",
"success": "Sus datos han sido actualizados con éxito!"
},

View File

@ -2,7 +2,6 @@ import Vue from 'vue'
import { enUS, de, nl, fr, es } from 'date-fns/locale'
import format from 'date-fns/format'
import addSeconds from 'date-fns/addSeconds'
import accounting from 'accounting'
export default ({ app = {} }) => {
@ -39,15 +38,6 @@ export default ({ app = {} }) => {
}
return accounting.formatNumber(value || 0, precision, thousands, decimals)
},
// format seconds or milliseconds to durations HH:mm:ss
duration: (value, unit = 's') => {
if (unit === 'ms') {
value = value / 1000
}
return value
? format(addSeconds(new Date('2000-01-01 00:00'), value), 'HH:mm:ss')
: '00:00:00'
},
truncate: (value = '', length = -1) => {
if (!value || typeof value !== 'string' || value.length <= 0) {
return ''

View File

@ -1040,6 +1040,11 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
bcryptjs@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=
becke-ch--regex--s0-0-v1--base--pl--lib@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/becke-ch--regex--s0-0-v1--base--pl--lib/-/becke-ch--regex--s0-0-v1--base--pl--lib-1.4.0.tgz#429ceebbfa5f7e936e78d73fbdc7da7162b20e20"
@ -2038,6 +2043,11 @@ domain-browser@^1.2.0:
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
dotenv@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0=
dotenv@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440"
@ -2658,6 +2668,16 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoek@5.x.x:
version "5.0.4"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-5.0.4.tgz#0f7fa270a1cafeb364a4b2ddfaa33f864e4157da"
integrity sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w==
hoek@6.x.x:
version "6.1.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-6.1.3.tgz#73b7d33952e01fe27a38b0457294b79dd8da242c"
integrity sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==
hosted-git-info@^2.1.4:
version "2.7.1"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
@ -3033,6 +3053,13 @@ isarray@^2.0.4:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.4.tgz#38e7bcbb0f3ba1b7933c86ba1894ddfc3781bbb7"
integrity sha512-GMxXOiUirWg1xTKRipM0Ek07rX+ubx4nNVElTJdNLYmNO/2YrDkgJGw9CljXn+r4EWiDQg/8lsRdHyg2PJuUaA==
isemail@3.x.x:
version "3.2.0"
resolved "https://registry.yarnpkg.com/isemail/-/isemail-3.2.0.tgz#59310a021931a9fb06bbb51e155ce0b3f236832c"
integrity sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==
dependencies:
punycode "2.x.x"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -3055,6 +3082,15 @@ isstream@~0.1.2:
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
joi@^13.7.0:
version "13.7.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-13.7.0.tgz#cfd85ebfe67e8a1900432400b4d03bbd93fb879f"
integrity sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==
dependencies:
hoek "5.x.x"
isemail "3.x.x"
topo "3.x.x"
js-levenshtein@^1.1.3:
version "1.1.6"
resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d"
@ -3524,7 +3560,7 @@ needle@^2.2.1:
iconv-lite "^0.4.4"
sax "^1.2.4"
neo4j-driver@^1.7.5:
neo4j-driver@^1.6.3, neo4j-driver@^1.7.5:
version "1.7.5"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4"
integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw==
@ -3533,6 +3569,16 @@ neo4j-driver@^1.7.5:
text-encoding-utf-8 "^1.0.2"
uri-js "^4.2.2"
neode@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/neode/-/neode-0.2.16.tgz#20532cc67604fd00cc88de841d422f5238ae5bd3"
integrity sha512-L9p55IDKGzAZsQgHdXrfd2xasDuB46RipcrPw6NP7ESgkmfJMaMWRZ1F3Kv+f4V4U1WnhZ1IILvwVFhYPnpXEg==
dependencies:
dotenv "^4.0.0"
joi "^13.7.0"
neo4j-driver "^1.6.3"
uuid "^3.3.2"
next-tick@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -3965,16 +4011,16 @@ punycode@1.3.2:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=
punycode@2.x.x, punycode@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
punycode@^1.3.2, punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
punycode@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@ -4373,6 +4419,13 @@ slice-ansi@0.0.4:
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=
slug@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/slug/-/slug-1.1.0.tgz#73eef5710416f515077bdf70c683bde4915913c9"
integrity sha512-NuIOjDQeTMPm+/AUIHJ5636mF3jOsYLFnoEErl9Tdpt4kpt4fOrAJxscH9mUgX1LtPaEqgPCawBg7A4yhoSWRg==
dependencies:
unicode ">= 0.3.1"
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@ -4785,6 +4838,13 @@ to-regex@^3.0.1, to-regex@^3.0.2:
regex-not "^1.0.2"
safe-regex "^1.1.0"
topo@3.x.x:
version "3.0.3"
resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.3.tgz#d5a67fb2e69307ebeeb08402ec2a2a6f5f7ad95c"
integrity sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==
dependencies:
hoek "6.x.x"
tough-cookie@~2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@ -4864,6 +4924,11 @@ unicode-property-aliases-ecmascript@^1.0.4:
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
"unicode@>= 0.3.1":
version "11.0.1"
resolved "https://registry.yarnpkg.com/unicode/-/unicode-11.0.1.tgz#735bd422ec75cf28d396eb224d535d168d5f1db6"
integrity sha512-+cHtykLb+eF1yrSLWTwcYBrqJkTfX7Quoyg7Juhe6uylF43ZbMdxMuSHNYlnyLT8T7POAvavgBthzUF9AIaQvQ==
union-value@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"