Merge branch 'master' of github.com:Human-Connection/Human-Connection into 2253-fix-scroll-layout-issue

This commit is contained in:
mattwr18 2019-12-05 11:16:16 +01:00
commit be319615f8
117 changed files with 5765 additions and 2367 deletions

View File

@ -8,13 +8,13 @@ addons:
- docker - docker
- chromium - chromium
before_install: install:
- yarn global add wait-on - yarn global add wait-on
# Install Codecov # Install Codecov
- yarn install - yarn install
- cp cypress.env.template.json cypress.env.json - cp cypress.env.template.json cypress.env.json
install: before_script:
- docker-compose -f docker-compose.yml build --parallel - docker-compose -f docker-compose.yml build --parallel
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml build # just tagging, just be quite fast - docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml build # just tagging, just be quite fast
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml up -d - docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml up -d
@ -30,10 +30,6 @@ script:
- docker-compose exec backend yarn run test --ci --verbose=false --coverage - docker-compose exec backend yarn run test --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:seed - docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:reset
# 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 # Frontend
- docker-compose exec webapp yarn run lint - docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage - docker-compose exec webapp yarn run test --ci --verbose=false --coverage
@ -42,6 +38,7 @@ script:
- docker-compose -f docker-compose.yml up -d - docker-compose -f docker-compose.yml up -d
- wait-on http://localhost:7474 - wait-on http://localhost:7474
- yarn run cypress:run --record - yarn run cypress:run --record
- yarn run cucumber
# Coverage # Coverage
- yarn run codecov - yarn run codecov

12
babel.config.json Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
]
]
}

View File

@ -10,8 +10,6 @@
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint src --config .eslintrc.js",
"test": "jest --forceExit --detectOpenHandles --runInBand", "test": "jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"db:reset": "babel-node src/seed/reset-db.js", "db:reset": "babel-node src/seed/reset-db.js",
"db:seed": "babel-node src/seed/seed-db.js" "db:seed": "babel-node src/seed/seed-db.js"
}, },
@ -34,13 +32,13 @@
] ]
}, },
"dependencies": { "dependencies": {
"@hapi/joi": "^16.1.7", "@hapi/joi": "^16.1.8",
"@sentry/node": "^5.9.0", "@sentry/node": "^5.9.0",
"apollo-cache-inmemory": "~1.6.3", "apollo-cache-inmemory": "~1.6.3",
"apollo-client": "~2.6.4", "apollo-client": "~2.6.4",
"apollo-link-context": "~1.0.19", "apollo-link-context": "~1.0.19",
"apollo-link-http": "~1.5.16", "apollo-link-http": "~1.5.16",
"apollo-server": "~2.9.11", "apollo-server": "~2.9.12",
"apollo-server-express": "^2.9.7", "apollo-server-express": "^2.9.7",
"babel-plugin-transform-runtime": "^6.23.0", "babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
@ -57,7 +55,7 @@
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~4.0.2", "graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1", "graphql-middleware-sentry": "^3.2.1",
"graphql-shield": "~7.0.2", "graphql-shield": "~7.0.4",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"helmet": "~3.21.2", "helmet": "~3.21.2",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
@ -99,10 +97,10 @@
"xregexp": "^4.2.4" "xregexp": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "~7.7.0", "@babel/cli": "~7.7.4",
"@babel/core": "~7.7.2", "@babel/core": "~7.7.4",
"@babel/node": "~7.7.4", "@babel/node": "~7.7.4",
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.7.4",
"@babel/preset-env": "~7.7.4", "@babel/preset-env": "~7.7.4",
"@babel/register": "~7.7.0", "@babel/register": "~7.7.0",
"apollo-server-testing": "~2.9.12", "apollo-server-testing": "~2.9.12",
@ -111,11 +109,11 @@
"babel-jest": "~24.9.0", "babel-jest": "~24.9.0",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~6.0.5", "cucumber": "~6.0.5",
"eslint": "~6.7.1", "eslint": "~6.7.2",
"eslint-config-prettier": "~6.7.0", "eslint-config-prettier": "~6.7.0",
"eslint-config-standard": "~14.1.0", "eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.18.2", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~23.0.4", "eslint-plugin-jest": "~23.1.1",
"eslint-plugin-node": "~10.0.0", "eslint-plugin-node": "~10.0.0",
"eslint-plugin-prettier": "~3.1.1", "eslint-plugin-prettier": "~3.1.1",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",

View File

@ -1,27 +1,29 @@
import user from './user' import user from './user'
import inbox from './inbox' import inbox from './inbox'
import webFinger from './webFinger'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import verify from './verify' import verify from './verify'
const router = express.Router() export default function() {
const router = express.Router()
router.use('/.well-known/webFinger', cors(), express.urlencoded({ extended: true }), webFinger) router.use(
router.use( '/activitypub/users',
'/activitypub/users', cors(),
cors(), express.json({
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), type: ['application/activity+json', 'application/ld+json', 'application/json'],
express.urlencoded({ extended: true }), }),
user, express.urlencoded({ extended: true }),
) user,
router.use( )
'/activitypub/inbox', router.use(
cors(), '/activitypub/inbox',
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), cors(),
express.urlencoded({ extended: true }), express.json({
verify, type: ['application/activity+json', 'application/ld+json', 'application/json'],
inbox, }),
) express.urlencoded({ extended: true }),
verify,
export default router inbox,
)
return router
}

View File

@ -1,43 +0,0 @@
import express from 'express'
import { createWebFinger } from '../utils/actor'
import gql from 'graphql-tag'
const router = express.Router()
router.get('/', async function(req, res) {
const resource = req.query.resource
if (!resource || !resource.includes('acct:')) {
return res
.status(400)
.send(
'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.',
)
} else {
const nameAndDomain = resource.replace('acct:', '')
const name = nameAndDomain.split('@')[0]
let result
try {
result = await req.app.get('ap').dataSource.client.query({
query: gql`
query {
User(slug: "${name}") {
slug
}
}
`,
})
} catch (error) {
return res.status(500).json({ error })
}
if (result.data && result.data.User.length > 0) {
const webFinger = createWebFinger(name)
return res.contentType('application/jrd+json').json(webFinger)
} else {
return res.status(404).json({ error: `No record found for ${nameAndDomain}.` })
}
}
})
export default router

View File

@ -0,0 +1,59 @@
import express from 'express'
import CONFIG from '../../config/'
import cors from 'cors'
const debug = require('debug')('ea:webfinger')
const regex = /acct:([a-z0-9_-]*)@([a-z0-9_-]*)/
const createWebFinger = name => {
const { host } = new URL(CONFIG.CLIENT_URI)
return {
subject: `acct:${name}@${host}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `${CONFIG.CLIENT_URI}/activitypub/users/${name}`,
},
],
}
}
export async function handler(req, res) {
const { resource = '' } = req.query
// eslint-disable-next-line no-unused-vars
const [_, name, domain] = resource.match(regex) || []
if (!(name && domain))
return res.status(400).json({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
const session = req.app.get('driver').session()
try {
const [slug] = await session.readTransaction(async t => {
const result = await t.run('MATCH (u:User {slug: $slug}) RETURN u.slug AS slug', {
slug: name,
})
return result.records.map(record => record.get('slug'))
})
if (!slug)
return res.status(404).json({
error: `No record found for "${name}@${domain}".`,
})
const webFinger = createWebFinger(name)
return res.contentType('application/jrd+json').json(webFinger)
} catch (error) {
debug(error)
return res.status(500).json({
error: 'Something went terribly wrong. Please contact support@human-connection.org',
})
} finally {
session.close()
}
}
export default function() {
const router = express.Router()
router.use('/webfinger', cors(), express.urlencoded({ extended: true }), handler)
return router
}

View File

@ -0,0 +1,113 @@
import { handler } from './webfinger'
import Factory from '../../seed/factories'
import { getDriver } from '../../bootstrap/neo4j'
let resource, res, json, status, contentType
const factory = Factory()
const driver = getDriver()
const request = () => {
json = jest.fn()
status = jest.fn(() => ({ json }))
contentType = jest.fn(() => ({ json }))
res = { status, contentType }
const req = {
app: {
get: key => {
return {
driver,
}[key]
},
},
query: {
resource,
},
}
return handler(req, res)
}
afterEach(async () => {
await factory.cleanDatabase()
})
describe('webfinger', () => {
describe('no ressource', () => {
beforeEach(() => {
resource = undefined
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('?resource query param', () => {
describe('is missing acct:', () => {
beforeEach(() => {
resource = 'some-user@domain'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('has no domain', () => {
beforeEach(() => {
resource = 'acct:some-user@'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('with acct:', () => {
beforeEach(() => {
resource = 'acct:some-user@domain'
})
it('returns error as json', async () => {
await request()
expect(status).toHaveBeenCalledWith(404)
expect(json).toHaveBeenCalledWith({
error: 'No record found for "some-user@domain".',
})
})
describe('given a user for acct', () => {
beforeEach(async () => {
await factory.create('User', { slug: 'some-user' })
})
it('returns user object', async () => {
await request()
expect(contentType).toHaveBeenCalledWith('application/jrd+json')
expect(json).toHaveBeenCalledWith({
links: [
{
href: 'http://localhost:3000/activitypub/users/some-user',
rel: 'self',
type: 'application/activity+json',
},
],
subject: 'acct:some-user@localhost:3000',
})
})
})
})
})
})

View File

@ -22,17 +22,3 @@ export function createActor(name, pubkey) {
}, },
} }
} }
export function createWebFinger(name) {
const { host } = new URL(activityPub.endpoint)
return {
subject: `acct:${name}@${host}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `${activityPub.endpoint}/activitypub/users/${name}`,
},
],
}
}

View File

@ -1,6 +1,7 @@
import dotenv from 'dotenv' import dotenv from 'dotenv'
import path from 'path'
dotenv.config() dotenv.config({ path: path.resolve(__dirname, '../../.env') })
const { const {
MAPBOX_TOKEN, MAPBOX_TOKEN,

View File

@ -11,15 +11,21 @@ export default async (driver, authorizationHeader) => {
} catch (err) { } catch (err) {
return null return null
} }
const session = driver.session()
const query = ` const query = `
MATCH (user:User {id: $id, deleted: false, disabled: false }) MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime()) SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1 LIMIT 1
` `
const result = await session.run(query, { id }) const session = driver.session()
session.close() let result
try {
result = await session.run(query, { id })
} finally {
session.close()
}
const [currentUser] = await result.records.map(record => { const [currentUser] = await result.records.map(record => {
return record.get('user') return record.get('user')
}) })

View File

@ -3,7 +3,6 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return if (!hashtags.length) return
const session = context.driver.session()
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
// and no new Hashtags and relations will be created. // and no new Hashtags and relations will be created.
@ -19,14 +18,18 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => {
MERGE (p)-[:TAGGED]->(t) MERGE (p)-[:TAGGED]->(t)
RETURN p, t RETURN p, t
` `
await session.run(cypherDeletePreviousRelations, { const session = context.driver.session()
postId, try {
}) await session.run(cypherDeletePreviousRelations, {
await session.run(cypherCreateNewTagsAndRelations, { postId,
postId, })
hashtags, await session.run(cypherCreateNewTagsAndRelations, {
}) postId,
session.close() hashtags,
})
} finally {
session.close()
}
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {

View File

@ -1,15 +1,19 @@
import extractMentionedUsers from './mentions/extractMentionedUsers' import extractMentionedUsers from './mentions/extractMentionedUsers'
const postAuthorOfComment = async (comment, { context }) => { const postAuthorOfComment = async (comment, { context }) => {
const session = context.driver.session()
const cypherFindUser = ` const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id } RETURN user { .id }
` `
const result = await session.run(cypherFindUser, { const session = context.driver.session()
commentId: comment.id, let result
}) try {
session.close() result = await session.run(cypherFindUser, {
commentId: comment.id,
})
} finally {
session.close()
}
const [postAuthor] = await result.records.map(record => { const [postAuthor] = await result.records.map(record => {
return record.get('user') return record.get('user')
}) })
@ -31,7 +35,6 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
throw new Error('Notification does not fit the reason!') throw new Error('Notification does not fit the reason!')
} }
const session = context.driver.session()
let cypher let cypher
switch (reason) { switch (reason) {
case 'mentioned_in_post': { case 'mentioned_in_post': {
@ -85,12 +88,16 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
break break
} }
} }
await session.run(cypher, { const session = context.driver.session()
id, try {
idsOfUsers, await session.run(cypher, {
reason, id,
}) idsOfUsers,
session.close() reason,
})
} finally {
session.close()
}
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
@ -123,15 +130,19 @@ const handleCreateComment = async (resolve, root, args, context, resolveInfo) =>
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo) const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
if (comment) { if (comment) {
const session = context.driver.session()
const cypherFindUser = ` const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id } RETURN user { .id }
` `
const result = await session.run(cypherFindUser, { const session = context.driver.session()
commentId: comment.id, let result
}) try {
session.close() result = await session.run(cypherFindUser, {
commentId: comment.id,
})
} finally {
session.close()
}
const [postAuthor] = await result.records.map(record => { const [postAuthor] = await result.records.map(record => {
return record.get('user') return record.get('user')
}) })

View File

@ -1,4 +1,4 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' import { rule, shield, deny, allow, or } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j' import { neode } from '../bootstrap/neo4j'
import CONFIG from '../config' import CONFIG from '../config'
@ -41,48 +41,27 @@ const isMySocialMedia = rule({
return socialMedia.ownedBy.node.id === user.id return socialMedia.ownedBy.node.id === user.id
}) })
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) >= 3 as limitReached
`,
{ id: user.id },
)
const [limitReached] = result.records.map(record => {
return record.get('limitReached')
})
return limitReached
} finally {
session.close()
}
})
const isAuthor = rule({ const isAuthor = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (_parent, args, { user, driver }) => { })(async (_parent, args, { user, driver }) => {
if (!user) return false if (!user) return false
const session = driver.session()
const { id: resourceId } = args const { id: resourceId } = args
const result = await session.run( const session = driver.session()
` try {
MATCH (resource {id: $resourceId})<-[:WROTE]-(author) const result = await session.run(
RETURN author `
`, MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
{ RETURN author
resourceId, `,
}, { resourceId, userId: user.id },
) )
session.close() const [author] = result.records.map(record => {
const [author] = result.records.map(record => { return record.get('author')
return record.get('author') })
}) return !!author
const authorId = author && author.properties && author.properties.id } finally {
return authorId === user.id session.close()
}
}) })
const isDeletingOwnAccount = rule({ const isDeletingOwnAccount = rule({
@ -129,12 +108,11 @@ export default shield(
SignupByInvitation: allow, SignupByInvitation: allow,
Signup: or(publicRegistration, isAdmin), Signup: or(publicRegistration, isAdmin),
SignupVerification: allow, SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
UpdateUser: onlyYourself, UpdateUser: onlyYourself,
CreatePost: isAuthenticated, CreatePost: isAuthenticated,
UpdatePost: isAuthor, UpdatePost: isAuthor,
DeletePost: isAuthor, DeletePost: isAuthor,
report: isAuthenticated, fileReport: isAuthenticated,
CreateSocialMedia: isAuthenticated, CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia, UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia, DeleteSocialMedia: isMySocialMedia,
@ -147,8 +125,7 @@ export default shield(
shout: isAuthenticated, shout: isAuthenticated,
unshout: isAuthenticated, unshout: isAuthenticated,
changePassword: isAuthenticated, changePassword: isAuthenticated,
enable: isModerator, review: isModerator,
disable: isModerator,
CreateComment: isAuthenticated, CreateComment: isAuthenticated,
UpdateComment: isAuthor, UpdateComment: isAuthor,
DeleteComment: isAuthor, DeleteComment: isAuthor,

View File

@ -3,11 +3,14 @@ import uniqueSlug from './slugify/uniqueSlug'
const isUniqueFor = (context, type) => { const isUniqueFor = (context, type) => {
return async slug => { return async slug => {
const session = context.driver.session() const session = context.driver.session()
const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, { try {
slug, const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, {
}) slug,
session.close() })
return response.records.length === 0 return response.records.length === 0
} finally {
session.close()
}
} }
} }

View File

@ -8,14 +8,8 @@ const factory = Factory()
const neode = getNeode() const neode = getNeode()
const driver = getDriver() const driver = getDriver()
let query
let mutate
let graphqlQuery
const categoryIds = ['cat9'] const categoryIds = ['cat9']
let authenticatedUser let query, graphqlQuery, authenticatedUser, user, moderator, troll
let user
let moderator
let troll
const action = () => { const action = () => {
return query({ query: graphqlQuery }) return query({ query: graphqlQuery })
@ -38,18 +32,17 @@ beforeAll(async () => {
avatar: '/some/offensive/avatar.jpg', avatar: '/some/offensive/avatar.jpg',
about: 'This self description is very offensive', about: 'This self description is very offensive',
}), }),
neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
]) ])
user = users[0] user = users[0]
moderator = users[1] moderator = users[1]
troll = users[2] troll = users[2]
await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
await Promise.all([ await Promise.all([
user.relateTo(troll, 'following'), user.relateTo(troll, 'following'),
factory.create('Post', { factory.create('Post', {
@ -70,33 +63,32 @@ beforeAll(async () => {
}), }),
]) ])
await Promise.all([ const resources = await Promise.all([
factory.create('Comment', { factory.create('Comment', {
author: user, author: user,
id: 'c2', id: 'c2',
postId: 'p3', postId: 'p3',
content: 'Enabled comment on public post', content: 'Enabled comment on public post',
}), }),
factory.create('Post', {
id: 'p2',
author: troll,
title: 'Disabled post',
content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false,
categoryIds,
}),
factory.create('Comment', {
id: 'c1',
author: troll,
postId: 'p3',
content: 'Disabled comment',
contentExcerpt: 'Disabled comment',
}),
]) ])
await factory.create('Post', {
id: 'p2',
author: troll,
title: 'Disabled post',
content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false,
categoryIds,
})
await factory.create('Comment', {
id: 'c1',
author: troll,
postId: 'p3',
content: 'Disabled comment',
contentExcerpt: 'Disabled comment',
})
const { server } = createServer({ const { server } = createServer({
context: () => { context: () => {
return { return {
@ -108,20 +100,57 @@ beforeAll(async () => {
}) })
const client = createTestClient(server) const client = createTestClient(server)
query = client.query query = client.query
mutate = client.mutate
authenticatedUser = await moderator.toJson() const trollingPost = resources[1]
const disableMutation = gql` const trollingComment = resources[2]
mutation($id: ID!) {
disable(id: $id) const reports = await Promise.all([
} factory.create('Report'),
` factory.create('Report'),
await Promise.all([ factory.create('Report'),
mutate({ mutation: disableMutation, variables: { id: 'c1' } }), ])
mutate({ mutation: disableMutation, variables: { id: 'u2' } }), const reportAgainstTroll = reports[0]
mutate({ mutation: disableMutation, variables: { id: 'p2' } }), const reportAgainstTrollingPost = reports[1]
const reportAgainstTrollingComment = reports[2]
const reportVariables = {
resourceId: 'undefined-resource',
reasonCategory: 'discrimination_etc',
reasonDescription: 'I am what I am !!!',
}
await Promise.all([
reportAgainstTroll.relateTo(user, 'filed', { ...reportVariables, resourceId: 'u2' }),
reportAgainstTroll.relateTo(troll, 'belongsTo'),
reportAgainstTrollingPost.relateTo(user, 'filed', { ...reportVariables, resourceId: 'p2' }),
reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'),
reportAgainstTrollingComment.relateTo(moderator, 'filed', {
...reportVariables,
resourceId: 'c1',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
const disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
await Promise.all([
reportAgainstTroll.relateTo(moderator, 'reviewed', { ...disableVariables, resourceId: 'u2' }),
troll.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingPost.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'p2',
}),
trollingPost.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingComment.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'c1',
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }),
]) ])
authenticatedUser = null
}) })
afterAll(async () => { afterAll(async () => {

View File

@ -5,7 +5,7 @@ const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE = const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three' 'You cannot save a post without at least one category or more than three'
const validateCommentCreation = async (resolve, root, args, context, info) => { const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -13,28 +13,30 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`) throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
} }
const session = context.driver.session() const session = context.driver.session()
const postQueryRes = await session.run( try {
` const postQueryRes = await session.run(
`
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
RETURN post`, RETURN post`,
{ {
postId, postId,
}, },
) )
session.close() const [post] = postQueryRes.records.map(record => {
const [post] = postQueryRes.records.map(record => { return record.get('post')
return record.get('post') })
})
if (!post) { if (!post) {
throw new UserInputError(NO_POST_ERR_MESSAGE) throw new UserInputError(NO_POST_ERR_MESSAGE)
} else { } else {
return resolve(root, args, context, info) return resolve(root, args, context, info)
}
} finally {
session.close()
} }
} }
const validateUpdateComment = async (resolve, root, args, context, info) => { const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) { if (!args.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`) throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
@ -59,36 +61,67 @@ const validateUpdatePost = async (resolve, root, args, context, info) => {
const validateReport = async (resolve, root, args, context, info) => { const validateReport = async (resolve, root, args, context, info) => {
const { resourceId } = args const { resourceId } = args
const { user, driver } = context const { user } = context
if (resourceId === user.id) throw new Error('You cannot report yourself!') if (resourceId === user.id) throw new Error('You cannot report yourself!')
const session = driver.session() return resolve(root, args, context, info)
const reportQueryRes = await session.run( }
`
MATCH (:User {id:$submitterId})-[:REPORTED]->(resource {id:$resourceId}) const validateReview = async (resolve, root, args, context, info) => {
RETURN labels(resource)[0] as label const { resourceId } = args
`, let existingReportedResource
{ const { user, driver } = context
resourceId, if (resourceId === user.id) throw new Error('You cannot review yourself!')
submitterId: user.id, const session = driver.session()
}, const reportReadTxPromise = session.writeTransaction(async txc => {
) const validateReviewTransactionResponse = await txc.run(
session.close() `
const [existingReportedResource] = reportQueryRes.records.map(record => { MATCH (resource {id: $resourceId})
return { WHERE resource:User OR resource:Post OR resource:Comment
label: record.get('label'), OPTIONAL MATCH (:User)-[filed:FILED]->(:Report {closed: false})-[:BELONGS_TO]->(resource)
} OPTIONAL MATCH (resource)<-[:WROTE]-(author:User)
}) RETURN labels(resource)[0] AS label, author, filed
`,
{
resourceId,
submitterId: user.id,
},
)
return validateReviewTransactionResponse.records.map(record => ({
label: record.get('label'),
author: record.get('author'),
filed: record.get('filed'),
}))
})
try {
const txResult = await reportReadTxPromise
existingReportedResource = txResult
if (!existingReportedResource || !existingReportedResource.length)
throw new Error(`Resource not found or is not a Post|Comment|User!`)
existingReportedResource = existingReportedResource[0]
if (!existingReportedResource.filed)
throw new Error(
`Before starting the review process, please report the ${existingReportedResource.label}!`,
)
const authorId =
existingReportedResource.label !== 'User' && existingReportedResource.author
? existingReportedResource.author.properties.id
: null
if (authorId && authorId === user.id)
throw new Error(`You cannot review your own ${existingReportedResource.label}!`)
} finally {
session.close()
}
if (existingReportedResource) throw new Error(`${existingReportedResource.label}`)
return resolve(root, args, context, info) return resolve(root, args, context, info)
} }
export default { export default {
Mutation: { Mutation: {
CreateComment: validateCommentCreation, CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment, UpdateComment: validateUpdateComment,
CreatePost: validatePost, CreatePost: validatePost,
UpdatePost: validateUpdatePost, UpdatePost: validateUpdatePost,
report: validateReport, fileReport: validateReport,
review: validateReview,
}, },
} }

View File

@ -0,0 +1,400 @@
import { gql } from '../../helpers/jest'
import Factory from '../../seed/factories'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
const factory = Factory()
const neode = getNeode()
const driver = getDriver()
let authenticatedUser,
mutate,
users,
offensivePost,
reportVariables,
disableVariables,
reportingUser,
moderatingUser,
commentingUser
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
const updateCommentMutation = gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
}
}
`
const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) {
CreatePost(
id: $id
title: $title
content: $content
language: $language
categoryIds: $categoryIds
) {
id
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
}
}
`
const reviewMutation = gql`
mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
createdAt
updatedAt
}
}
`
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
user: authenticatedUser,
neode,
driver,
}
},
})
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
users = await Promise.all([
factory.create('User', {
id: 'reporting-user',
}),
factory.create('User', {
id: 'moderating-user',
role: 'moderator',
}),
factory.create('User', {
id: 'commenting-user',
}),
])
reportVariables = {
resourceId: 'whatever',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
reportingUser = users[0]
moderatingUser = users[1]
commentingUser = users[2]
const posts = await Promise.all([
factory.create('Post', {
id: 'offensive-post',
authorId: 'moderating-user',
}),
factory.create('Post', {
id: 'post-4-commenting',
authorId: 'commenting-user',
}),
])
offensivePost = posts[0]
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('validateCreateComment', () => {
let createCommentVariables
beforeEach(async () => {
createCommentVariables = {
postId: 'whatever',
content: '',
}
authenticatedUser = await commentingUser.toJson()
})
it('throws an error if content is empty', async () => {
createCommentVariables = { ...createCommentVariables, postId: 'post-4-commenting' }
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('sanitizes content and throws an error if not longer than 1 character', async () => {
createCommentVariables = { postId: 'post-4-commenting', content: '<a></a>' }
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('throws an error if there is no post with given id in the database', async () => {
createCommentVariables = {
...createCommentVariables,
postId: 'non-existent-post',
content: 'valid content',
}
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment cannot be created without a post!' }],
})
})
describe('validateUpdateComment', () => {
let updateCommentVariables
beforeEach(async () => {
await factory.create('Comment', {
id: 'comment-id',
authorId: 'commenting-user',
})
updateCommentVariables = {
id: 'whatever',
content: '',
}
authenticatedUser = await commentingUser.toJson()
})
it('throws an error if content is empty', async () => {
updateCommentVariables = { ...updateCommentVariables, id: 'comment-id' }
await expect(
mutate({ mutation: updateCommentMutation, variables: updateCommentVariables }),
).resolves.toMatchObject({
data: { UpdateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('sanitizes content and throws an error if not longer than 1 character', async () => {
updateCommentVariables = { id: 'comment-id', content: '<a></a>' }
await expect(
mutate({ mutation: updateCommentMutation, variables: updateCommentVariables }),
).resolves.toMatchObject({
data: { UpdateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
})
describe('validatePost', () => {
let createPostVariables
beforeEach(async () => {
createPostVariables = {
title: 'I am a title',
content: 'Some content',
}
authenticatedUser = await commentingUser.toJson()
})
describe('categories', () => {
describe('null', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: null }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('empty', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: [] }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('more than 3 categoryIds', () => {
it('throws UserInputError', async () => {
createPostVariables = {
...createPostVariables,
categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'],
}
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
})
})
describe('validateUpdatePost', () => {
describe('post created without categories somehow', () => {
let owner, updatePostVariables
beforeEach(async () => {
const postSomehowCreated = await neode.create('Post', {
id: 'how-was-this-created',
})
owner = await neode.create('User', {
id: 'author-of-post-without-category',
slug: 'hacker',
})
await postSomehowCreated.relateTo(owner, 'author')
authenticatedUser = await owner.toJson()
updatePostVariables = {
id: 'how-was-this-created',
title: 'I am a title',
content: 'Some content',
categoryIds: [],
}
})
it('requires at least one category for successful update', async () => {
await expect(
mutate({ mutation: updatePostMutation, variables: updatePostVariables }),
).resolves.toMatchObject({
data: { UpdatePost: null },
errors: [
{ message: 'You cannot save a post without at least one category or more than three' },
],
})
})
})
})
})
describe('validateReport', () => {
it('throws an error if a user tries to report themself', async () => {
authenticatedUser = await reportingUser.toJson()
reportVariables = { ...reportVariables, resourceId: 'reporting-user' }
await expect(
mutate({ mutation: reportMutation, variables: reportVariables }),
).resolves.toMatchObject({
data: { fileReport: null },
errors: [{ message: 'You cannot report yourself!' }],
})
})
})
describe('validateReview', () => {
beforeEach(async () => {
const reportAgainstModerator = await factory.create('Report')
await Promise.all([
reportAgainstModerator.relateTo(reportingUser, 'filed', {
...reportVariables,
resourceId: 'moderating-user',
}),
reportAgainstModerator.relateTo(moderatingUser, 'belongsTo'),
])
authenticatedUser = await moderatingUser.toJson()
})
it('throws an error if a user tries to review a report against them', async () => {
disableVariables = { ...disableVariables, resourceId: 'moderating-user' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'You cannot review yourself!' }],
})
})
it('throws an error for invaild resource', async () => {
disableVariables = { ...disableVariables, resourceId: 'non-existent-resource' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }],
})
})
it('throws an error if no report exists', async () => {
disableVariables = { ...disableVariables, resourceId: 'offensive-post' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Before starting the review process, please report the Post!' }],
})
})
it('throws an error if a moderator tries to review their own resource(Post|Comment)', async () => {
const reportAgainstOffensivePost = await factory.create('Report')
await Promise.all([
reportAgainstOffensivePost.relateTo(reportingUser, 'filed', {
...reportVariables,
resourceId: 'offensive-post',
}),
reportAgainstOffensivePost.relateTo(offensivePost, 'belongsTo'),
])
disableVariables = { ...disableVariables, resourceId: 'offensive-post' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'You cannot review your own Post!' }],
})
})
describe('moderate a resource that is not a (Comment|Post|User) ', () => {
beforeEach(async () => {
await Promise.all([factory.create('Tag', { id: 'tag-id' })])
})
it('returns null', async () => {
disableVariables = {
...disableVariables,
resourceId: 'tag-id',
}
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }],
})
})
})
})

View File

@ -25,12 +25,6 @@ module.exports = {
target: 'User', target: 'User',
direction: 'in', direction: 'in',
}, },
disabledBy: {
type: 'relationship',
relationship: 'DISABLED',
target: 'User',
direction: 'in',
},
notified: { notified: {
type: 'relationship', type: 'relationship',
relationship: 'NOTIFIED', relationship: 'NOTIFIED',

View File

@ -17,12 +17,6 @@ module.exports = {
image: { type: 'string', allow: [null] }, image: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false }, deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false },
disabledBy: {
type: 'relationship',
relationship: 'DISABLED',
target: 'User',
direction: 'in',
},
notified: { notified: {
type: 'relationship', type: 'relationship',
relationship: 'NOTIFIED', relationship: 'NOTIFIED',

View File

@ -0,0 +1,53 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
rule: { type: 'string', default: 'latestReviewUpdatedAtRules' },
disable: { type: 'boolean', default: false },
closed: { type: 'boolean', default: false },
belongsTo: {
type: 'relationship',
relationship: 'BELONGS_TO',
target: ['User', 'Comment', 'Post'],
direction: 'out',
},
filed: {
type: 'relationship',
relationship: 'FILED',
target: 'User',
direction: 'in',
properties: {
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
resourceId: { type: 'string', primary: true, default: uuid },
reasonCategory: {
type: 'string',
valid: [
'other',
'discrimination_etc',
'pornographic_content_links',
'glorific_trivia_of_cruel_inhuman_acts',
'doxing',
'intentional_intimidation_stalking_persecution',
'advert_products_services_commercial',
'criminal_behavior_violation_german_law',
],
invalid: [null],
},
reasonDescription: { type: 'string', allow: [null] },
},
},
reviewed: {
type: 'relationship',
relationship: 'REVIEWED',
target: 'User',
direction: 'in',
properties: {
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
disable: { type: 'boolean', default: false },
closed: { type: 'boolean', default: false },
},
},
}

View File

@ -42,12 +42,6 @@ module.exports = {
}, },
}, },
friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' }, friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' },
disabledBy: {
type: 'relationship',
relationship: 'DISABLED',
target: 'User',
direction: 'in',
},
rewarded: { rewarded: {
type: 'relationship', type: 'relationship',
relationship: 'REWARDED', relationship: 'REWARDED',

View File

@ -3,7 +3,6 @@
export default { export default {
Badge: require('./Badge.js'), Badge: require('./Badge.js'),
User: require('./User.js'), User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'), EmailAddress: require('./EmailAddress.js'),
UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js'), UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js'),
SocialMedia: require('./SocialMedia.js'), SocialMedia: require('./SocialMedia.js'),
@ -13,4 +12,5 @@ export default {
Tag: require('./Tag.js'), Tag: require('./Tag.js'),
Location: require('./Location.js'), Location: require('./Location.js'),
Donations: require('./Donations.js'), Donations: require('./Donations.js'),
Report: require('./Report.js'),
} }

View File

@ -10,7 +10,6 @@ export default makeAugmentedSchema({
exclude: [ exclude: [
'Badge', 'Badge',
'Embed', 'Embed',
'InvitationCode',
'EmailAddress', 'EmailAddress',
'Notfication', 'Notfication',
'Statistics', 'Statistics',
@ -18,7 +17,9 @@ export default makeAugmentedSchema({
'Location', 'Location',
'SocialMedia', 'SocialMedia',
'NOTIFIED', 'NOTIFIED',
'REPORTED', 'FILED',
'REVIEWED',
'Report',
'Donations', 'Donations',
], ],
}, },

View File

@ -13,7 +13,8 @@ export default {
params.id = params.id || uuid() params.id = params.id || uuid()
const session = context.driver.session() const session = context.driver.session()
const createCommentCypher = ` try {
const createCommentCypher = `
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
MATCH (author:User {id: $userId}) MATCH (author:User {id: $userId})
WITH post, author WITH post, author
@ -23,45 +24,53 @@ export default {
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN comment RETURN comment
` `
const transactionRes = await session.run(createCommentCypher, { const transactionRes = await session.run(createCommentCypher, {
userId: context.user.id, userId: context.user.id,
postId, postId,
params, params,
}) })
session.close()
const [comment] = transactionRes.records.map(record => record.get('comment').properties) const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return comment return comment
} finally {
session.close()
}
}, },
UpdateComment: async (_parent, params, context, _resolveInfo) => { UpdateComment: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
const updateCommentCypher = ` try {
const updateCommentCypher = `
MATCH (comment:Comment {id: $params.id}) MATCH (comment:Comment {id: $params.id})
SET comment += $params SET comment += $params
SET comment.updatedAt = toString(datetime()) SET comment.updatedAt = toString(datetime())
RETURN comment RETURN comment
` `
const transactionRes = await session.run(updateCommentCypher, { params }) const transactionRes = await session.run(updateCommentCypher, { params })
session.close() const [comment] = transactionRes.records.map(record => record.get('comment').properties)
const [comment] = transactionRes.records.map(record => record.get('comment').properties) return comment
return comment } finally {
session.close()
}
}, },
DeleteComment: async (_parent, args, context, _resolveInfo) => { DeleteComment: async (_parent, args, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
const transactionRes = await session.run( try {
` const transactionRes = await session.run(
`
MATCH (comment:Comment {id: $commentId}) MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE SET comment.deleted = TRUE
SET comment.content = 'UNAVAILABLE' SET comment.content = 'UNAVAILABLE'
SET comment.contentExcerpt = 'UNAVAILABLE' SET comment.contentExcerpt = 'UNAVAILABLE'
RETURN comment RETURN comment
`, `,
{ commentId: args.id }, { commentId: args.id },
) )
session.close() const [comment] = transactionRes.records.map(record => record.get('comment').properties)
const [comment] = transactionRes.records.map(record => record.get('comment').properties) return comment
return comment } finally {
session.close()
}
}, },
}, },
Comment: { Comment: {
@ -69,7 +78,6 @@ export default {
hasOne: { hasOne: {
author: '<-[:WROTE]-(related:User)', author: '<-[:WROTE]-(related:User)',
post: '-[:COMMENTS]->(related:Post)', post: '-[:COMMENTS]->(related:Post)',
disabledBy: '<-[:DISABLED]-(related:User)',
}, },
}), }),
}, },

View File

@ -111,42 +111,6 @@ describe('CreateComment', () => {
}, },
) )
}) })
describe('comment content is empty', () => {
beforeEach(() => {
variables = { ...variables, content: '<p></p>' }
})
it('throw UserInput error', async () => {
const { data, errors } = await mutate({ mutation: createCommentMutation, variables })
expect(data).toEqual({ CreateComment: null })
expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!')
})
})
describe('comment content contains only whitespaces', () => {
beforeEach(() => {
variables = { ...variables, content: ' <p> </p> ' }
})
it('throw UserInput error', async () => {
const { data, errors } = await mutate({ mutation: createCommentMutation, variables })
expect(data).toEqual({ CreateComment: null })
expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!')
})
})
describe('invalid post id', () => {
beforeEach(() => {
variables = { ...variables, postId: 'does-not-exist' }
})
it('throw UserInput error', async () => {
const { data, errors } = await mutate({ mutation: createCommentMutation, variables })
expect(data).toEqual({ CreateComment: null })
expect(errors[0]).toHaveProperty('message', 'Comment cannot be created without a post!')
})
})
}) })
}) })
}) })
@ -226,17 +190,6 @@ describe('UpdateComment', () => {
expect(newlyCreatedComment.updatedAt).not.toEqual(UpdateComment.updatedAt) expect(newlyCreatedComment.updatedAt).not.toEqual(UpdateComment.updatedAt)
}) })
describe('if `content` empty', () => {
beforeEach(() => {
variables = { ...variables, content: ' <p> </p>' }
})
it('throws InputError', async () => {
const { errors } = await mutate({ mutation: updateCommentMutation, variables })
expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!')
})
})
describe('if comment does not exist for given id', () => { describe('if comment does not exist for given id', () => {
beforeEach(() => { beforeEach(() => {
variables = { ...variables, id: 'does-not-exist' } variables = { ...variables, id: 'does-not-exist' }

View File

@ -2,8 +2,8 @@ export default {
Mutation: { Mutation: {
UpdateDonations: async (_parent, params, context, _resolveInfo) => { UpdateDonations: async (_parent, params, context, _resolveInfo) => {
const { driver } = context const { driver } = context
const session = driver.session()
let donations let donations
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => { const writeTxResultPromise = session.writeTransaction(async txc => {
const updateDonationsTransactionResponse = await txc.run( const updateDonationsTransactionResponse = await txc.run(
` `

View File

@ -3,7 +3,7 @@ import Resolver from './helpers/Resolver'
import existingEmailAddress from './helpers/existingEmailAddress' import existingEmailAddress from './helpers/existingEmailAddress'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Validator from 'neode/build/Services/Validator.js' import Validator from 'neode/build/Services/Validator.js'
import { normalizeEmail } from 'validator' import normalizeEmail from './helpers/normalizeEmail'
export default { export default {
Mutation: { Mutation: {

View File

@ -1,10 +1,9 @@
import { normalizeEmail } from 'validator' import normalizeEmail from './normalizeEmail'
export default async function createPasswordReset(options) { export default async function createPasswordReset(options) {
const { driver, nonce, email, issuedAt = new Date() } = options const { driver, nonce, email, issuedAt = new Date() } = options
const normalizedEmail = normalizeEmail(email) const normalizedEmail = normalizeEmail(email)
const session = driver.session() const session = driver.session()
let response = {}
try { try {
const cypher = ` const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
@ -23,9 +22,8 @@ export default async function createPasswordReset(options) {
const { name } = record.get('u').properties const { name } = record.get('u').properties
return { email, nonce, name } return { email, nonce, name }
}) })
response = records[0] || {} return records[0] || {}
} finally { } finally {
session.close() session.close()
} }
return response
} }

View File

@ -0,0 +1,11 @@
import { normalizeEmail } from 'validator'
export default email =>
normalizeEmail(email, {
// gmail_remove_dots: true, default
gmail_remove_subaddress: false,
// gmail_convert_googlemaildotcom: true, default
outlookdotcom_remove_subaddress: false,
yahoo_remove_subaddress: false,
icloud_remove_subaddress: false,
})

View File

@ -1,41 +1,51 @@
const transformReturnType = record => {
return {
...record.get('review').properties,
report: record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
export default { export default {
Mutation: { Mutation: {
disable: async (object, params, { user, driver }) => { review: async (_object, params, context, _resolveInfo) => {
const { id } = params const { user: moderator, driver } = context
const { id: userId } = user
const cypher = ` let createdRelationshipWithNestedAttributes = null // return value
MATCH (u:User {id: $userId})
MATCH (resource {id: $id})
WHERE resource:User OR resource:Comment OR resource:Post
SET resource.disabled = true
MERGE (resource)<-[:DISABLED]-(u)
RETURN resource {.id}
`
const session = driver.session() const session = driver.session()
const res = await session.run(cypher, { id, userId }) try {
session.close() const cypher = `
const [resource] = res.records.map(record => { MATCH (moderator:User {id: $moderatorId})
return record.get('resource') MATCH (resource {id: $params.resourceId})<-[:BELONGS_TO]-(report:Report {closed: false})
}) WHERE resource:User OR resource:Post OR resource:Comment
if (!resource) return null MERGE (report)<-[review:REVIEWED]-(moderator)
return resource.id ON CREATE SET review.createdAt = $dateTime, review.updatedAt = review.createdAt
}, ON MATCH SET review.updatedAt = $dateTime
enable: async (object, params, { user, driver }) => { SET review.disable = $params.disable
const { id } = params SET report.updatedAt = $dateTime, report.closed = $params.closed
const cypher = ` SET resource.disabled = review.disable
MATCH (resource {id: $id})<-[d:DISABLED]-()
SET resource.disabled = false RETURN review, report, resource, labels(resource)[0] AS type
DELETE d `
RETURN resource {.id} const reviewWriteTxResultPromise = session.writeTransaction(async txc => {
` const reviewTransactionResponse = await txc.run(cypher, {
const session = driver.session() params,
const res = await session.run(cypher, { id }) moderatorId: moderator.id,
session.close() dateTime: new Date().toISOString(),
const [resource] = res.records.map(record => { })
return record.get('resource') return reviewTransactionResponse.records.map(transformReturnType)
}) })
if (!resource) return null const txResult = await reviewWriteTxResultPromise
return resource.id if (!txResult[0]) return null
createdRelationshipWithNestedAttributes = txResult[0]
} finally {
session.close()
}
return createdRelationshipWithNestedAttributes
}, },
}, },
} }

View File

@ -8,45 +8,53 @@ const factory = Factory()
const neode = getNeode() const neode = getNeode()
const driver = getDriver() const driver = getDriver()
let query, mutate, authenticatedUser, variables, moderator, nonModerator let mutate,
authenticatedUser,
disableVariables,
enableVariables,
moderator,
nonModerator,
closeReportVariables
const disableMutation = gql` const reviewMutation = gql`
mutation($id: ID!) { mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
disable(id: $id) review(resourceId: $resourceId, disable: $disable, closed: $closed) {
} createdAt
` updatedAt
const enableMutation = gql` resource {
mutation($id: ID!) { __typename
enable(id: $id) ... on User {
} id
` disabled
}
const commentQuery = gql` ... on Post {
query($id: ID!) { id
Comment(id: $id) { disabled
id }
disabled ... on Comment {
disabledBy { id
id disabled
}
} }
} report {
}
`
const postQuery = gql`
query($id: ID) {
Post(id: $id) {
id
disabled
disabledBy {
id id
createdAt
updatedAt
closed
reviewed {
createdAt
moderator {
id
}
}
} }
} }
} }
` `
describe('moderate resources', () => { describe('moderate resources', () => {
beforeAll(() => { beforeAll(async () => {
await factory.cleanDatabase()
authenticatedUser = undefined authenticatedUser = undefined
const { server } = createServer({ const { server } = createServer({
context: () => { context: () => {
@ -58,11 +66,19 @@ describe('moderate resources', () => {
}, },
}) })
mutate = createTestClient(server).mutate mutate = createTestClient(server).mutate
query = createTestClient(server).query
}) })
beforeEach(async () => { beforeEach(async () => {
variables = {} disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
enableVariables = {
resourceId: 'undefined-resource',
disable: false,
closed: false,
}
authenticatedUser = null authenticatedUser = null
moderator = await factory.create('User', { moderator = await factory.create('User', {
id: 'moderator-id', id: 'moderator-id',
@ -71,155 +87,392 @@ describe('moderate resources', () => {
password: '1234', password: '1234',
role: 'moderator', role: 'moderator',
}) })
nonModerator = await factory.create('User', {
id: 'non-moderator',
name: 'Non Moderator',
email: 'non.moderator@example.org',
password: '1234',
})
}) })
afterEach(async () => { afterEach(async () => {
await factory.cleanDatabase() await factory.cleanDatabase()
}) })
describe('disable', () => { describe('review to close report, leaving resource enabled', () => {
beforeEach(() => {
variables = {
id: 'some-resource',
}
})
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }], errors: [{ message: 'Not Authorised!' }],
}) })
}) })
}) })
describe('authenticated', () => { describe('authenticated', () => {
describe('non moderator', () => { beforeEach(async () => {
beforeEach(async () => { authenticatedUser = await nonModerator.toJson()
nonModerator = await factory.create('User', { })
id: 'non-moderator',
name: 'Non Moderator', it('non-moderator receives an authorization error', async () => {
email: 'non.moderator@example.org', await expect(
password: '1234', mutate({ mutation: reviewMutation, variables: disableVariables }),
}) ).resolves.toMatchObject({
authenticatedUser = await nonModerator.toJson() errors: [{ message: 'Not Authorised!' }],
}) })
it('throws authorization error', async () => { })
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ })
errors: [{ message: 'Not Authorised!' }],
describe('moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
const questionablePost = await factory.create('Post', {
id: 'should-i-be-disabled',
})
const reportAgainstQuestionablePost = await factory.create('Report')
await Promise.all([
reportAgainstQuestionablePost.relateTo(nonModerator, 'filed', {
resourceId: 'should-i-be-disabled',
reasonCategory: 'doxing',
reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!",
}),
reportAgainstQuestionablePost.relateTo(questionablePost, 'belongsTo'),
])
closeReportVariables = {
resourceId: 'should-i-be-disabled',
disable: false,
closed: true,
}
})
it('report can be closed without disabling resource', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: closeReportVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'Post', id: 'should-i-be-disabled', disabled: false },
report: { id: expect.any(String), closed: true },
},
},
errors: undefined,
})
})
it('creates only one review for multiple reviews by the same moderator on same resource', async () => {
await Promise.all([
mutate({
mutation: reviewMutation,
variables: { ...disableVariables, resourceId: 'should-i-be-disabled' },
}),
mutate({
mutation: reviewMutation,
variables: { ...enableVariables, resourceId: 'should-i-be-disabled' },
}),
])
const cypher =
'MATCH (:Report)<-[review:REVIEWED]-(moderator:User {id: "moderator-id"}) RETURN review'
const reviews = await neode.cypher(cypher)
expect(reviews.records).toHaveLength(1)
})
it('updates the updatedAt attribute', async () => {
const [firstReview, secondReview] = await Promise.all([
mutate({
mutation: reviewMutation,
variables: { ...disableVariables, resourceId: 'should-i-be-disabled' },
}),
mutate({
mutation: reviewMutation,
variables: { ...enableVariables, resourceId: 'should-i-be-disabled' },
}),
])
expect(firstReview.data.review.updatedAt).toBeTruthy()
expect(Date.parse(firstReview.data.review.updatedAt)).toEqual(expect.any(Number))
expect(secondReview.data.review.updatedAt).toBeTruthy()
expect(Date.parse(secondReview.data.review.updatedAt)).toEqual(expect.any(Number))
expect(firstReview.data.review.updatedAt).not.toEqual(secondReview.data.review.updatedAt)
})
})
})
describe('review to disable', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await nonModerator.toJson()
})
it('non-moderator receives an authorization error', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
describe('moderate a comment', () => {
beforeEach(async () => {
const trollingComment = await factory.create('Comment', {
id: 'comment-id',
})
const reportAgainstTrollingComment = await factory.create('Report')
await Promise.all([
reportAgainstTrollingComment.relateTo(nonModerator, 'filed', {
resourceId: 'comment-id',
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
disableVariables = {
...disableVariables,
resourceId: 'comment-id',
}
})
it('returns disabled resource id', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: { resource: { __typename: 'Comment', id: 'comment-id' } } },
errors: undefined,
})
})
it('returns .reviewed', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'Comment', id: 'comment-id' },
report: {
id: expect.any(String),
reviewed: expect.arrayContaining([
{ createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
]),
},
},
},
errors: undefined,
})
})
it('updates .disabled on comment', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: {
review: { resource: { __typename: 'Comment', id: 'comment-id', disabled: true } },
},
errors: undefined,
})
})
it('can be closed with one review', async () => {
closeReportVariables = {
...disableVariables,
closed: true,
}
await expect(
mutate({ mutation: reviewMutation, variables: closeReportVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'Comment', id: 'comment-id' },
report: { id: expect.any(String), closed: true },
},
},
errors: undefined,
}) })
}) })
}) })
describe('moderator', () => { describe('moderate a post', () => {
beforeEach(async () => { beforeEach(async () => {
authenticatedUser = await moderator.toJson() const trollingPost = await factory.create('Post', {
id: 'post-id',
})
const reportAgainstTrollingPost = await factory.create('Report')
await Promise.all([
reportAgainstTrollingPost.relateTo(nonModerator, 'filed', {
resourceId: 'post-id',
reasonCategory: 'doxing',
reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!",
}),
reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'),
])
disableVariables = {
...disableVariables,
resourceId: 'post-id',
}
}) })
describe('moderate a resource that is not a (Comment|Post|User) ', () => { it('returns disabled resource id', async () => {
beforeEach(async () => { await expect(
variables = { mutate({ mutation: reviewMutation, variables: disableVariables }),
id: 'sample-tag-id', ).resolves.toMatchObject({
} data: {
await factory.create('Tag', { id: 'sample-tag-id' }) review: {
}) resource: { __typename: 'Post', id: 'post-id' },
},
it('returns null', async () => { },
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ errors: undefined,
data: { disable: null },
})
}) })
}) })
describe('moderate a comment', () => { it('returns .reviewed', async () => {
beforeEach(async () => { await expect(
variables = {} mutate({ mutation: reviewMutation, variables: disableVariables }),
await factory.create('Comment', { ).resolves.toMatchObject({
id: 'comment-id', data: {
}) review: {
}) resource: { __typename: 'Post', id: 'post-id' },
report: {
it('returns disabled resource id', async () => { id: expect.any(String),
variables = { id: 'comment-id' } reviewed: expect.arrayContaining([
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ { createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
data: { disable: 'comment-id' }, ]),
errors: undefined, },
}) },
}) },
errors: undefined,
it('changes .disabledBy', async () => {
variables = { id: 'comment-id' }
const before = { data: { Comment: [{ id: 'comment-id', disabledBy: null }] } }
const expected = {
data: { Comment: [{ id: 'comment-id', disabledBy: { id: 'moderator-id' } }] },
}
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(before)
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({
data: { disable: 'comment-id' },
})
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected)
})
it('updates .disabled on comment', async () => {
variables = { id: 'comment-id' }
const before = { data: { Comment: [{ id: 'comment-id', disabled: false }] } }
const expected = { data: { Comment: [{ id: 'comment-id', disabled: true }] } }
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(before)
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({
data: { disable: 'comment-id' },
})
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected)
}) })
}) })
describe('moderate a post', () => { it('updates .disabled on post', async () => {
beforeEach(async () => { await expect(
variables = {} mutate({ mutation: reviewMutation, variables: disableVariables }),
await factory.create('Post', { ).resolves.toMatchObject({
id: 'sample-post-id', data: { review: { resource: { __typename: 'Post', id: 'post-id', disabled: true } } },
}) errors: undefined,
}) })
})
it('returns disabled resource id', async () => { it('can be closed with one review', async () => {
variables = { id: 'sample-post-id' } closeReportVariables = {
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ ...disableVariables,
data: { disable: 'sample-post-id' }, closed: true,
}) }
await expect(
mutate({ mutation: reviewMutation, variables: closeReportVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'Post', id: 'post-id' },
report: { id: expect.any(String), closed: true },
},
},
errors: undefined,
}) })
})
})
it('changes .disabledBy', async () => { describe('moderate a user', () => {
variables = { id: 'sample-post-id' } beforeEach(async () => {
const before = { data: { Post: [{ id: 'sample-post-id', disabledBy: null }] } } const troll = await factory.create('User', {
const expected = { id: 'user-id',
data: { Post: [{ id: 'sample-post-id', disabledBy: { id: 'moderator-id' } }] },
}
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(before)
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({
data: { disable: 'sample-post-id' },
})
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected)
}) })
const reportAgainstTroll = await factory.create('Report')
await Promise.all([
reportAgainstTroll.relateTo(nonModerator, 'filed', {
resourceId: 'user-id',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This user is harassing me with bigoted remarks!',
}),
reportAgainstTroll.relateTo(troll, 'belongsTo'),
])
disableVariables = {
...disableVariables,
resourceId: 'user-id',
}
})
it('updates .disabled on post', async () => { it('returns disabled resource id', async () => {
const before = { data: { Post: [{ id: 'sample-post-id', disabled: false }] } } await expect(
const expected = { data: { Post: [{ id: 'sample-post-id', disabled: true }] } } mutate({ mutation: reviewMutation, variables: disableVariables }),
variables = { id: 'sample-post-id' } ).resolves.toMatchObject({
data: { review: { resource: { __typename: 'User', id: 'user-id' } } },
errors: undefined,
})
})
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(before) it('returns .reviewed', async () => {
await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ await expect(
data: { disable: 'sample-post-id' }, mutate({ mutation: reviewMutation, variables: disableVariables }),
}) ).resolves.toMatchObject({
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) data: {
review: {
resource: { __typename: 'User', id: 'user-id' },
report: {
id: expect.any(String),
reviewed: expect.arrayContaining([
{ createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
]),
},
},
},
errors: undefined,
})
})
it('updates .disabled on user', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: { resource: { __typename: 'User', id: 'user-id', disabled: true } } },
errors: undefined,
})
})
it('can be closed with one review', async () => {
closeReportVariables = {
...disableVariables,
closed: true,
}
await expect(
mutate({ mutation: reviewMutation, variables: closeReportVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'User', id: 'user-id' },
report: { id: expect.any(String), closed: true },
},
},
errors: undefined,
}) })
}) })
}) })
}) })
}) })
describe('enable', () => { describe('review to re-enable after disabled', () => {
describe('unautenticated user', () => { describe('unautenticated user', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
variables = { id: 'sample-post-id' } enableVariables = {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ ...enableVariables,
resourceId: 'post-id',
}
await expect(
mutate({ mutation: reviewMutation, variables: enableVariables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }], errors: [{ message: 'Not Authorised!' }],
}) })
}) })
@ -228,17 +481,17 @@ describe('moderate resources', () => {
describe('authenticated user', () => { describe('authenticated user', () => {
describe('non moderator', () => { describe('non moderator', () => {
beforeEach(async () => { beforeEach(async () => {
nonModerator = await factory.create('User', {
id: 'non-moderator',
name: 'Non Moderator',
email: 'non.moderator@example.org',
password: '1234',
})
authenticatedUser = await nonModerator.toJson() authenticatedUser = await nonModerator.toJson()
}) })
it('throws authorization error', async () => { it('throws authorization error', async () => {
variables = { id: 'sample-post-id' } enableVariables = {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ ...enableVariables,
resourceId: 'post-id',
}
await expect(
mutate({ mutation: reviewMutation, variables: enableVariables }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }], errors: [{ message: 'Not Authorised!' }],
}) })
}) })
@ -248,101 +501,197 @@ describe('moderate resources', () => {
beforeEach(async () => { beforeEach(async () => {
authenticatedUser = await moderator.toJson() authenticatedUser = await moderator.toJson()
}) })
describe('moderate a resource that is not a (Comment|Post|User) ', () => {
beforeEach(async () => {
await Promise.all([factory.create('Tag', { id: 'sample-tag-id' })])
})
it('returns null', async () => {
await expect(
mutate({ mutation: enableMutation, variables: { id: 'sample-tag-id' } }),
).resolves.toMatchObject({
data: { enable: null },
})
})
})
describe('moderate a comment', () => { describe('moderate a comment', () => {
beforeEach(async () => { beforeEach(async () => {
variables = { id: 'comment-id' } const trollingComment = await factory.create('Comment', {
await factory.create('Comment', {
id: 'comment-id', id: 'comment-id',
}) })
await mutate({ mutation: disableMutation, variables }) const reportAgainstTrollingComment = await factory.create('Report')
await Promise.all([
reportAgainstTrollingComment.relateTo(nonModerator, 'filed', {
resourceId: 'comment-id',
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
await Promise.all([
reportAgainstTrollingComment.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'comment-id',
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
enableVariables = {
...enableVariables,
resourceId: 'comment-id',
}
}) })
it('returns enabled resource id', async () => { it('returns enabled resource id', async () => {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ await expect(
data: { enable: 'comment-id' }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
data: { review: { resource: { __typename: 'Comment', id: 'comment-id' } } },
}) })
}) })
it('changes .disabledBy', async () => { it('returns .reviewed', async () => {
const expected = { await expect(
data: { Comment: [{ id: 'comment-id', disabledBy: null }] }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
} data: {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ review: {
data: { enable: 'comment-id' }, resource: { __typename: 'Comment', id: 'comment-id' },
errors: undefined, report: {
id: expect.any(String),
reviewed: expect.arrayContaining([
{ createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
]),
},
},
},
}) })
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected)
}) })
it('updates .disabled on comment', async () => { it('updates .disabled on comment', async () => {
const expected = { await expect(
data: { Comment: [{ id: 'comment-id', disabled: false }] }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
} data: {
review: { resource: { __typename: 'Comment', id: 'comment-id', disabled: false } },
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ },
data: { enable: 'comment-id' },
errors: undefined,
}) })
await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected)
}) })
}) })
describe('moderate a post', () => { describe('moderate a post', () => {
beforeEach(async () => { beforeEach(async () => {
variables = { id: 'post-id' } const trollingPost = await factory.create('Post', {
await factory.create('Post', {
id: 'post-id', id: 'post-id',
}) })
await mutate({ mutation: disableMutation, variables }) const reportAgainstTrollingPost = await factory.create('Report')
await Promise.all([
reportAgainstTrollingPost.relateTo(nonModerator, 'filed', {
resourceId: 'post-id',
reasonCategory: 'doxing',
reasonDescription:
"This shouldn't be shown to anybody else! It's my private thing!",
}),
reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'),
])
await Promise.all([
reportAgainstTrollingPost.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'comment-id',
}),
trollingPost.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
enableVariables = {
...enableVariables,
resourceId: 'post-id',
}
}) })
it('returns enabled resource id', async () => { it('returns enabled resource id', async () => {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ await expect(
data: { enable: 'post-id' }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
data: { review: { resource: { __typename: 'Post', id: 'post-id' } } },
}) })
}) })
it('changes .disabledBy', async () => { it('returns .reviewed', async () => {
const expected = { await expect(
data: { Post: [{ id: 'post-id', disabledBy: null }] }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
} data: {
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ review: {
data: { enable: 'post-id' }, resource: { __typename: 'Post', id: 'post-id' },
errors: undefined, report: {
id: expect.any(String),
reviewed: expect.arrayContaining([
{ createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
]),
},
},
},
}) })
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected)
}) })
it('updates .disabled on post', async () => { it('updates .disabled on post', async () => {
const expected = { await expect(
data: { Post: [{ id: 'post-id', disabled: false }] }, mutate({ mutation: reviewMutation, variables: enableVariables }),
errors: undefined, ).resolves.toMatchObject({
} data: {
review: { resource: { __typename: 'Post', id: 'post-id', disabled: false } },
await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ },
data: { enable: 'post-id' }, })
errors: undefined, })
})
describe('moderate a user', () => {
beforeEach(async () => {
const troll = await factory.create('User', {
id: 'user-id',
})
const reportAgainstTroll = await factory.create('Report')
await Promise.all([
reportAgainstTroll.relateTo(nonModerator, 'filed', {
resourceId: 'user-id',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This user is harassing me with bigoted remarks!',
}),
reportAgainstTroll.relateTo(troll, 'belongsTo'),
])
await Promise.all([
reportAgainstTroll.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'comment-id',
}),
troll.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
enableVariables = {
...enableVariables,
resourceId: 'user-id',
}
})
it('returns enabled resource id', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: enableVariables }),
).resolves.toMatchObject({
data: { review: { resource: { __typename: 'User', id: 'user-id' } } },
})
})
it('returns .reviewed', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: enableVariables }),
).resolves.toMatchObject({
data: {
review: {
resource: { __typename: 'User', id: 'user-id' },
report: {
id: expect.any(String),
reviewed: expect.arrayContaining([
{ createdAt: expect.any(String), moderator: { id: 'moderator-id' } },
]),
},
},
},
})
})
it('updates .disabled on user', async () => {
await expect(
mutate({ mutation: reviewMutation, variables: enableVariables }),
).resolves.toMatchObject({
data: {
review: { resource: { __typename: 'User', id: 'user-id', disabled: false } },
},
}) })
await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected)
}) })
}) })
}) })

View File

@ -18,7 +18,7 @@ export default {
notifications: async (_parent, args, context, _resolveInfo) => { notifications: async (_parent, args, context, _resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() const session = context.driver.session()
let notifications, whereClause, orderByClause let whereClause, orderByClause
switch (args.read) { switch (args.read) {
case true: case true:
@ -42,27 +42,25 @@ export default {
} }
const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : '' const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : ''
const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : '' const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : ''
try { const cypher = `
const cypher = `
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause} ${whereClause}
RETURN resource, notification, user RETURN resource, notification, user
${orderByClause} ${orderByClause}
${offset} ${limit} ${offset} ${limit}
` `
try {
const result = await session.run(cypher, { id: currentUser.id }) const result = await session.run(cypher, { id: currentUser.id })
notifications = await result.records.map(transformReturnType) return result.records.map(transformReturnType)
} finally { } finally {
session.close() session.close()
} }
return notifications
}, },
}, },
Mutation: { Mutation: {
markAsRead: async (parent, args, context, resolveInfo) => { markAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() const session = context.driver.session()
let notification
try { try {
const cypher = ` const cypher = `
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
@ -71,11 +69,10 @@ export default {
` `
const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id }) const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id })
const notifications = await result.records.map(transformReturnType) const notifications = await result.records.map(transformReturnType)
notification = notifications[0] return notifications[0]
} finally { } finally {
session.close() session.close()
} }
return notification
}, },
}, },
NOTIFIED: { NOTIFIED: {

View File

@ -9,7 +9,6 @@ export default {
return createPasswordReset({ driver, nonce, email }) return createPasswordReset({ driver, nonce, email })
}, },
resetPassword: async (_parent, { email, nonce, newPassword }, { driver }) => { resetPassword: async (_parent, { email, nonce, newPassword }, { driver }) => {
const session = driver.session()
const stillValid = new Date() const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1) stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
@ -21,16 +20,20 @@ export default {
SET u.encryptedPassword = $encryptedNewPassword SET u.encryptedPassword = $encryptedNewPassword
RETURN pr RETURN pr
` `
const transactionRes = await session.run(cypher, { const session = driver.session()
stillValid, try {
email, const transactionRes = await session.run(cypher, {
nonce, stillValid,
encryptedNewPassword, email,
}) nonce,
const [reset] = transactionRes.records.map(record => record.get('pr')) encryptedNewPassword,
const response = !!(reset && reset.properties.usedAt) })
session.close() const [reset] = transactionRes.records.map(record => record.get('pr'))
return response const response = !!(reset && reset.properties.usedAt)
return response
} finally {
session.close()
}
}, },
}, },
} }

View File

@ -15,10 +15,13 @@ let variables
const getAllPasswordResets = async () => { const getAllPasswordResets = async () => {
const session = driver.session() const session = driver.session()
const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') try {
const resets = transactionRes.records.map(record => record.get('r')) const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
session.close() const resets = transactionRes.records.map(record => record.get('r'))
return resets return resets
} finally {
session.close()
}
} }
beforeEach(() => { beforeEach(() => {

View File

@ -5,6 +5,7 @@ import { getBlockedUsers, getBlockedByUsers } from './users.js'
import { mergeWith, isArray, isEmpty } from 'lodash' import { mergeWith, isArray, isEmpty } from 'lodash'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
const filterForBlockedUsers = async (params, context) => { const filterForBlockedUsers = async (params, context) => {
if (!context.user) return params if (!context.user) return params
const [blockedUsers, blockedByUsers] = await Promise.all([ const [blockedUsers, blockedByUsers] = await Promise.all([
@ -54,37 +55,41 @@ export default {
return neo4jgraphql(object, params, context, resolveInfo) return neo4jgraphql(object, params, context, resolveInfo)
}, },
PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => {
const session = context.driver.session()
const { postId, data } = params const { postId, data } = params
const transactionRes = await session.run( const session = context.driver.session()
`MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() try {
const transactionRes = await session.run(
`MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-()
RETURN COUNT(DISTINCT emoted) as emotionsCount RETURN COUNT(DISTINCT emoted) as emotionsCount
`, `,
{ postId, data }, { postId, data },
) )
session.close()
const [emotionsCount] = transactionRes.records.map(record => { const [emotionsCount] = transactionRes.records.map(record => {
return record.get('emotionsCount').low return record.get('emotionsCount').low
}) })
return emotionsCount
return emotionsCount } finally {
session.close()
}
}, },
PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => {
const session = context.driver.session()
const { postId } = params const { postId } = params
const transactionRes = await session.run( const session = context.driver.session()
`MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) try {
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId})
RETURN collect(emoted.emotion) as emotion`, RETURN collect(emoted.emotion) as emotion`,
{ userId: context.user.id, postId }, { userId: context.user.id, postId },
) )
session.close() const [emotions] = transactionRes.records.map(record => {
return record.get('emotion')
const [emotions] = transactionRes.records.map(record => { })
return record.get('emotion') return emotions
}) } finally {
return emotions session.close()
}
}, },
}, },
Mutation: { Mutation: {
@ -93,8 +98,6 @@ export default {
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
params.id = params.id || uuid() params.id = params.id || uuid()
let post
const createPostCypher = `CREATE (post:Post {params}) const createPostCypher = `CREATE (post:Post {params})
SET post.createdAt = toString(datetime()) SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime()) SET post.updatedAt = toString(datetime())
@ -113,7 +116,7 @@ export default {
try { try {
const transactionRes = await session.run(createPostCypher, createPostVariables) const transactionRes = await session.run(createPostCypher, createPostVariables)
const posts = transactionRes.records.map(record => record.get('post').properties) const posts = transactionRes.records.map(record => record.get('post').properties)
post = posts[0] return posts[0]
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Post with this slug already exists!') throw new UserInputError('Post with this slug already exists!')
@ -121,54 +124,55 @@ export default {
} finally { } finally {
session.close() session.close()
} }
return post
}, },
UpdatePost: async (_parent, params, context, _resolveInfo) => { UpdatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params const { categoryIds } = params
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
const session = context.driver.session()
let updatePostCypher = `MATCH (post:Post {id: $params.id}) let updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post += $params SET post += $params
SET post.updatedAt = toString(datetime()) SET post.updatedAt = toString(datetime())
WITH post WITH post
` `
if (categoryIds && categoryIds.length) { const session = context.driver.session()
const cypherDeletePreviousRelations = ` try {
if (categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations DELETE previousRelations
RETURN post, category RETURN post, category
` `
await session.run(cypherDeletePreviousRelations, { params }) await session.run(cypherDeletePreviousRelations, { params })
updatePostCypher += ` updatePostCypher += `
UNWIND $categoryIds AS categoryId UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId}) MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category) MERGE (post)-[:CATEGORIZED]->(category)
WITH post WITH post
` `
}
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
const transactionRes = await session.run(updatePostCypher, updatePostVariables)
const [post] = transactionRes.records.map(record => {
return record.get('post').properties
})
return post
} finally {
session.close()
} }
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
const transactionRes = await session.run(updatePostCypher, updatePostVariables)
const [post] = transactionRes.records.map(record => {
return record.get('post').properties
})
session.close()
return post
}, },
DeletePost: async (object, args, context, resolveInfo) => { DeletePost: async (object, args, context, resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
// we cannot set slug to 'UNAVAILABE' because of unique constraints try {
const transactionRes = await session.run( // we cannot set slug to 'UNAVAILABE' because of unique constraints
` const transactionRes = await session.run(
`
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
SET post.deleted = TRUE SET post.deleted = TRUE
@ -179,51 +183,60 @@ export default {
REMOVE post.image REMOVE post.image
RETURN post RETURN post
`, `,
{ postId: args.id }, { postId: args.id },
) )
session.close() const [post] = transactionRes.records.map(record => record.get('post').properties)
const [post] = transactionRes.records.map(record => record.get('post').properties) return post
return post } finally {
session.close()
}
}, },
AddPostEmotions: async (object, params, context, resolveInfo) => { AddPostEmotions: async (object, params, context, resolveInfo) => {
const session = context.driver.session()
const { to, data } = params const { to, data } = params
const { user } = context const { user } = context
const transactionRes = await session.run( const session = context.driver.session()
`MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id}) try {
const transactionRes = await session.run(
`MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id})
MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo) MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo)
RETURN userFrom, postTo, emotedRelation`, RETURN userFrom, postTo, emotedRelation`,
{ user, to, data }, { user, to, data },
) )
session.close()
const [emoted] = transactionRes.records.map(record => { const [emoted] = transactionRes.records.map(record => {
return { return {
from: { ...record.get('userFrom').properties }, from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties }, to: { ...record.get('postTo').properties },
...record.get('emotedRelation').properties, ...record.get('emotedRelation').properties,
} }
}) })
return emoted return emoted
} finally {
session.close()
}
}, },
RemovePostEmotions: async (object, params, context, resolveInfo) => { RemovePostEmotions: async (object, params, context, resolveInfo) => {
const session = context.driver.session()
const { to, data } = params const { to, data } = params
const { id: from } = context.user const { id: from } = context.user
const transactionRes = await session.run( const session = context.driver.session()
`MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) try {
const transactionRes = await session.run(
`MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id})
DELETE emotedRelation DELETE emotedRelation
RETURN userFrom, postTo`, RETURN userFrom, postTo`,
{ from, to, data }, { from, to, data },
) )
session.close() const [emoted] = transactionRes.records.map(record => {
const [emoted] = transactionRes.records.map(record => { return {
return { from: { ...record.get('userFrom').properties },
from: { ...record.get('userFrom').properties }, to: { ...record.get('postTo').properties },
to: { ...record.get('postTo').properties }, emotion: data.emotion,
emotion: data.emotion, }
} })
}) return emoted
return emoted } finally {
session.close()
}
}, },
pinPost: async (_parent, params, context, _resolveInfo) => { pinPost: async (_parent, params, context, _resolveInfo) => {
let pinnedPostWithNestedAttributes let pinnedPostWithNestedAttributes
@ -241,25 +254,25 @@ export default {
) )
return deletePreviousRelationsResponse.records.map(record => record.get('post').properties) return deletePreviousRelationsResponse.records.map(record => record.get('post').properties)
}) })
await writeTxResultPromise try {
await writeTxResultPromise
writeTxResultPromise = session.writeTransaction(async transaction => { writeTxResultPromise = session.writeTransaction(async transaction => {
const pinPostTransactionResponse = await transaction.run( const pinPostTransactionResponse = await transaction.run(
` `
MATCH (user:User {id: $userId}) WHERE user.role = 'admin' MATCH (user:User {id: $userId}) WHERE user.role = 'admin'
MATCH (post:Post {id: $params.id}) MATCH (post:Post {id: $params.id})
MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post)
SET post.pinned = true SET post.pinned = true
RETURN post, pinned.createdAt as pinnedAt RETURN post, pinned.createdAt as pinnedAt
`, `,
{ userId, params }, { userId, params },
) )
return pinPostTransactionResponse.records.map(record => ({ return pinPostTransactionResponse.records.map(record => ({
pinnedPost: record.get('post').properties, pinnedPost: record.get('post').properties,
pinnedAt: record.get('pinnedAt'), pinnedAt: record.get('pinnedAt'),
})) }))
}) })
try {
const [transactionResult] = await writeTxResultPromise const [transactionResult] = await writeTxResultPromise
const { pinnedPost, pinnedAt } = transactionResult const { pinnedPost, pinnedAt } = transactionResult
pinnedPostWithNestedAttributes = { pinnedPostWithNestedAttributes = {
@ -314,7 +327,6 @@ export default {
}, },
hasOne: { hasOne: {
author: '<-[:WROTE]-(related:User)', author: '<-[:WROTE]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)',
}, },
count: { count: {

View File

@ -316,53 +316,6 @@ describe('CreatePost', () => {
) )
}) })
}) })
describe('categories', () => {
describe('null', () => {
beforeEach(() => {
variables = { ...variables, categoryIds: null }
})
it('throws UserInputError', async () => {
const {
errors: [error],
} = await mutate({ mutation: createPostMutation, variables })
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
})
describe('empty', () => {
beforeEach(() => {
variables = { ...variables, categoryIds: [] }
})
it('throws UserInputError', async () => {
const {
errors: [error],
} = await mutate({ mutation: createPostMutation, variables })
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
})
describe('more than 3 items', () => {
beforeEach(() => {
variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] }
})
it('throws UserInputError', async () => {
const {
errors: [error],
} = await mutate({ mutation: createPostMutation, variables })
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
})
})
}) })
}) })
@ -493,74 +446,6 @@ describe('UpdatePost', () => {
expected, expected,
) )
}) })
describe('more than 3 categories', () => {
beforeEach(() => {
variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] }
})
it('allows a maximum of three category for a successful update', async () => {
const {
errors: [error],
} = await mutate({ mutation: updatePostMutation, variables })
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
})
describe('post created without categories somehow', () => {
let owner
beforeEach(async () => {
const postSomehowCreated = await neode.create('Post', {
id: 'how-was-this-created',
})
owner = await neode.create('User', {
id: 'author-of-post-without-category',
name: 'Hacker',
slug: 'hacker',
email: 'hacker@example.org',
password: '1234',
})
await postSomehowCreated.relateTo(owner, 'author')
authenticatedUser = await owner.toJson()
variables = { ...variables, id: 'how-was-this-created' }
})
it('throws an error if categoryIds is not an array', async () => {
const {
errors: [error],
} = await mutate({
mutation: updatePostMutation,
variables: {
...variables,
categoryIds: null,
},
})
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
it('requires at least one category for successful update', async () => {
const {
errors: [error],
} = await mutate({
mutation: updatePostMutation,
variables: {
...variables,
categoryIds: [],
},
})
expect(error).toHaveProperty(
'message',
'You cannot save a post without at least one category or more than three',
)
})
})
}) })
}) })

View File

@ -4,31 +4,12 @@ import fileUpload from './fileUpload'
import encryptPassword from '../../helpers/encryptPassword' import encryptPassword from '../../helpers/encryptPassword'
import generateNonce from './helpers/generateNonce' import generateNonce from './helpers/generateNonce'
import existingEmailAddress from './helpers/existingEmailAddress' import existingEmailAddress from './helpers/existingEmailAddress'
import { normalizeEmail } from 'validator' import normalizeEmail from './helpers/normalizeEmail'
const instance = neode() const instance = neode()
export default { export default {
Mutation: { Mutation: {
CreateInvitationCode: async (_parent, args, context, _resolveInfo) => {
args.token = generateNonce()
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) => { Signup: async (_parent, args, context) => {
args.nonce = generateNonce() args.nonce = generateNonce()
args.email = normalizeEmail(args.email) args.email = normalizeEmail(args.email)
@ -41,35 +22,6 @@ export default {
throw new UserInputError(e.message) throw new UserInputError(e.message)
} }
}, },
SignupByInvitation: async (_parent, args, context) => {
const { token } = args
args.nonce = generateNonce()
args.email = normalizeEmail(args.email)
let emailAddress = await existingEmailAddress({ args, context })
if (emailAddress) return emailAddress
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.')
emailAddress = await instance.create('EmailAddress', args)
await validInvitationCode.relateTo(emailAddress, 'activated')
return emailAddress.toJson()
} catch (e) {
throw new UserInputError(e)
}
},
SignupVerification: async (_parent, args) => { SignupVerification: async (_parent, args) => {
const { termsAndConditionsAgreedVersion } = args const { termsAndConditionsAgreedVersion } = args
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)

View File

@ -9,7 +9,6 @@ const neode = getNeode()
let mutate let mutate
let authenticatedUser let authenticatedUser
let user
let variables let variables
const driver = getDriver() const driver = getDriver()
@ -34,243 +33,6 @@ afterEach(async () => {
await factory.cleanDatabase() await factory.cleanDatabase()
}) })
describe('CreateInvitationCode', () => {
const mutation = gql`
mutation {
CreateInvitationCode {
token
}
}
`
describe('unauthenticated', () => {
beforeEach(() => {
authenticatedUser = null
})
it('throws Authorization error', async () => {
await expect(mutate({ mutation })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
})
describe('authenticated', () => {
beforeEach(async () => {
user = await factory.create('User', {
id: 'i123',
name: 'Inviter',
email: 'inviter@example.org',
password: '1234',
termsAndConditionsAgreedVersion: null,
})
authenticatedUser = await user.toJson()
})
it('resolves', async () => {
await expect(mutate({ mutation })).resolves.toMatchObject({
data: { CreateInvitationCode: { token: expect.any(String) } },
})
})
it('creates an InvitationCode with a `createdAt` attribute', async () => {
await mutate({ mutation })
const codes = await neode.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 mutate({ mutation })
const result = await neode.cypher(
'MATCH(code:InvitationCode)<-[:GENERATED]-(user:User) RETURN user',
)
const inviter = neode.hydrateFirst(result, 'user', neode.model('User'))
await expect(inviter.toJson()).resolves.toEqual(expect.objectContaining({ name: 'Inviter' }))
})
describe('who has invited a lot of users already', () => {
beforeEach(async () => {
await Promise.all([mutate({ mutation }), mutate({ mutation }), mutate({ mutation })])
})
describe('as ordinary `user`', () => {
it('throws `Not Authorised` because of maximum number of invitations', async () => {
await expect(mutate({ mutation })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
})
})
it('creates no additional invitation codes', async () => {
await mutate({ mutation })
const invitationCodes = await neode.all('InvitationCode')
await expect(invitationCodes.toJson()).resolves.toHaveLength(3)
})
})
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 = gql`
mutation($email: String!, $token: String!) {
SignupByInvitation(email: $email, token: $token) {
email
}
}
`
describe('with valid email but invalid InvitationCode', () => {
beforeEach(() => {
variables = {
...variables,
email: 'any-email@example.org',
token: 'wut?',
}
})
it('throws UserInputError', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'UserInputError: Invitation code already used or does not exist.' }],
})
})
describe('with valid InvitationCode', () => {
beforeEach(async () => {
const inviter = await factory.create('User', {
name: 'Inviter',
email: 'inviter@example.org',
password: '1234',
})
authenticatedUser = await inviter.toJson()
const invitationMutation = gql`
mutation {
CreateInvitationCode {
token
}
}
`
const {
data: {
CreateInvitationCode: { token },
},
} = await mutate({ mutation: invitationMutation })
authenticatedUser = null
variables = {
...variables,
token,
}
})
describe('given an invalid email', () => {
beforeEach(() => {
variables = { ...variables, email: 'someuser' }
})
it('throws `email is not a valid email`', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: expect.stringContaining('"email" must be a valid email') }],
})
})
it('creates no additional EmailAddress node', async () => {
let emailAddresses = await neode.all('EmailAddress')
emailAddresses = await emailAddresses.toJson()
expect(emailAddresses).toHaveLength(1)
await mutate({ mutation, variables })
emailAddresses = await neode.all('EmailAddress')
emailAddresses = await emailAddresses.toJson()
expect(emailAddresses).toHaveLength(1)
})
})
describe('given a valid email', () => {
beforeEach(() => {
variables = { ...variables, email: 'someUser@example.org' }
})
it('resolves', async () => {
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { SignupByInvitation: { email: 'someuser@example.org' } },
})
})
describe('creates a EmailAddress node', () => {
it('with a `createdAt` attribute', async () => {
await mutate({ mutation, variables })
let emailAddress = await neode.first('EmailAddress', { email: 'someuser@example.org' })
emailAddress = await emailAddress.toJson()
expect(emailAddress.createdAt).toBeTruthy()
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
})
it('with a cryptographic `nonce`', async () => {
await mutate({ mutation, variables })
let emailAddress = await neode.first('EmailAddress', { email: 'someuser@example.org' })
emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String))
})
it('connects inviter through invitation code', async () => {
await mutate({ mutation, variables })
const result = await neode.cypher(
'MATCH(inviter:User)-[:GENERATED]->(:InvitationCode)-[:ACTIVATED]->(email:EmailAddress {email: {email}}) RETURN inviter',
{ email: 'someuser@example.org' },
)
const inviter = neode.hydrateFirst(result, 'inviter', neode.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 () => {
await mutate({ mutation, variables })
variables = { ...variables, email: 'yetanotheremail@example.org' }
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [
{ message: 'UserInputError: Invitation code already used or does not exist.' },
],
})
})
})
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(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'A user account with this email already exists.' }],
})
})
})
describe('if the EmailAddress already exists but without user account', () => {
it.todo('shall we re-send the registration email?')
})
})
})
})
})
})
describe('Signup', () => { describe('Signup', () => {
const mutation = gql` const mutation = gql`
mutation($email: String!) { mutation($email: String!) {

View File

@ -1,18 +1,32 @@
const transformReturnType = record => {
return {
...record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
export default { export default {
Mutation: { Mutation: {
report: async (_parent, params, context, _resolveInfo) => { fileReport: async (_parent, params, context, _resolveInfo) => {
let createdRelationshipWithNestedAttributes let createdRelationshipWithNestedAttributes
const { resourceId, reasonCategory, reasonDescription } = params const { resourceId, reasonCategory, reasonDescription } = params
const { driver, user } = context const { driver, user } = context
const session = driver.session() const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => { const reportWriteTxResultPromise = session.writeTransaction(async txc => {
const reportRelationshipTransactionResponse = await txc.run( const reportTransactionResponse = await txc.run(
` `
MATCH (submitter:User {id: $submitterId}) MATCH (submitter:User {id: $submitterId})
MATCH (resource {id: $resourceId}) MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Comment OR resource:Post WHERE resource:User OR resource:Post OR resource:Comment
CREATE (resource)<-[report:REPORTED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter) MERGE (resource)<-[:BELONGS_TO]-(report:Report {closed: false})
RETURN report, submitter, resource, labels(resource)[0] as type ON CREATE SET report.id = randomUUID(), report.createdAt = $createdAt, report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.disable = resource.disabled, report.closed = false
WITH submitter, resource, report
CREATE (report)<-[filed:FILED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter)
RETURN report, resource, labels(resource)[0] AS type
`, `,
{ {
resourceId, resourceId,
@ -22,36 +36,12 @@ export default {
reasonDescription, reasonDescription,
}, },
) )
return reportRelationshipTransactionResponse.records.map(record => ({ return reportTransactionResponse.records.map(transformReturnType)
report: record.get('report'),
submitter: record.get('submitter'),
resource: record.get('resource').properties,
type: record.get('type'),
}))
}) })
try { try {
const txResult = await writeTxResultPromise const txResult = await reportWriteTxResultPromise
if (!txResult[0]) return null if (!txResult[0]) return null
const { report, submitter, resource, type } = txResult[0] createdRelationshipWithNestedAttributes = txResult[0]
createdRelationshipWithNestedAttributes = {
...report.properties,
post: null,
comment: null,
user: null,
submitter: submitter.properties,
type,
}
switch (type) {
case 'Post':
createdRelationshipWithNestedAttributes.post = resource
break
case 'Comment':
createdRelationshipWithNestedAttributes.comment = resource
break
case 'User':
createdRelationshipWithNestedAttributes.user = resource
break
}
} finally { } finally {
session.close() session.close()
} }
@ -62,8 +52,7 @@ export default {
reports: async (_parent, params, context, _resolveInfo) => { reports: async (_parent, params, context, _resolveInfo) => {
const { driver } = context const { driver } = context
const session = driver.session() const session = driver.session()
let response let reports, orderByClause
let orderByClause
switch (params.orderBy) { switch (params.orderBy) {
case 'createdAt_asc': case 'createdAt_asc':
orderByClause = 'ORDER BY report.createdAt ASC' orderByClause = 'ORDER BY report.createdAt ASC'
@ -74,55 +63,97 @@ export default {
default: default:
orderByClause = '' orderByClause = ''
} }
try { const reportReadTxPromise = session.readTransaction(async tx => {
const cypher = ` const allReportsTransactionResponse = await tx.run(
MATCH (submitter:User)-[report:REPORTED]->(resource) `
WHERE resource:User OR resource:Comment OR resource:Post MATCH (submitter:User)-[filed:FILED]->(report:Report)-[:BELONGS_TO]->(resource)
RETURN report, submitter, resource, labels(resource)[0] as type WHERE resource:User OR resource:Post OR resource:Comment
RETURN DISTINCT report, resource, labels(resource)[0] as type
${orderByClause} ${orderByClause}
` `,
const result = await session.run(cypher, {}) {},
const dbResponse = result.records.map(r => { )
return { return allReportsTransactionResponse.records.map(transformReturnType)
report: r.get('report'), })
submitter: r.get('submitter'), try {
resource: r.get('resource'), const txResult = await reportReadTxPromise
type: r.get('type'), if (!txResult[0]) return null
reports = txResult
} finally {
session.close()
}
return reports
},
},
Report: {
filed: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.filed !== 'undefined') return parent.filed
const session = context.driver.session()
const { id } = parent
let filed
const readTxPromise = session.readTransaction(async tx => {
const allReportsTransactionResponse = await tx.run(
`
MATCH (submitter:User)-[filed:FILED]->(report:Report {id: $id})
RETURN filed, submitter
`,
{ id },
)
return allReportsTransactionResponse.records.map(record => ({
submitter: record.get('submitter').properties,
filed: record.get('filed').properties,
}))
})
try {
const txResult = await readTxPromise
if (!txResult[0]) return null
filed = txResult.map(reportedRecord => {
const { submitter, filed } = reportedRecord
const relationshipWithNestedAttributes = {
...filed,
submitter,
} }
}) return relationshipWithNestedAttributes
if (!dbResponse) return null
response = []
dbResponse.forEach(ele => {
const { report, submitter, resource, type } = ele
const responseEle = {
...report.properties,
post: null,
comment: null,
user: null,
submitter: submitter.properties,
type,
}
switch (type) {
case 'Post':
responseEle.post = resource.properties
break
case 'Comment':
responseEle.comment = resource.properties
break
case 'User':
responseEle.user = resource.properties
break
}
response.push(responseEle)
}) })
} finally { } finally {
session.close() session.close()
} }
return filed
return response },
reviewed: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.reviewed !== 'undefined') return parent.reviewed
const session = context.driver.session()
const { id } = parent
let reviewed
const readTxPromise = session.readTransaction(async tx => {
const allReportsTransactionResponse = await tx.run(
`
MATCH (resource)<-[:BELONGS_TO]-(report:Report {id: $id})<-[review:REVIEWED]-(moderator:User)
RETURN moderator, review
ORDER BY report.updatedAt DESC, review.updatedAt DESC
`,
{ id },
)
return allReportsTransactionResponse.records.map(record => ({
review: record.get('review').properties,
moderator: record.get('moderator').properties,
}))
})
try {
const txResult = await readTxPromise
if (!txResult[0]) return null
reviewed = txResult.map(reportedRecord => {
const { review, moderator } = reportedRecord
const relationshipWithNestedAttributes = {
...review,
moderator,
}
return relationshipWithNestedAttributes
})
} finally {
session.close()
}
return reviewed
}, },
}, },
} }

View File

@ -8,31 +8,41 @@ const factory = Factory()
const instance = getNeode() const instance = getNeode()
const driver = getDriver() const driver = getDriver()
describe('report resources', () => { describe('file a report on a resource', () => {
let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser
const categoryIds = ['cat9'] const categoryIds = ['cat9']
const reportMutation = gql` const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report( fileReport(
resourceId: $resourceId resourceId: $resourceId
reasonCategory: $reasonCategory reasonCategory: $reasonCategory
reasonDescription: $reasonDescription reasonDescription: $reasonDescription
) { ) {
id
createdAt createdAt
reasonCategory updatedAt
reasonDescription disable
type closed
submitter { rule
email resource {
__typename
... on User {
name
}
... on Post {
title
}
... on Comment {
content
}
} }
user { filed {
name submitter {
} id
post { }
title createdAt
} reasonCategory
comment { reasonDescription
content
} }
} }
} }
@ -67,7 +77,7 @@ describe('report resources', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
authenticatedUser = null authenticatedUser = null
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
data: { report: null }, data: { fileReport: null },
errors: [{ message: 'Not Authorised!' }], errors: [{ message: 'Not Authorised!' }],
}) })
}) })
@ -81,6 +91,12 @@ describe('report resources', () => {
email: 'test@example.org', email: 'test@example.org',
password: '1234', password: '1234',
}) })
otherReportingUser = await factory.create('User', {
id: 'other-reporting-user-id',
role: 'user',
email: 'reporting@example.org',
password: '1234',
})
await factory.create('User', { await factory.create('User', {
id: 'abusive-user-id', id: 'abusive-user-id',
role: 'user', role: 'user',
@ -99,15 +115,15 @@ describe('report resources', () => {
describe('invalid resource id', () => { describe('invalid resource id', () => {
it('returns null', async () => { it('returns null', async () => {
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
data: { report: null }, data: { fileReport: null },
errors: undefined, errors: undefined,
}) })
}) })
}) })
describe('valid resource', () => { describe('valid resource', () => {
describe('reported resource is a user', () => { describe('creates report', () => {
it('returns type "User"', async () => { it('which belongs to resource', async () => {
await expect( await expect(
mutate({ mutate({
mutation: reportMutation, mutation: reportMutation,
@ -115,15 +131,28 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
type: 'User', id: expect.any(String),
}, },
}, },
errors: undefined, errors: undefined,
}) })
}) })
it('returns resource in user attribute', async () => { it('creates only one report for multiple reports on the same resource', async () => {
const firstReport = await mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
authenticatedUser = await otherReportingUser.toJson()
const secondReport = await mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
expect(firstReport.data.fileReport.id).toEqual(secondReport.data.fileReport.id)
})
it('returns the rule for how the report was decided', async () => {
await expect( await expect(
mutate({ mutate({
mutation: reportMutation, mutation: reportMutation,
@ -131,8 +160,46 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
user: { rule: 'latestReviewUpdatedAtRules',
},
},
errors: undefined,
})
})
it.todo('creates multiple filed reports')
})
describe('reported resource is a user', () => {
it('returns __typename "User"', async () => {
await expect(
mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
resource: {
__typename: 'User',
},
},
},
errors: undefined,
})
})
it('returns user attribute info', async () => {
await expect(
mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
resource: {
__typename: 'User',
name: 'abusive-user', name: 'abusive-user',
}, },
}, },
@ -149,10 +216,14 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
submitter: { filed: [
email: 'test@example.org', {
}, submitter: {
id: 'current-user-id',
},
},
],
}, },
}, },
errors: undefined, errors: undefined,
@ -167,7 +238,7 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
createdAt: expect.any(String), createdAt: expect.any(String),
}, },
}, },
@ -187,8 +258,12 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
reasonCategory: 'criminal_behavior_violation_german_law', filed: [
{
reasonCategory: 'criminal_behavior_violation_german_law',
},
],
}, },
}, },
errors: undefined, errors: undefined,
@ -228,15 +303,19 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
reasonDescription: 'My reason!', filed: [
{
reasonDescription: 'My reason!',
},
],
}, },
}, },
errors: undefined, errors: undefined,
}) })
}) })
it('sanitize the reason description', async () => { it('sanitizes the reason description', async () => {
await expect( await expect(
mutate({ mutate({
mutation: reportMutation, mutation: reportMutation,
@ -248,8 +327,12 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
reasonDescription: 'My reason !', filed: [
{
reasonDescription: 'My reason !',
},
],
}, },
}, },
errors: undefined, errors: undefined,
@ -278,8 +361,10 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
type: 'Post', resource: {
__typename: 'Post',
},
}, },
}, },
errors: undefined, errors: undefined,
@ -297,8 +382,9 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
post: { resource: {
__typename: 'Post',
title: 'This is a post that is going to be reported', title: 'This is a post that is going to be reported',
}, },
}, },
@ -306,25 +392,6 @@ describe('report resources', () => {
errors: undefined, errors: undefined,
}) })
}) })
it('returns null in user attribute', async () => {
await expect(
mutate({
mutation: reportMutation,
variables: {
...variables,
resourceId: 'post-to-report-id',
},
}),
).resolves.toMatchObject({
data: {
report: {
user: null,
},
},
errors: undefined,
})
})
}) })
describe('reported resource is a comment', () => { describe('reported resource is a comment', () => {
@ -356,8 +423,10 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
type: 'Comment', resource: {
__typename: 'Comment',
},
}, },
}, },
errors: undefined, errors: undefined,
@ -375,8 +444,9 @@ describe('report resources', () => {
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { data: {
report: { fileReport: {
comment: { resource: {
__typename: 'Comment',
content: 'Post comment to be reported.', content: 'Post comment to be reported.',
}, },
}, },
@ -403,7 +473,7 @@ describe('report resources', () => {
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { report: null }, data: { fileReport: null },
errors: undefined, errors: undefined,
}) })
}) })
@ -411,25 +481,35 @@ describe('report resources', () => {
}) })
}) })
}) })
describe('query for reported resource', () => { describe('query for reported resource', () => {
const reportsQuery = gql` const reportsQuery = gql`
query { query {
reports(orderBy: createdAt_desc) { reports(orderBy: createdAt_desc) {
id
createdAt createdAt
reasonCategory updatedAt
reasonDescription disable
submitter { closed
id resource {
__typename
... on User {
id
}
... on Post {
id
}
... on Comment {
id
}
} }
type filed {
user { submitter {
id id
} }
post { createdAt
id reasonCategory
} reasonDescription
comment {
id
} }
} }
} }
@ -437,7 +517,6 @@ describe('report resources', () => {
beforeEach(async () => { beforeEach(async () => {
authenticatedUser = null authenticatedUser = null
moderator = await factory.create('User', { moderator = await factory.create('User', {
id: 'moderator-1', id: 'moderator-1',
role: 'moderator', role: 'moderator',
@ -518,6 +597,7 @@ describe('report resources', () => {
]) ])
authenticatedUser = null authenticatedUser = null
}) })
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
authenticatedUser = null authenticatedUser = null
@ -527,6 +607,7 @@ describe('report resources', () => {
}) })
}) })
}) })
describe('authenticated', () => { describe('authenticated', () => {
it('role "user" gets no reports', async () => { it('role "user" gets no reports', async () => {
authenticatedUser = await currentUser.toJson() authenticatedUser = await currentUser.toJson()
@ -538,49 +619,69 @@ describe('report resources', () => {
it('role "moderator" gets reports', async () => { it('role "moderator" gets reports', async () => {
const expected = { const expected = {
// to check 'orderBy: createdAt_desc' is not possible here, because 'createdAt' does not differ
reports: expect.arrayContaining([ reports: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String), createdAt: expect.any(String),
reasonCategory: 'doxing', updatedAt: expect.any(String),
reasonDescription: 'This user is harassing me with bigoted remarks', disable: false,
submitter: expect.objectContaining({ closed: false,
id: 'current-user-id', resource: {
}), __typename: 'User',
type: 'User',
user: expect.objectContaining({
id: 'abusive-user-1', id: 'abusive-user-1',
}), },
post: null, filed: expect.arrayContaining([
comment: null, expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
}),
]),
}), }),
expect.objectContaining({ expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String), createdAt: expect.any(String),
reasonCategory: 'other', updatedAt: expect.any(String),
reasonDescription: 'This comment is bigoted', disable: false,
submitter: expect.objectContaining({ closed: false,
id: 'current-user-id', resource: {
}), __typename: 'Post',
type: 'Post',
user: null,
post: expect.objectContaining({
id: 'abusive-post-1', id: 'abusive-post-1',
}), },
comment: null, filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
}),
]),
}), }),
expect.objectContaining({ expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String), createdAt: expect.any(String),
reasonCategory: 'discrimination_etc', updatedAt: expect.any(String),
reasonDescription: 'This post is bigoted', disable: false,
submitter: expect.objectContaining({ closed: false,
id: 'current-user-id', resource: {
}), __typename: 'Comment',
type: 'Comment',
user: null,
post: null,
comment: expect.objectContaining({
id: 'abusive-comment-1', id: 'abusive-comment-1',
}), },
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
}),
]),
}), }),
]), ]),
} }

View File

@ -4,48 +4,51 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
const transactionRes = await session.run( try {
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId}) const transactionRes = await session.run(
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node) MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node)
RETURN COUNT(relation) > 0 as isShouted`, RETURN COUNT(relation) > 0 as isShouted`,
{ {
id, id,
type, type,
userId: context.user.id, userId: context.user.id,
}, },
) )
const [isShouted] = transactionRes.records.map(record => { const [isShouted] = transactionRes.records.map(record => {
return record.get('isShouted') return record.get('isShouted')
}) })
session.close() return isShouted
} finally {
return isShouted session.close()
}
}, },
unshout: async (_object, params, context, _resolveInfo) => { unshout: async (_object, params, context, _resolveInfo) => {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
try {
const transactionRes = await session.run( const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id}) `MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node) WHERE $type IN labels(node)
DELETE relation DELETE relation
RETURN COUNT(relation) > 0 as isShouted`, RETURN COUNT(relation) > 0 as isShouted`,
{ {
id, id,
type, type,
userId: context.user.id, userId: context.user.id,
}, },
) )
const [isShouted] = transactionRes.records.map(record => { const [isShouted] = transactionRes.records.map(record => {
return record.get('isShouted') return record.get('isShouted')
}) })
session.close() return isShouted
} finally {
return isShouted session.close()
}
}, },
}, },
} }

View File

@ -1,6 +1,6 @@
export default { export default {
Query: { Query: {
statistics: async (parent, args, { driver, user }) => { statistics: async (_parent, _args, { driver }) => {
const session = driver.session() const session = driver.session()
const response = {} const response = {}
try { try {
@ -33,10 +33,10 @@ export default {
* Note: invites count is calculated this way because invitation codes are not in use yet * Note: invites count is calculated this way because invitation codes are not in use yet
*/ */
response.countInvites = response.countEmails - response.countUsers response.countInvites = response.countEmails - response.countUsers
return response
} finally { } finally {
session.close() session.close()
} }
return response
}, },
}, },
} }

View File

@ -0,0 +1,140 @@
import { createTestClient } from 'apollo-server-testing'
import Factory from '../../seed/factories'
import { gql } from '../../helpers/jest'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let query, authenticatedUser
const factory = Factory()
const instance = getNeode()
const driver = getDriver()
const statisticsQuery = gql`
query {
statistics {
countUsers
countPosts
countComments
countNotifications
countInvites
countFollows
countShouts
}
}
`
beforeAll(() => {
authenticatedUser = undefined
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('statistics', () => {
describe('countUsers', () => {
beforeEach(async () => {
await Promise.all(
[...Array(6).keys()].map(() => {
return factory.create('User')
}),
)
})
it('returns the count of all users', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countUsers: 6 } },
errors: undefined,
})
})
})
describe('countPosts', () => {
beforeEach(async () => {
await Promise.all(
[...Array(3).keys()].map(() => {
return factory.create('Post')
}),
)
})
it('returns the count of all posts', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countPosts: 3 } },
errors: undefined,
})
})
})
describe('countComments', () => {
beforeEach(async () => {
await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('Comment')
}),
)
})
it('returns the count of all comments', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countComments: 2 } },
errors: undefined,
})
})
})
describe('countFollows', () => {
let users
beforeEach(async () => {
users = await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('User')
}),
)
await users[0].relateTo(users[1], 'following')
})
it('returns the count of all follows', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countFollows: 1 } },
errors: undefined,
})
})
})
describe('countShouts', () => {
let users, posts
beforeEach(async () => {
users = await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('User')
}),
)
posts = await Promise.all(
[...Array(3).keys()].map(() => {
return factory.create('Post')
}),
)
await Promise.all([
users[0].relateTo(posts[1], 'shouted'),
users[1].relateTo(posts[0], 'shouted'),
])
})
it('returns the count of all shouts', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countShouts: 2 } },
errors: undefined,
})
})
})
})

View File

@ -2,7 +2,7 @@ import encode from '../../jwt/encode'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server' import { AuthenticationError } from 'apollo-server'
import { neode } from '../../bootstrap/neo4j' import { neode } from '../../bootstrap/neo4j'
import { normalizeEmail } from 'validator' import normalizeEmail from './helpers/normalizeEmail'
const instance = neode() const instance = neode()
@ -24,29 +24,32 @@ export default {
// } // }
email = normalizeEmail(email) email = normalizeEmail(email)
const session = driver.session() const session = driver.session()
const result = await session.run( try {
` const result = await session.run(
`
MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail}) MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})
RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1
`, `,
{ userEmail: email }, { userEmail: email },
) )
session.close() const [currentUser] = await result.records.map(record => {
const [currentUser] = await result.records.map(record => { return record.get('user')
return record.get('user') })
})
if ( if (
currentUser && currentUser &&
(await bcrypt.compareSync(password, currentUser.encryptedPassword)) && (await bcrypt.compareSync(password, currentUser.encryptedPassword)) &&
!currentUser.disabled !currentUser.disabled
) { ) {
delete currentUser.encryptedPassword delete currentUser.encryptedPassword
return encode(currentUser) return encode(currentUser)
} else if (currentUser && currentUser.disabled) { } else if (currentUser && currentUser.disabled) {
throw new AuthenticationError('Your account has been disabled.') throw new AuthenticationError('Your account has been disabled.')
} else { } else {
throw new AuthenticationError('Incorrect email address or password.') throw new AuthenticationError('Incorrect email address or password.')
}
} finally {
session.close()
} }
}, },
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {

View File

@ -9,25 +9,25 @@ import { neode as getNeode } from '../../bootstrap/neo4j'
const factory = Factory() const factory = Factory()
const neode = getNeode() const neode = getNeode()
let query let query, mutate, variables, req, user
let mutate
let variables
let req
let user
const disable = async id => { const disable = async id => {
await factory.create('User', { id: 'u2', role: 'moderator' }) const moderator = await factory.create('User', { id: 'u2', role: 'moderator' })
const moderatorBearerToken = encode({ id: 'u2' }) const user = await neode.find('User', id)
req = { headers: { authorization: `Bearer ${moderatorBearerToken}` } } const reportAgainstUser = await factory.create('Report')
await mutate({ await Promise.all([
mutation: gql` reportAgainstUser.relateTo(moderator, 'filed', {
mutation($id: ID!) { resourceId: id,
disable(id: $id) reasonCategory: 'discrimination_etc',
} reasonDescription: 'This user is harassing me with bigoted remarks!',
`, }),
variables: { id }, reportAgainstUser.relateTo(user, 'belongsTo'),
}) ])
req = { headers: {} } const disableVariables = { resourceId: user.id, disable: true, closed: false }
await Promise.all([
reportAgainstUser.relateTo(moderator, 'reviewed', disableVariables),
user.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
} }
beforeEach(() => { beforeEach(() => {

View File

@ -212,7 +212,6 @@ export default {
}, },
hasOne: { hasOne: {
invitedBy: '<-[:INVITED]-(related:User)', invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
location: '-[:IS_IN]->(related:Location)', location: '-[:IS_IN]->(related:Location)',
}, },
hasMany: { hasMany: {

View File

@ -24,8 +24,6 @@ type Mutation {
changePassword(oldPassword: String!, newPassword: String!): String! changePassword(oldPassword: String!, newPassword: String!): String!
requestPasswordReset(email: String!): Boolean! requestPasswordReset(email: String!): Boolean!
resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean! resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean!
disable(id: ID!): ID
enable(id: ID!): ID
# Shout the given Type and ID # Shout the given Type and ID
shout(id: ID!, type: ShoutTypeEnum): Boolean! shout(id: ID!, type: ShoutTypeEnum): Boolean!
# Unshout the given Type and ID # Unshout the given Type and ID

View File

@ -47,7 +47,6 @@ type Comment {
updatedAt: String updatedAt: String
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
} }
type Query { type Query {

View File

@ -0,0 +1,23 @@
type FILED {
createdAt: String!
reasonCategory: ReasonCategory!
reasonDescription: String!
submitter: User
}
# this list equals the strings of an array in file "webapp/constants/modals.js"
enum ReasonCategory {
other
discrimination_etc
pornographic_content_links
glorific_trivia_of_cruel_inhuman_acts
doxing
intentional_intimidation_stalking_persecution
advert_products_services_commercial
criminal_behavior_violation_german_law
}
enum ReportOrdering {
createdAt_asc
createdAt_desc
}

View File

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

View File

@ -26,7 +26,7 @@ enum NotificationReason {
type Query { type Query {
notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED] notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED]
} }
type Mutation { type Mutation {
markAsRead(id: ID!): NOTIFIED markAsRead(id: ID!): NOTIFIED
} }

View File

@ -114,7 +114,7 @@ type Post {
objectId: String objectId: String
author: User @relation(name: "WROTE", direction: "IN") author: User @relation(name: "WROTE", direction: "IN")
title: String! title: String!
slug: String slug: String!
content: String! content: String!
contentExcerpt: String contentExcerpt: String
image: String image: String
@ -124,7 +124,6 @@ type Post {
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean
pinned: Boolean pinned: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
createdAt: String createdAt: String
updatedAt: String updatedAt: String
language: String language: String

View File

@ -1,43 +0,0 @@
type REPORTED {
createdAt: String
reasonCategory: ReasonCategory
reasonDescription: String
submitter: User
@cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN user")
# not yet supported
# resource: ReportResource
# @cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN resource")
type: String
@cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN labels(resource)[0]")
user: User
post: Post
comment: Comment
}
# this list equals the strings of an array in file "webapp/constants/modals.js"
enum ReasonCategory {
other
discrimination_etc
pornographic_content_links
glorific_trivia_of_cruel_inhuman_acts
doxing
intentional_intimidation_stalking_persecution
advert_products_services_commercial
criminal_behavior_violation_german_law
}
# not yet supported
# union ReportResource = User | Post | Comment
enum ReportOrdering {
createdAt_asc
createdAt_desc
}
type Query {
reports(orderBy: ReportOrdering): [REPORTED]
}
type Mutation {
report(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): REPORTED
}

View File

@ -0,0 +1,15 @@
type REVIEWED {
createdAt: String!
updatedAt: String!
disable: Boolean!
closed: Boolean!
report: Report
# @cypher(statement: "MATCH (report:Report)<-[this:REVIEWED]-(:User) RETURN report")
moderator: User
resource: ReviewedResource
}
union ReviewedResource = User | Post | Comment
type Mutation {
review(resourceId: ID!, disable: Boolean, closed: Boolean): REVIEWED
}

View File

@ -0,0 +1,25 @@
type Report {
id: ID!
createdAt: String!
updatedAt: String!
rule: ReportRule!
disable: Boolean!
closed: Boolean!
filed: [FILED]
reviewed: [REVIEWED]
resource: ReportedResource
}
union ReportedResource = User | Post | Comment
enum ReportRule {
latestReviewUpdatedAtRules
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): Report
}
type Query {
reports(orderBy: ReportOrdering): [Report]
}

View File

@ -33,7 +33,6 @@ type User {
coverImg: String coverImg: String
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
role: UserGroup! role: UserGroup!
publicKey: String publicKey: String
invitedBy: User @relation(name: "INVITED", direction: "IN") invitedBy: User @relation(name: "INVITED", direction: "IN")
@ -44,8 +43,6 @@ type User {
about: String about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN")
# createdAt: DateTime
# updatedAt: DateTime
createdAt: String createdAt: String
updatedAt: String updatedAt: String

View File

@ -10,6 +10,7 @@ import createLocation from './locations.js'
import createEmailAddress from './emailAddresses.js' import createEmailAddress from './emailAddresses.js'
import createDonations from './donations.js' import createDonations from './donations.js'
import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js'
import createReport from './reports.js'
const factories = { const factories = {
Badge: createBadge, Badge: createBadge,
@ -23,12 +24,13 @@ const factories = {
EmailAddress: createEmailAddress, EmailAddress: createEmailAddress,
UnverifiedEmailAddress: createUnverifiedEmailAddresss, UnverifiedEmailAddress: createUnverifiedEmailAddresss,
Donations: createDonations, Donations: createDonations,
Report: createReport,
} }
export const cleanDatabase = async (options = {}) => { export const cleanDatabase = async (options = {}) => {
const { driver = getDriver() } = options const { driver = getDriver() } = options
const session = driver.session()
const cypher = 'MATCH (n) DETACH DELETE n' const cypher = 'MATCH (n) DETACH DELETE n'
const session = driver.session()
try { try {
return await session.run(cypher) return await session.run(cypher)
} finally { } finally {

View File

@ -0,0 +1,7 @@
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
return neodeInstance.create('Report', args)
},
}
}

View File

@ -545,7 +545,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
]) ])
authenticatedUser = null authenticatedUser = null
await Promise.all([ const comments = await Promise.all([
factory.create('Comment', { factory.create('Comment', {
author: jennyRostock, author: jennyRostock,
id: 'c1', id: 'c1',
@ -562,7 +562,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
postId: 'p3', postId: 'p3',
}), }),
factory.create('Comment', { factory.create('Comment', {
author: bobDerBaumeister, author: jennyRostock,
id: 'c5', id: 'c5',
postId: 'p3', postId: 'p3',
}), }),
@ -602,6 +602,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
postId: 'p15', postId: 'p15',
}), }),
]) ])
const trollingComment = comments[0]
await Promise.all([ await Promise.all([
democracy.relateTo(p3, 'post'), democracy.relateTo(p3, 'post'),
@ -665,68 +666,107 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
louie.relateTo(p10, 'shouted'), louie.relateTo(p10, 'shouted'),
]) ])
const disableMutation = gql` const reports = await Promise.all([
mutation($id: ID!) { factory.create('Report'),
disable(id: $id) factory.create('Report'),
} factory.create('Report'),
`
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: disableMutation,
variables: {
id: 'p11',
},
}),
mutate({
mutation: disableMutation,
variables: {
id: 'c5',
},
}),
]) ])
authenticatedUser = null const reportAgainstDagobert = reports[0]
const reportAgainstTrollingPost = reports[1]
const reportAgainstTrollingComment = reports[2]
// There is no error logged or the 'try' fails if this mutation is wrong. Why? // report resource first time
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
type
}
}
`
authenticatedUser = await huey.toJson()
await Promise.all([ await Promise.all([
mutate({ reportAgainstDagobert.relateTo(jennyRostock, 'filed', {
mutation: reportMutation, resourceId: 'u7',
variables: { reasonCategory: 'discrimination_etc',
resourceId: 'c1', reasonDescription: 'This user is harassing me with bigoted remarks!',
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
},
}), }),
mutate({ reportAgainstDagobert.relateTo(dagobert, 'belongsTo'),
mutation: reportMutation, reportAgainstTrollingPost.relateTo(jennyRostock, 'filed', {
variables: { resourceId: 'p2',
resourceId: 'p1', reasonCategory: 'doxing',
reasonCategory: 'discrimination_etc', reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!",
reasonDescription: 'This post is bigoted',
},
}), }),
mutate({ reportAgainstTrollingPost.relateTo(p2, 'belongsTo'),
mutation: reportMutation, reportAgainstTrollingComment.relateTo(huey, 'filed', {
variables: { resourceId: 'c1',
resourceId: 'u1', reasonCategory: 'other',
reasonCategory: 'doxing', reasonDescription: 'This comment is bigoted',
reasonDescription: 'This user is harassing me with bigoted remarks',
},
}), }),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
// report resource a second time
await Promise.all([
reportAgainstDagobert.relateTo(louie, 'filed', {
resourceId: 'u7',
reasonCategory: 'discrimination_etc',
reasonDescription: 'this user is attacking me for who I am!',
}),
reportAgainstDagobert.relateTo(dagobert, 'belongsTo'),
reportAgainstTrollingPost.relateTo(peterLustig, 'filed', {
resourceId: 'p2',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
}),
reportAgainstTrollingPost.relateTo(p2, 'belongsTo'),
reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'filed', {
resourceId: 'c1',
reasonCategory: 'pornographic_content_links',
reasonDescription: 'This comment is porno!!!',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
const disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
// review resource first time
await Promise.all([
reportAgainstDagobert.relateTo(bobDerBaumeister, 'reviewed', {
...disableVariables,
resourceId: 'u7',
}),
dagobert.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingPost.relateTo(peterLustig, 'reviewed', {
...disableVariables,
resourceId: 'p2',
}),
p2.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'reviewed', {
...disableVariables,
resourceId: 'c1',
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
// second review of resource and close report
await Promise.all([
reportAgainstDagobert.relateTo(peterLustig, 'reviewed', {
resourceId: 'u7',
disable: false,
closed: true,
}),
dagobert.update({ disabled: false, updatedAt: new Date().toISOString(), closed: true }),
reportAgainstTrollingPost.relateTo(bobDerBaumeister, 'reviewed', {
resourceId: 'p2',
disable: true,
closed: true,
}),
p2.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }),
reportAgainstTrollingComment.relateTo(peterLustig, 'reviewed', {
...disableVariables,
resourceId: 'c1',
disable: true,
closed: true,
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }),
]) ])
authenticatedUser = null
await Promise.all( await Promise.all(
[...Array(30).keys()].map(i => { [...Array(30).keys()].map(i => {

View File

@ -6,6 +6,7 @@ import middleware from './middleware'
import { neode as getNeode, getDriver } from './bootstrap/neo4j' import { neode as getNeode, getDriver } from './bootstrap/neo4j'
import decode from './jwt/decode' import decode from './jwt/decode'
import schema from './schema' import schema from './schema'
import webfinger from './activitypub/routes/webfinger'
// check required configs and throw error // check required configs and throw error
// TODO check this directly in config file - currently not possible due to testsetup // TODO check this directly in config file - currently not possible due to testsetup
@ -41,7 +42,10 @@ const createServer = options => {
const server = new ApolloServer(Object.assign({}, defaults, options)) const server = new ApolloServer(Object.assign({}, defaults, options))
const app = express() const app = express()
app.set('driver', driver)
app.use(helmet()) app.use(helmet())
app.use('/.well-known/', webfinger())
app.use(express.static('public')) app.use(express.static('public'))
server.applyMiddleware({ app, path: '/' }) server.applyMiddleware({ app, path: '/' })

View File

@ -9,32 +9,6 @@ Feature: Webfinger discovery
| Slug | | Slug |
| peter-lustiger | | peter-lustiger |
Scenario: Search
When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost"
Then I receive the following json:
"""
{
"subject": "acct:peter-lustiger@localhost:4123",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "http://localhost:4123/activitypub/users/peter-lustiger"
}
]
}
"""
And I expect the Content-Type to be "application/jrd+json; charset=utf-8"
Scenario: User does not exist
When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost"
Then I receive the following json:
"""
{
"error": "No record found for nonexisting@localhost."
}
"""
Scenario: Receiving an actor object Scenario: Receiving an actor object
When I send a GET request to "/activitypub/users/peter-lustiger" When I send a GET request to "/activitypub/users/peter-lustiger"
Then I receive the following json: Then I receive the following json:

View File

@ -33,12 +33,12 @@
resolved "https://registry.yarnpkg.com/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.24.tgz#3ce939cb127fb8aaa3ffc1e90dff9b8af9f2e3dc" resolved "https://registry.yarnpkg.com/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.24.tgz#3ce939cb127fb8aaa3ffc1e90dff9b8af9f2e3dc"
integrity sha512-8GqG48m1XqyXh4mIZrtB5xOhUwSsh1WsrrsaZQOEYYql3YN9DEu9OOSg0ILzXHZo/h2Q74777YE4YzlArQzQEQ== integrity sha512-8GqG48m1XqyXh4mIZrtB5xOhUwSsh1WsrrsaZQOEYYql3YN9DEu9OOSg0ILzXHZo/h2Q74777YE4YzlArQzQEQ==
"@babel/cli@~7.7.0": "@babel/cli@~7.7.4":
version "7.7.0" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.7.0.tgz#8d10c9acb2acb362d7614a9493e1791c69100d89" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.7.4.tgz#38804334c8db40209f88c69a5c90998e60cca18b"
integrity sha512-jECEqAq6Ngf3pOhLSg7od9WKyrIacyh1oNNYtRXNn+ummSHCTXBamGywOAtiae34Vk7zKuQNnLvo2BKTMCoV4A== integrity sha512-O7mmzaWdm+VabWQmxuM8hqNrWGGihN83KfhPUzp2lAW4kzIMwBxujXkZbD4fMwKMYY9FXTbDvXsJqU+5XHXi4A==
dependencies: dependencies:
commander "^2.8.1" commander "^4.0.1"
convert-source-map "^1.1.0" convert-source-map "^1.1.0"
fs-readdir-recursive "^1.1.0" fs-readdir-recursive "^1.1.0"
glob "^7.0.0" glob "^7.0.0"
@ -56,18 +56,18 @@
dependencies: dependencies:
"@babel/highlight" "^7.0.0" "@babel/highlight" "^7.0.0"
"@babel/core@^7.1.0", "@babel/core@~7.7.2": "@babel/core@^7.1.0", "@babel/core@~7.7.4":
version "7.7.2" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.7.2.tgz#ea5b99693bcfc058116f42fa1dd54da412b29d91" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.7.4.tgz#37e864532200cb6b50ee9a4045f5f817840166ab"
integrity sha512-eeD7VEZKfhK1KUXGiyPFettgF3m513f8FoBSWiQ1xTvl1RAopLs42Wp9+Ze911I6H0N9lNqJMDgoZT7gHsipeQ== integrity sha512-+bYbx56j4nYBmpsWtnPUsKW3NdnYxbqyfrP2w9wILBuHzdfIKz9prieZK0DFPyIzkjYVUe4QkusGL07r5pXznQ==
dependencies: dependencies:
"@babel/code-frame" "^7.5.5" "@babel/code-frame" "^7.5.5"
"@babel/generator" "^7.7.2" "@babel/generator" "^7.7.4"
"@babel/helpers" "^7.7.0" "@babel/helpers" "^7.7.4"
"@babel/parser" "^7.7.2" "@babel/parser" "^7.7.4"
"@babel/template" "^7.7.0" "@babel/template" "^7.7.4"
"@babel/traverse" "^7.7.2" "@babel/traverse" "^7.7.4"
"@babel/types" "^7.7.2" "@babel/types" "^7.7.4"
convert-source-map "^1.7.0" convert-source-map "^1.7.0"
debug "^4.1.0" debug "^4.1.0"
json5 "^2.1.0" json5 "^2.1.0"
@ -76,17 +76,7 @@
semver "^5.4.1" semver "^5.4.1"
source-map "^0.5.0" source-map "^0.5.0"
"@babel/generator@^7.4.0", "@babel/generator@^7.7.2": "@babel/generator@^7.4.0", "@babel/generator@^7.7.4":
version "7.7.2"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.7.2.tgz#2f4852d04131a5e17ea4f6645488b5da66ebf3af"
integrity sha512-WthSArvAjYLz4TcbKOi88me+KmDJdKSlfwwN8CnUYn9jBkzhq0ZEPuBfkAWIvjJ3AdEV1Cf/+eSQTnp3IDJKlQ==
dependencies:
"@babel/types" "^7.7.2"
jsesc "^2.5.1"
lodash "^4.17.13"
source-map "^0.5.0"
"@babel/generator@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.7.4.tgz#db651e2840ca9aa66f327dcec1dc5f5fa9611369" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.7.4.tgz#db651e2840ca9aa66f327dcec1dc5f5fa9611369"
integrity sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg== integrity sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==
@ -145,15 +135,6 @@
"@babel/traverse" "^7.7.4" "@babel/traverse" "^7.7.4"
"@babel/types" "^7.7.4" "@babel/types" "^7.7.4"
"@babel/helper-function-name@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.7.0.tgz#44a5ad151cfff8ed2599c91682dda2ec2c8430a3"
integrity sha512-tDsJgMUAP00Ugv8O2aGEua5I2apkaQO7lBGUq1ocwN3G23JE5Dcq0uh3GvFTChPa4b40AWiAsLvCZOA2rdnQ7Q==
dependencies:
"@babel/helper-get-function-arity" "^7.7.0"
"@babel/template" "^7.7.0"
"@babel/types" "^7.7.0"
"@babel/helper-function-name@^7.7.4": "@babel/helper-function-name@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz#ab6e041e7135d436d8f0a3eca15de5b67a341a2e" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz#ab6e041e7135d436d8f0a3eca15de5b67a341a2e"
@ -163,13 +144,6 @@
"@babel/template" "^7.7.4" "@babel/template" "^7.7.4"
"@babel/types" "^7.7.4" "@babel/types" "^7.7.4"
"@babel/helper-get-function-arity@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.0.tgz#c604886bc97287a1d1398092bc666bc3d7d7aa2d"
integrity sha512-tLdojOTz4vWcEnHWHCuPN5P85JLZWbm5Fx5ZsMEMPhF3Uoe3O7awrbM2nQ04bDOUToH/2tH/ezKEOR8zEYzqyw==
dependencies:
"@babel/types" "^7.7.0"
"@babel/helper-get-function-arity@^7.7.4": "@babel/helper-get-function-arity@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz#cb46348d2f8808e632f0ab048172130e636005f0" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz#cb46348d2f8808e632f0ab048172130e636005f0"
@ -258,13 +232,6 @@
"@babel/template" "^7.7.4" "@babel/template" "^7.7.4"
"@babel/types" "^7.7.4" "@babel/types" "^7.7.4"
"@babel/helper-split-export-declaration@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.0.tgz#1365e74ea6c614deeb56ebffabd71006a0eb2300"
integrity sha512-HgYSI8rH08neWlAH3CcdkFg9qX9YsZysZI5GD8LjhQib/mM0jGOZOVkoUiiV2Hu978fRtjtsGsW6w0pKHUWtqA==
dependencies:
"@babel/types" "^7.7.0"
"@babel/helper-split-export-declaration@^7.7.4": "@babel/helper-split-export-declaration@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz#57292af60443c4a3622cf74040ddc28e68336fd8" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz#57292af60443c4a3622cf74040ddc28e68336fd8"
@ -282,14 +249,14 @@
"@babel/traverse" "^7.7.4" "@babel/traverse" "^7.7.4"
"@babel/types" "^7.7.4" "@babel/types" "^7.7.4"
"@babel/helpers@^7.7.0": "@babel/helpers@^7.7.4":
version "7.7.0" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.7.0.tgz#359bb5ac3b4726f7c1fde0ec75f64b3f4275d60b" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.7.4.tgz#62c215b9e6c712dadc15a9a0dcab76c92a940302"
integrity sha512-VnNwL4YOhbejHb7x/b5F39Zdg5vIQpUUNzJwx0ww1EcVRt41bbGRZWhAURrfY32T5zTT3qwNOQFWpn+P0i0a2g== integrity sha512-ak5NGZGJ6LV85Q1Zc9gn2n+ayXOizryhjSUBTdu5ih1tlVCJeuQENzc4ItyCVhINVXvIT/ZQ4mheGIsfBkpskg==
dependencies: dependencies:
"@babel/template" "^7.7.0" "@babel/template" "^7.7.4"
"@babel/traverse" "^7.7.0" "@babel/traverse" "^7.7.4"
"@babel/types" "^7.7.0" "@babel/types" "^7.7.4"
"@babel/highlight@^7.0.0": "@babel/highlight@^7.0.0":
version "7.5.0" version "7.5.0"
@ -313,12 +280,7 @@
regenerator-runtime "^0.13.3" regenerator-runtime "^0.13.3"
v8flags "^3.1.1" v8flags "^3.1.1"
"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.7.0", "@babel/parser@^7.7.2": "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.7.4":
version "7.7.2"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.7.2.tgz#ea8334dc77416bfd9473eb470fd00d8245b3943b"
integrity sha512-DDaR5e0g4ZTb9aP7cpSZLkACEBdoLGwJDWgHtBhrGX7Q1RjhdoMOfexICj5cqTAtpowjGQWfcvfnQG7G2kAB5w==
"@babel/parser@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.7.4.tgz#75ab2d7110c2cf2fa949959afb05fa346d2231bb" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.7.4.tgz#75ab2d7110c2cf2fa949959afb05fa346d2231bb"
integrity sha512-jIwvLO0zCL+O/LmEJQjWA75MQTWwx3c3u2JOTDK5D3/9egrWRRA0/0hk9XXywYnXZVVpzrBYeIQTmhwUaePI9g== integrity sha512-jIwvLO0zCL+O/LmEJQjWA75MQTWwx3c3u2JOTDK5D3/9egrWRRA0/0hk9XXywYnXZVVpzrBYeIQTmhwUaePI9g==
@ -364,13 +326,13 @@
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-optional-catch-binding" "^7.7.4" "@babel/plugin-syntax-optional-catch-binding" "^7.7.4"
"@babel/plugin-proposal-throw-expressions@^7.2.0": "@babel/plugin-proposal-throw-expressions@^7.7.4":
version "7.2.0" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-throw-expressions/-/plugin-proposal-throw-expressions-7.2.0.tgz#2d9e452d370f139000e51db65d0a85dc60c64739" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-throw-expressions/-/plugin-proposal-throw-expressions-7.7.4.tgz#0321bd4acb699abef3006f7cd3d1b2c00daf1b82"
integrity sha512-adsydM8DQF4i5DLNO4ySAU5VtHTPewOtNBV3u7F4lNMPADFF9bWQ+iDtUUe8+033cYCUz+bFlQdXQJmJOwoLpw== integrity sha512-yMcK1dM9Rv+Y5n62rKaHfRoRD4eOWIqYn4uy/Xu7C47rJKaR5JpQR905Hc/OL8EEaGNcEyuvjOtYdNAVXZKDZQ==
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-throw-expressions" "^7.2.0" "@babel/plugin-syntax-throw-expressions" "^7.7.4"
"@babel/plugin-proposal-unicode-property-regex@^7.7.4": "@babel/plugin-proposal-unicode-property-regex@^7.7.4":
version "7.7.4" version "7.7.4"
@ -415,10 +377,10 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-throw-expressions@^7.2.0": "@babel/plugin-syntax-throw-expressions@^7.7.4":
version "7.2.0" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-throw-expressions/-/plugin-syntax-throw-expressions-7.2.0.tgz#79001ee2afe1b174b1733cdc2fc69c9a46a0f1f8" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-throw-expressions/-/plugin-syntax-throw-expressions-7.7.4.tgz#2e1e91485f9a35f1f71547717ccd8407a900092c"
integrity sha512-ngwynuqu1Rx0JUS9zxSDuPgW1K8TyVZCi2hHehrL4vyjqE7RGoNHWlZsS7KQT2vw9Yjk4YLa0+KldBXTRdPLRg== integrity sha512-qtLTzzOJ1Co6tQ8pMqnsfRgeUfzExP90Tc6GISTC34O0lR6IDvymWLfVJFcJaQinz6reciQ4auALV3JM+b3D/Q==
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
@ -756,16 +718,7 @@
dependencies: dependencies:
regenerator-runtime "^0.13.2" regenerator-runtime "^0.13.2"
"@babel/template@^7.4.0", "@babel/template@^7.7.0": "@babel/template@^7.4.0", "@babel/template@^7.7.4":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.0.tgz#4fadc1b8e734d97f56de39c77de76f2562e597d0"
integrity sha512-OKcwSYOW1mhWbnTBgQY5lvg1Fxg+VyfQGjcBduZFljfc044J5iDlnDSfhQ867O17XHiSCxYHUxHg2b7ryitbUQ==
dependencies:
"@babel/code-frame" "^7.0.0"
"@babel/parser" "^7.7.0"
"@babel/types" "^7.7.0"
"@babel/template@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.4.tgz#428a7d9eecffe27deac0a98e23bf8e3675d2a77b" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.4.tgz#428a7d9eecffe27deac0a98e23bf8e3675d2a77b"
integrity sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw== integrity sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==
@ -774,22 +727,7 @@
"@babel/parser" "^7.7.4" "@babel/parser" "^7.7.4"
"@babel/types" "^7.7.4" "@babel/types" "^7.7.4"
"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.7.4":
version "7.7.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.7.2.tgz#ef0a65e07a2f3c550967366b3d9b62a2dcbeae09"
integrity sha512-TM01cXib2+rgIZrGJOLaHV/iZUAxf4A0dt5auY6KNZ+cm6aschuJGqKJM3ROTt3raPUdIDk9siAufIFEleRwtw==
dependencies:
"@babel/code-frame" "^7.5.5"
"@babel/generator" "^7.7.2"
"@babel/helper-function-name" "^7.7.0"
"@babel/helper-split-export-declaration" "^7.7.0"
"@babel/parser" "^7.7.2"
"@babel/types" "^7.7.2"
debug "^4.1.0"
globals "^11.1.0"
lodash "^4.17.13"
"@babel/traverse@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.7.4.tgz#9c1e7c60fb679fe4fcfaa42500833333c2058558" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.7.4.tgz#9c1e7c60fb679fe4fcfaa42500833333c2058558"
integrity sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw== integrity sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==
@ -804,7 +742,7 @@
globals "^11.1.0" globals "^11.1.0"
lodash "^4.17.13" lodash "^4.17.13"
"@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.7.0", "@babel/types@^7.7.2", "@babel/types@^7.7.4": "@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.7.4.tgz#516570d539e44ddf308c07569c258ff94fde9193" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.7.4.tgz#516570d539e44ddf308c07569c258ff94fde9193"
integrity sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA== integrity sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==
@ -879,10 +817,10 @@
"@hapi/hoek" "8.x.x" "@hapi/hoek" "8.x.x"
"@hapi/topo" "3.x.x" "@hapi/topo" "3.x.x"
"@hapi/joi@^16.1.7": "@hapi/joi@^16.1.8":
version "16.1.7" version "16.1.8"
resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-16.1.7.tgz#360857223a87bb1f5f67691537964c1b4908ed93" resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-16.1.8.tgz#84c1f126269489871ad4e2decc786e0adef06839"
integrity sha512-anaIgnZhNooG3LJLrTFzgGALTiO97zRA1UkvQHm9KxxoSiIzCozB3RCNCpDnfhTJD72QlrHA8nwGmNgpFFCIeg== integrity sha512-wAsVvTPe+FwSrsAurNt5vkg3zo+TblvC5Bb1zMVK6SJzZqw9UrJnexxR+76cpePmtUZKHAPxcQ2Bf7oVHyahhg==
dependencies: dependencies:
"@hapi/address" "^2.1.2" "@hapi/address" "^2.1.2"
"@hapi/formula" "^1.2.0" "@hapi/formula" "^1.2.0"
@ -1453,10 +1391,10 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@types/yargs-parser" "*"
"@types/yup@0.26.24": "@types/yup@0.26.26":
version "0.26.24" version "0.26.26"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.24.tgz#c24170b3a5c867b4fabd49fcc42fe45f780cb153" resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.26.tgz#2e7065384ac2b7711271d8790f26ac7d73f6a33d"
integrity sha512-x0bhHnYjH5mZit4HivUYbTMO4LouOTGwp/LLxSL1mbJYVwNJtHYESH0ed2bwM1lkI2yDmsoCDYJnWEgHeJDACg== integrity sha512-5cLJLd8NIT7OfJLi7ScquRn/NWfmewBqJ92nT/FYfdpgKzyUNcR4n2BKEOQ7mOG8WuJXgomIvNl5P3sn9Akd4A==
"@types/zen-observable@^0.8.0": "@types/zen-observable@^0.8.0":
version "0.8.0" version "0.8.0"
@ -1693,13 +1631,6 @@ apollo-datasource@^0.6.3:
apollo-server-caching "^0.5.0" apollo-server-caching "^0.5.0"
apollo-server-env "^2.4.3" apollo-server-env "^2.4.3"
apollo-engine-reporting-protobuf@^0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.3.tgz#c8cf52aa799a2d8fc92bd59c5942bb2989d225aa"
integrity sha512-ikRiu2PJOOYoTfJ1gAPB58tmVwu8IF71J9u1jWJJ7tLO+RvGOvntA4kwImiKbHrc/zsAx12lPnpjjfn7tOF1AQ==
dependencies:
protobufjs "^6.8.6"
apollo-engine-reporting-protobuf@^0.4.4: apollo-engine-reporting-protobuf@^0.4.4:
version "0.4.4" version "0.4.4"
resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.4.tgz#73a064f8c9f2d6605192d1673729c66ec47d9cb7" resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.4.tgz#73a064f8c9f2d6605192d1673729c66ec47d9cb7"
@ -1788,7 +1719,7 @@ apollo-server-caching@^0.5.0:
dependencies: dependencies:
lru-cache "^5.0.0" lru-cache "^5.0.0"
apollo-server-core@^2.9.11, apollo-server-core@^2.9.12: apollo-server-core@^2.9.12:
version "2.9.12" version "2.9.12"
resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.12.tgz#c8ed48540762913242eef5fce0da8b59b131a1e8" resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.12.tgz#c8ed48540762913242eef5fce0da8b59b131a1e8"
integrity sha512-jhGr2R655PSwUUBweXDl+0F3oa74Elu5xXF+88ymUUej34EwBUCqz97wPqR07BEuyxaAlRfZwPMvKaHhMUKg5g== integrity sha512-jhGr2R655PSwUUBweXDl+0F3oa74Elu5xXF+88ymUUej34EwBUCqz97wPqR07BEuyxaAlRfZwPMvKaHhMUKg5g==
@ -1828,10 +1759,10 @@ apollo-server-errors@^2.3.4:
resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.4.tgz#b70ef01322f616cbcd876f3e0168a1a86b82db34" resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.4.tgz#b70ef01322f616cbcd876f3e0168a1a86b82db34"
integrity sha512-Y0PKQvkrb2Kd18d1NPlHdSqmlr8TgqJ7JQcNIfhNDgdb45CnqZlxL1abuIRhr8tiw8OhVOcFxz2KyglBi8TKdA== integrity sha512-Y0PKQvkrb2Kd18d1NPlHdSqmlr8TgqJ7JQcNIfhNDgdb45CnqZlxL1abuIRhr8tiw8OhVOcFxz2KyglBi8TKdA==
apollo-server-express@^2.9.11, apollo-server-express@^2.9.7: apollo-server-express@^2.9.12, apollo-server-express@^2.9.7:
version "2.9.11" version "2.9.12"
resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.11.tgz#ae1b6161a563bc6aa0dd7456deec998236a4234a" resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.12.tgz#e779ea2c107fcc63b0c9b888a4cbf0f65af6d505"
integrity sha512-38ZaPnahJPnibVii6i983Uu32iGR2xO5kUZvGxqH4lSPiy8V9Ph93TxeTmIlzHUqd8z4CguZsCV+ngd5KT7lFQ== integrity sha512-4Ev8MY7m23mSzwO/BvLTy97a/68IP/wZoCRBn2R81OoZt9/GxlvvYZGvozJCXYsQt1qAbIT4Sn05LmqawsI98w==
dependencies: dependencies:
"@apollographql/graphql-playground-html" "1.6.24" "@apollographql/graphql-playground-html" "1.6.24"
"@types/accepts" "^1.3.5" "@types/accepts" "^1.3.5"
@ -1839,8 +1770,8 @@ apollo-server-express@^2.9.11, apollo-server-express@^2.9.7:
"@types/cors" "^2.8.4" "@types/cors" "^2.8.4"
"@types/express" "4.17.1" "@types/express" "4.17.1"
accepts "^1.3.5" accepts "^1.3.5"
apollo-server-core "^2.9.11" apollo-server-core "^2.9.12"
apollo-server-types "^0.2.7" apollo-server-types "^0.2.8"
body-parser "^1.18.3" body-parser "^1.18.3"
cors "^2.8.4" cors "^2.8.4"
express "^4.17.1" express "^4.17.1"
@ -1864,15 +1795,6 @@ apollo-server-testing@~2.9.12:
dependencies: dependencies:
apollo-server-core "^2.9.12" apollo-server-core "^2.9.12"
apollo-server-types@^0.2.7:
version "0.2.7"
resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.7.tgz#832760017a9d55beda23bc7ba8af357802ee9b49"
integrity sha512-umbXyo7DVG6/lZIAL1D666e16+gZM/Y/kfPx2nn9KTSamIJHUDLs2p0es78RuUx2VS8XoKZDgL0BTkIi5Nt8Iw==
dependencies:
apollo-engine-reporting-protobuf "^0.4.3"
apollo-server-caching "^0.5.0"
apollo-server-env "^2.4.3"
apollo-server-types@^0.2.8: apollo-server-types@^0.2.8:
version "0.2.8" version "0.2.8"
resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.8.tgz#729208a8dd72831af3aa4f1eb584022ada146e6b" resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.8.tgz#729208a8dd72831af3aa4f1eb584022ada146e6b"
@ -1882,13 +1804,13 @@ apollo-server-types@^0.2.8:
apollo-server-caching "^0.5.0" apollo-server-caching "^0.5.0"
apollo-server-env "^2.4.3" apollo-server-env "^2.4.3"
apollo-server@~2.9.11: apollo-server@~2.9.12:
version "2.9.11" version "2.9.12"
resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.11.tgz#fafa7bc8f758689b0675c3300599a9b1128c58cd" resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.12.tgz#3fe28c361ee373d52ae38ca190869508b0c532c0"
integrity sha512-BhMpBqgdEYN2SqjigRyEDUDKN3GwrYXzMZXUv/WD55HaKST8RAP4GiJp8lHswdeoUfr/9fsfvtVlrN++zJziuQ== integrity sha512-Q+qaBTgTxb2vwqyh7NTHs9rOmadbuKw34SgeAOLsCnr3MLVjisa50fL3nQrGbhOGfRaroF8SSZYgya0tvnefig==
dependencies: dependencies:
apollo-server-core "^2.9.11" apollo-server-core "^2.9.12"
apollo-server-express "^2.9.11" apollo-server-express "^2.9.12"
express "^4.0.0" express "^4.0.0"
graphql-subscriptions "^1.0.0" graphql-subscriptions "^1.0.0"
graphql-tools "^4.0.0" graphql-tools "^4.0.0"
@ -2648,6 +2570,11 @@ commander@^3.0.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e"
integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==
commander@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.0.1.tgz#b67622721785993182e807f4883633e6401ba53c"
integrity sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA==
commondir@^1.0.1: commondir@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@ -3407,10 +3334,10 @@ eslint-plugin-import@~2.18.2:
read-pkg-up "^2.0.0" read-pkg-up "^2.0.0"
resolve "^1.11.0" resolve "^1.11.0"
eslint-plugin-jest@~23.0.4: eslint-plugin-jest@~23.1.1:
version "23.0.4" version "23.1.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.0.4.tgz#1ab81ffe3b16c5168efa72cbd4db14d335092aa0" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.1.1.tgz#1220ab53d5a4bf5c3c4cd07c0dabc6199d4064dd"
integrity sha512-OaP8hhT8chJNodUPvLJ6vl8gnalcsU/Ww1t9oR3HnGdEWjm/DdCCUXLOral+IPGAeWu/EwgVQCK/QtxALpH1Yw== integrity sha512-2oPxHKNh4j1zmJ6GaCBuGcb8FVZU7YjFUOJzGOPnl9ic7VA/MGAskArLJiRIlnFUmi1EUxY+UiATAy8dv8s5JA==
dependencies: dependencies:
"@typescript-eslint/experimental-utils" "^2.5.0" "@typescript-eslint/experimental-utils" "^2.5.0"
@ -3463,10 +3390,10 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
eslint@~6.7.1: eslint@~6.7.2:
version "6.7.1" version "6.7.2"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.7.1.tgz#269ccccec3ef60ab32358a44d147ac209154b919" resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.7.2.tgz#c17707ca4ad7b2d8af986a33feba71e18a9fecd1"
integrity sha512-UWzBS79pNcsDSxgxbdjkmzn/B6BhsXMfUaOHnNwyE8nD+Q6pyT96ow2MccVayUTV4yMid4qLhMiQaywctRkBLA== integrity sha512-qMlSWJaCSxDFr8fBPvJM9kJwbazrhNcBU3+DszDW1OlEwKBBRWsJc7NJFelvwQpanHCR14cOLD41x8Eqvo3Nng==
dependencies: dependencies:
"@babel/code-frame" "^7.0.0" "@babel/code-frame" "^7.0.0"
ajv "^6.10.0" ajv "^6.10.0"
@ -4173,12 +4100,12 @@ graphql-middleware@~4.0.2:
dependencies: dependencies:
graphql-tools "^4.0.5" graphql-tools "^4.0.5"
graphql-shield@~7.0.2: graphql-shield@~7.0.4:
version "7.0.2" version "7.0.4"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.2.tgz#65d51528b4e36c4a5023276267abbd1abb57ce0b" resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.4.tgz#fdff8066f9fbb86b363e7dd6a9cf295dbbf8a09b"
integrity sha512-ZgCQ9efzgehkIVMFoxaOubnvwHVqthtsIXA+xRu8B4ga2Ch/EHiswNCDKpTXTBQZmjeDCSHeAj4XDmbzR4Trxw== integrity sha512-+SEz/tKx2uJAbMKzS7X0hCUWsZo54J8SARhXb5jNDG/RKur44mjIGfBnuBRszw73+dUdBvTlLl1j1WKwm0ZhEA==
dependencies: dependencies:
"@types/yup" "0.26.24" "@types/yup" "0.26.26"
object-hash "^2.0.0" object-hash "^2.0.0"
yup "^0.27.0" yup "^0.27.0"
@ -6936,25 +6863,6 @@ property-expr@^1.5.0:
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-1.5.1.tgz#22e8706894a0c8e28d58735804f6ba3a3673314f" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-1.5.1.tgz#22e8706894a0c8e28d58735804f6ba3a3673314f"
integrity sha512-CGuc0VUTGthpJXL36ydB6jnbyOf/rAHFvmVrJlH+Rg0DqqLFQGAP6hIaxD/G0OAmBJPhXDHuEJigrp0e0wFV6g== integrity sha512-CGuc0VUTGthpJXL36ydB6jnbyOf/rAHFvmVrJlH+Rg0DqqLFQGAP6hIaxD/G0OAmBJPhXDHuEJigrp0e0wFV6g==
protobufjs@^6.8.6:
version "6.8.8"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/long" "^4.0.0"
"@types/node" "^10.1.0"
long "^4.0.0"
proxy-addr@~2.0.5: proxy-addr@~2.0.5:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"

View File

@ -129,8 +129,8 @@ Given('somebody reported the following posts:', table => {
.create('User', submitter) .create('User', submitter)
.authenticateAs(submitter) .authenticateAs(submitter)
.mutate(`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { .mutate(`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) { fileReport(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) {
type id
} }
}`, { }`, {
resourceId, resourceId,

View File

@ -9,7 +9,7 @@ open your minikube dashboard:
$ minikube dashboard $ minikube dashboard
``` ```
This will give you an overview. Some of the steps below need some timing to make ressources available to other dependent deployments. Keeping an eye on the dashboard is a great way to check that. This will give you an overview. Some of the steps below need some timing to make resources available to other dependent deployments. Keeping an eye on the dashboard is a great way to check that.
Follow the installation instruction for [Human Connection](../human-connection/README.md). Follow the installation instruction for [Human Connection](../human-connection/README.md).
If all the pods and services have settled and everything looks green in your If all the pods and services have settled and everything looks green in your

View File

@ -15,7 +15,7 @@ services:
- ./webapp:/nitro-web - ./webapp:/nitro-web
environment: environment:
- NUXT_BUILD=/tmp/nuxt # avoid file permission issues when `rm -rf .nuxt/` - NUXT_BUILD=/tmp/nuxt # avoid file permission issues when `rm -rf .nuxt/`
- PUBLIC_REGISTRATION=true - PUBLIC_REGISTRATION=false
command: yarn run dev command: yarn run dev
backend: backend:
build: build:
@ -29,7 +29,7 @@ services:
- SMTP_PORT=25 - SMTP_PORT=25
- SMTP_IGNORE_TLS=true - SMTP_IGNORE_TLS=true
- "DEBUG=${DEBUG}" - "DEBUG=${DEBUG}"
- PUBLIC_REGISTRATION=true - PUBLIC_REGISTRATION=false
maintenance: maintenance:
image: humanconnection/maintenance:latest image: humanconnection/maintenance:latest
build: build:

48
features/support/steps.js Normal file
View File

@ -0,0 +1,48 @@
// features/support/steps.js
import { Given, When, Then, After, AfterAll } from 'cucumber'
import Factory from '../../backend/src/seed/factories'
import dotenv from 'dotenv'
import expect from 'expect'
const debug = require('debug')('ea:test:steps')
const factory = Factory()
After(async () => {
await factory.cleanDatabase()
})
Given('our CLIENT_URI is {string}', function (string) {
expect(string).toEqual('http://localhost:3000')
// This is just for documentation. When you see URLs in the response of
// scenarios you, should be able to tell that it's coming from this
// environment variable.
});
Given('we have the following users in our database:', function (dataTable) {
return Promise.all(dataTable.hashes().map(({ slug, name }) => {
return factory.create('User', {
name,
slug,
})
}))
})
When('I send a GET request to {string}', async function (pathname) {
const response = await this.get(pathname)
this.lastContentType = response.lastContentType
this.lastResponses.push(response.lastResponse)
this.statusCode = response.statusCode
})
Then('the server responds with a HTTP Status {int} and the following json:', function (statusCode, docString) {
expect(this.statusCode).toEqual(statusCode)
const [ lastResponse ] = this.lastResponses
expect(JSON.parse(lastResponse)).toMatchObject(JSON.parse(docString))
})
Then('the Content-Type is {string}', function (contentType) {
expect(this.lastContentType).toEqual(contentType)
})

View File

@ -0,0 +1,36 @@
Feature: Webfinger discovery
From an external server, e.g. Mastodon
I want to search for an actor alias
In order to follow the actor
Background:
Given our CLIENT_URI is "http://localhost:3000"
And we have the following users in our database:
| name | slug |
| Peter Lustiger | peter-lustiger |
Scenario: Search a user
When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost"
Then the server responds with a HTTP Status 200 and the following json:
"""
{
"subject": "acct:peter-lustiger@localhost:3000",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "http://localhost:3000/activitypub/users/peter-lustiger"
}
]
}
"""
And the Content-Type is "application/jrd+json; charset=utf-8"
Scenario: Search without result
When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost"
Then the server responds with a HTTP Status 404 and the following json:
"""
{
"error": "No record found for \"nonexisting@localhost\"."
}
"""

38
features/world.js Normal file
View File

@ -0,0 +1,38 @@
import { setWorldConstructor } from 'cucumber'
import request from 'request'
class CustomWorld {
constructor () {
// webFinger.feature
this.lastResponses = []
this.lastContentType = null
this.lastInboxUrl = null
this.lastActivity = null
// object-article.feature
this.statusCode = null
}
get (pathname) {
return new Promise((resolve, reject) => {
request(`http://localhost:4000/${this.replaceSlashes(pathname)}`, {
headers: {
'Accept': 'application/activity+json'
}}, (error, response, body) => {
if (!error) {
resolve({
lastResponse: body,
lastContentType: response.headers['content-type'],
statusCode: response.statusCode
})
} else {
reject(error)
}
})
})
}
replaceSlashes (pathname) {
return pathname.replace(/^\/+/, '')
}
}
setWorldConstructor(CustomWorld)

View File

@ -1,4 +1,4 @@
FROM neo4j:3.5.12-enterprise FROM neo4j:3.5.13-enterprise
LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
ARG BUILD_COMMIT ARG BUILD_COMMIT

View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
ENV_FILE=$(dirname "$0")/.env
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then
echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables."
echo "Database manipulation is not possible without connecting to the database."
echo "E.g. you could \`cp .env.template .env\` unless you run the script in a docker container"
fi
until echo 'RETURN "Connection successful" as info;' | cypher-shell
do
echo "Connecting to neo4j failed, trying again..."
sleep 1
done
echo "
// convert old DISABLED to new REVIEWED-Report-BELONGS_TO structure
MATCH (moderator:User)-[disabled:DISABLED]->(disabledResource)
WHERE disabledResource:User OR disabledResource:Comment OR disabledResource:Post
DELETE disabled
CREATE (moderator)-[review:REVIEWED]->(report:Report)-[:BELONGS_TO]->(disabledResource)
SET review.createdAt = toString(datetime()), review.updatedAt = review.createdAt, review.disable = true
SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false
// if disabledResource has no filed report, then create a moderators default filed report
WITH moderator, disabledResource, report
OPTIONAL MATCH (disabledResourceReporter:User)-[existingFiledReport:FILED]->(disabledResource)
FOREACH(disabledResource IN CASE WHEN existingFiledReport IS NULL THEN [1] ELSE [] END |
CREATE (moderator)-[addModeratorReport:FILED]->(report)
SET addModeratorReport.createdAt = toString(datetime()), addModeratorReport.reasonCategory = 'other', addModeratorReport.reasonDescription = 'Old DISABLED relations didn't enforce mandatory reporting !!! Created automatically to ensure database consistency! Creation date is when the database manipulation happened.'
)
FOREACH(disabledResource IN CASE WHEN existingFiledReport IS NOT NULL THEN [1] ELSE [] END |
CREATE (disabledResourceReporter)-[moveModeratorReport:FILED]->(report)
SET moveModeratorReport = existingFiledReport
DELETE existingFiledReport
)
RETURN disabledResource {.id};
" | cypher-shell
echo "
// for FILED resources without DISABLED relation which are handled above, create new FILED-Report-BELONGS_TO structure
MATCH (reporter:User)-[oldReport:REPORTED]->(notDisabledResource)
WHERE notDisabledResource:User OR notDisabledResource:Comment OR notDisabledResource:Post
MERGE (report:Report)-[:BELONGS_TO]->(notDisabledResource)
ON CREATE SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false
CREATE (reporter)-[filed:FILED]->(report)
SET report = oldReport
DELETE oldReport
RETURN notDisabledResource {.id};
" | cypher-shell

View File

@ -1,26 +0,0 @@
#!/usr/bin/env bash
ENV_FILE=$(dirname "$0")/.env
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then
echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables."
echo "Database manipulation is not possible without connecting to the database."
echo "E.g. you could \`cp .env.template .env\` unless you run the script in a docker container"
fi
until echo 'RETURN "Connection successful" as info;' | cypher-shell
do
echo "Connecting to neo4j failed, trying again..."
sleep 1
done
echo "
MATCH (submitter:User)-[:REPORTED]->(report:Report)-[:REPORTED]->(resource)
DETACH DELETE report
CREATE (submitter)-[reported:REPORTED]->(resource)
SET reported.createdAt = toString(datetime())
SET reported.reasonCategory = 'other'
SET reported.reasonDescription = '!!! Created automatically to ensure database consistency! Creation date is when the database manipulation happened.'
RETURN reported;
" | cypher-shell

View File

@ -1,7 +1,7 @@
{ {
"name": "nitro-cypress", "name": "human-connection",
"version": "0.1.11", "version": "0.1.11",
"description": "Fullstack tests with cypress for Human Connection", "description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh", "author": "Human Connection gGmbh",
"license": "MIT", "license": "MIT",
"cypress-cucumber-preprocessor": { "cypress-cucumber-preprocessor": {
@ -16,19 +16,26 @@
"cypress:setup": "run-p cypress:backend cypress:webapp", "cypress:setup": "run-p cypress:backend cypress:webapp",
"cypress:run": "cross-env cypress run --browser chromium", "cypress:run": "cross-env cypress run --browser chromium",
"cypress:open": "cross-env cypress open --browser chromium", "cypress:open": "cross-env cypress open --browser chromium",
"cucumber:setup": "cd backend && yarn run dev",
"cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit",
"version": "auto-changelog -p" "version": "auto-changelog -p"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.7.2",
"@babel/preset-env": "^7.7.4",
"@babel/register": "^7.7.4",
"auto-changelog": "^1.16.2", "auto-changelog": "^1.16.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"codecov": "^3.6.1", "codecov": "^3.6.1",
"cross-env": "^6.0.3", "cross-env": "^6.0.3",
"cypress": "^3.6.1", "cucumber": "^6.0.5",
"cypress-cucumber-preprocessor": "^1.16.2", "cypress": "^3.7.0",
"cypress-cucumber-preprocessor": "^1.17.0",
"cypress-file-upload": "^3.5.0", "cypress-file-upload": "^3.5.0",
"cypress-plugin-retries": "^1.4.0", "cypress-plugin-retries": "^1.5.0",
"date-fns": "^2.8.1", "date-fns": "^2.8.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"expect": "^24.9.0",
"faker": "Marak/faker.js#master", "faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2", "graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.6", "neo4j-driver": "^1.7.6",

View File

@ -1,19 +1,9 @@
<template> <template>
<div id="comments"> <div id="comments">
<h3 style="margin-top: -10px;"> <h3 style="margin-top: -10px;">
<span> <counter-icon icon="comments" :count="post.comments.length">
<base-icon name="comments" /> {{ $t('common.comment', null, 0) }}
<ds-tag </counter-icon>
v-if="post.comments.length"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>
{{ post.comments.length }}
</ds-tag>
<span class="list-title">{{ $t('common.comment', null, 0) }}</span>
</span>
</h3> </h3>
<ds-space margin-bottom="large" /> <ds-space margin-bottom="large" />
<div v-if="post.comments && post.comments.length" id="comments" class="comments"> <div v-if="post.comments && post.comments.length" id="comments" class="comments">
@ -31,12 +21,14 @@
</div> </div>
</template> </template>
<script> <script>
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Comment from '~/components/Comment/Comment' import Comment from '~/components/Comment/Comment'
import scrollToAnchor from '~/mixins/scrollToAnchor' import scrollToAnchor from '~/mixins/scrollToAnchor'
export default { export default {
mixins: [scrollToAnchor], mixins: [scrollToAnchor],
components: { components: {
CounterIcon,
Comment, Comment,
}, },
props: { props: {
@ -58,9 +50,3 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
.list-title {
margin-left: $space-x-small;
}
</style>

View File

@ -85,7 +85,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'post.menu.delete') .filter(item => item.text() === 'post.menu.delete')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('delete') expect(openModalSpy).toHaveBeenCalledWith('confirm', 'delete')
}) })
}) })
@ -166,7 +166,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'comment.menu.delete') .filter(item => item.text() === 'comment.menu.delete')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('delete') expect(openModalSpy).toHaveBeenCalledWith('confirm', 'delete')
}) })
}) })
@ -332,7 +332,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'release.contribution.title') .filter(item => item.text() === 'release.contribution.title')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') expect(openModalSpy).toHaveBeenCalledWith('release')
}) })
it('can release comments', () => { it('can release comments', () => {
@ -350,7 +350,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'release.comment.title') .filter(item => item.text() === 'release.comment.title')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') expect(openModalSpy).toHaveBeenCalledWith('release')
}) })
it('can release users', () => { it('can release users', () => {
@ -368,7 +368,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'release.user.title') .filter(item => item.text() === 'release.user.title')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') expect(openModalSpy).toHaveBeenCalledWith('release')
}) })
it('can release organizations', () => { it('can release organizations', () => {
@ -386,7 +386,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'release.organization.title') .filter(item => item.text() === 'release.organization.title')
.at(0) .at(0)
.trigger('click') .trigger('click')
expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') expect(openModalSpy).toHaveBeenCalledWith('release')
}) })
}) })

View File

@ -70,7 +70,7 @@ export default {
routes.push({ routes.push({
name: this.$t(`post.menu.delete`), name: this.$t(`post.menu.delete`),
callback: () => { callback: () => {
this.openModal('delete') this.openModal('confirm', 'delete')
}, },
icon: 'trash', icon: 'trash',
}) })
@ -108,7 +108,7 @@ export default {
routes.push({ routes.push({
name: this.$t(`comment.menu.delete`), name: this.$t(`comment.menu.delete`),
callback: () => { callback: () => {
this.openModal('delete') this.openModal('confirm', 'delete')
}, },
icon: 'trash', icon: 'trash',
}) })
@ -137,7 +137,7 @@ export default {
routes.push({ routes.push({
name: this.$t(`release.${this.resourceType}.title`), name: this.$t(`release.${this.resourceType}.title`),
callback: () => { callback: () => {
this.openModal('release', this.resource.id) this.openModal('release')
}, },
icon: 'eye', icon: 'eye',
}) })
@ -190,13 +190,13 @@ export default {
} }
toggleMenu() toggleMenu()
}, },
openModal(dialog) { openModal(dialog, modalDataName = null) {
this.$store.commit('modal/SET_OPEN', { this.$store.commit('modal/SET_OPEN', {
name: dialog, name: dialog,
data: { data: {
type: this.resourceType, type: this.resourceType,
resource: this.resource, resource: this.resource,
modalsData: this.modalsData, modalData: modalDataName ? this.modalsData[modalDataName] : {},
}, },
}) })
}, },

View File

@ -64,9 +64,9 @@ describe('DropdownFilter.vue', () => {
expect(unreadLink.text()).toEqual('Unread') expect(unreadLink.text()).toEqual('Unread')
}) })
it('clicking on menu item emits filterNotifications', () => { it('clicking on menu item emits filter', () => {
allLink.trigger('click') allLink.trigger('click')
expect(wrapper.emitted().filterNotifications[0]).toEqual( expect(wrapper.emitted().filter[0]).toEqual(
propsData.filterOptions.filter(option => option.label === 'All'), propsData.filterOptions.filter(option => option.label === 'All'),
) )
}) })

View File

@ -20,10 +20,10 @@ storiesOf('DropdownFilter', module)
selected: filterOptions[0].label, selected: filterOptions[0].label,
}), }),
methods: { methods: {
filterNotifications: action('filterNotifications'), filter: action('filter'),
}, },
template: `<dropdown-filter template: `<dropdown-filter
@filterNotifications="filterNotifications" @filter="filter"
:filterOptions="filterOptions" :filterOptions="filterOptions"
:selected="selected" :selected="selected"
/>`, />`,

View File

@ -25,7 +25,7 @@
class="dropdown-menu-item" class="dropdown-menu-item"
:route="item.route" :route="item.route"
:parents="item.parents" :parents="item.parents"
@click.stop.prevent="filterNotifications(item.route, toggleMenu)" @click.stop.prevent="filter(item.route, toggleMenu)"
> >
{{ item.route.label }} {{ item.route.label }}
</ds-menu-item> </ds-menu-item>
@ -44,8 +44,8 @@ export default {
filterOptions: { type: Array, default: () => [] }, filterOptions: { type: Array, default: () => [] },
}, },
methods: { methods: {
filterNotifications(option, toggleMenu) { filter(option, toggleMenu) {
this.$emit('filterNotifications', option) this.$emit('filter', option)
toggleMenu() toggleMenu()
}, },
}, },

View File

@ -23,11 +23,11 @@
@close="close" @close="close"
/> />
<confirm-modal <confirm-modal
v-if="open === 'delete'" v-if="open === 'confirm'"
:id="data.resource.id" :id="data.resource.id"
:type="data.type" :type="data.type"
:name="name" :name="name"
:modalData="data.modalsData.delete" :modalData="data.modalData"
@close="close" @close="close"
/> />
</div> </div>

View File

@ -77,7 +77,7 @@ export default {
}, 500) }, 500)
}, 1500) }, 1500)
} catch (err) { } catch (err) {
this.success = false this.isOpen = false
} finally { } finally {
this.loading = false this.loading = false
} }

View File

@ -26,9 +26,7 @@ describe('DisableModal.vue', () => {
$apollo: { $apollo: {
mutate: jest mutate: jest
.fn() .fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce()
enable: 'u4711',
})
.mockRejectedValue({ .mockRejectedValue({
message: 'Not Authorised!', message: 'Not Authorised!',
}), }),
@ -159,11 +157,13 @@ describe('DisableModal.vue', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled() expect(mocks.$apollo.mutate).toHaveBeenCalled()
}) })
it('passes id to mutation', () => { it('passes parameters to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls const [[{ variables }]] = calls
expect(variables).toEqual({ expect(variables).toMatchObject({
id: 'u4711', resourceId: 'u4711',
disable: true,
closed: false,
}) })
}) })

View File

@ -54,11 +54,13 @@ export default {
// await this.modalData.buttons.confirm.callback() // await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: gql` mutation: gql`
mutation($id: ID!) { mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
disable(id: $id) review(resourceId: $resourceId, disable: $disable, closed: $closed) {
disable
}
} }
`, `,
variables: { id: this.id }, variables: { resourceId: this.id, disable: true, closed: false },
}) })
this.$toast.success(this.$t('disable.success')) this.$toast.success(this.$t('disable.success'))
this.isOpen = false this.isOpen = false
@ -67,6 +69,7 @@ export default {
}, 1000) }, 1000)
} catch (err) { } catch (err) {
this.$toast.error(err.message) this.$toast.error(err.message)
this.isOpen = false
} }
}, },
}, },

View File

@ -149,6 +149,7 @@ export default {
default: default:
this.$toast.error(err.message) this.$toast.error(err.message)
} }
this.isOpen = false
this.loading = false this.loading = false
}) })
}, },

View File

@ -46,7 +46,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons' import { SweetalertIcon } from 'vue-sweetalert-icons'
import { normalizeEmail } from 'validator' import normalizeEmail from '~/components/utils/NormalizeEmail'
export default { export default {
components: { components: {

View File

@ -27,7 +27,7 @@ describe('ReleaseModal.vue', () => {
$apollo: { $apollo: {
mutate: jest mutate: jest
.fn() .fn()
.mockResolvedValueOnce({ enable: 'u4711' }) .mockResolvedValueOnce()
.mockRejectedValue({ message: 'Not Authorised!' }), .mockRejectedValue({ message: 'Not Authorised!' }),
}, },
location: { location: {
@ -154,11 +154,13 @@ describe('ReleaseModal.vue', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled() expect(mocks.$apollo.mutate).toHaveBeenCalled()
}) })
it('passes id to mutation', () => { it('passes parameters to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls const [[{ variables }]] = calls
expect(variables).toEqual({ expect(variables).toMatchObject({
id: 'u4711', resourceId: 'u4711',
disable: false,
closed: false,
}) })
}) })

View File

@ -53,11 +53,13 @@ export default {
// await this.modalData.buttons.confirm.callback() // await this.modalData.buttons.confirm.callback()
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: gql` mutation: gql`
mutation($id: ID!) { mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
enable(id: $id) review(resourceId: $resourceId, disable: $disable, closed: $closed) {
disable
}
} }
`, `,
variables: { id: this.id }, variables: { resourceId: this.id, disable: false, closed: false },
}) })
this.$toast.success(this.$t('release.success')) this.$toast.success(this.$t('release.success'))
this.isOpen = false this.isOpen = false
@ -66,6 +68,7 @@ export default {
}, 1000) }, 1000)
} catch (err) { } catch (err) {
this.$toast.error(err.message) this.$toast.error(err.message)
this.isOpen = false
} }
}, },
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="user" v-if="displayAnonymous"> <div class="user" v-if="displayAnonymous">
<hc-avatar class="avatar" /> <hc-avatar v-if="showAvatar" class="avatar" />
<div> <div>
<b class="username">{{ $t('profile.userAnonym') }}</b> <b class="username">{{ $t('profile.userAnonym') }}</b>
</div> </div>
@ -9,21 +9,19 @@
<template slot="default" slot-scope="{ openMenu, closeMenu, isOpen }"> <template slot="default" slot-scope="{ openMenu, closeMenu, isOpen }">
<nuxt-link :to="userLink" :class="['user', isOpen && 'active']"> <nuxt-link :to="userLink" :class="['user', isOpen && 'active']">
<div @mouseover="openMenu(true)" @mouseleave="closeMenu(true)"> <div @mouseover="openMenu(true)" @mouseleave="closeMenu(true)">
<hc-avatar class="avatar" :user="user" /> <hc-avatar v-if="showAvatar" class="avatar" :user="user" />
<div> <div>
<ds-text class="userinfo"> <ds-text class="userinfo">
<b class="username">{{ userName | truncate(18) }}</b> <b>{{ userSlug }}</b>
<ds-text v-if="dateTime" size="small" color="soft">
<base-icon name="clock" />
<client-only>
<hc-relative-date-time :date-time="dateTime" />
</client-only>
<slot name="dateTime"></slot>
</ds-text>
</ds-text> </ds-text>
</div> </div>
<ds-text align="left" size="small" color="soft"> <ds-text class="username" align="left" size="small" color="soft">
{{ userSlug }} {{ userName | truncate(18) }}
<template v-if="dateTime">
<base-icon name="clock" />
<hc-relative-date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</template>
</ds-text> </ds-text>
</div> </div>
</nuxt-link> </nuxt-link>
@ -105,7 +103,8 @@ export default {
}, },
props: { props: {
user: { type: Object, default: null }, user: { type: Object, default: null },
trunc: { type: Number, default: null }, showAvatar: { type: Boolean, default: true },
trunc: { type: Number, default: 18 }, // "-1" is no trunc
dateTime: { type: [Date, String], default: null }, dateTime: { type: [Date, String], default: null },
}, },
computed: { computed: {
@ -177,4 +176,8 @@ export default {
z-index: 999; z-index: 999;
} }
} }
.user-slug {
margin-bottom: $space-xx-small;
}
</style> </style>

View File

@ -0,0 +1,37 @@
import { mount } from '@vue/test-utils'
import CounterIcon from './CounterIcon'
import BaseIcon from '../BaseIcon/BaseIcon'
const localVue = global.localVue
describe('CounterIcon.vue', () => {
let propsData, wrapper, tag
const Wrapper = () => {
return mount(CounterIcon, { propsData, localVue })
}
describe('given a valid icon name and count', () => {
beforeEach(() => {
propsData = { icon: 'comments', count: 1 }
wrapper = Wrapper()
tag = wrapper.find('.ds-tag')
})
it('renders BaseIcon', () => {
expect(wrapper.find(BaseIcon).exists()).toBe(true)
})
it('renders the count', () => {
expect(tag.text()).toEqual('1')
})
it('uses a round tag', () => {
expect(tag.classes()).toContain('ds-tag-round')
})
it('uses a primary button', () => {
expect(tag.classes()).toContain('ds-tag-primary')
})
})
})

View File

@ -0,0 +1,19 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import CounterIcon from './CounterIcon.vue'
storiesOf('CounterIcon', module)
.addDecorator(helpers.layout)
.add('flag icon with button in slot position', () => ({
components: { CounterIcon },
data() {
return { icon: 'flag', count: 3 }
},
template: `
<counter-icon icon="pizza" :count="count">
<ds-button ghost primary>
Report Details
</ds-button>
</counter-icon>
`,
}))

View File

@ -0,0 +1,29 @@
<template>
<span>
<base-icon :name="icon" />
<ds-tag
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>
{{ count }}
</ds-tag>
<span class="counter-icon-text">
<slot />
</span>
</span>
</template>
<script>
export default {
props: {
icon: { type: String, required: true },
count: { type: Number, required: true },
},
}
</script>
<style lang="scss" scoped>
.counter-icon-text {
margin-left: $space-xx-small;
}
</style>

View File

@ -0,0 +1,93 @@
import { config, mount, RouterLinkStub } from '@vue/test-utils'
import Vuex from 'vuex'
import FiledReportsTable from './FiledReportsTable'
import { reports } from '~/components/features/ReportList/ReportList.story.js'
const localVue = global.localVue
localVue.filter('truncate', string => string)
config.stubs['client-only'] = '<span><slot /></span>'
describe('FiledReportsTable.vue', () => {
let wrapper, mocks, propsData, stubs, filed
beforeEach(() => {
mocks = {
$t: jest.fn(string => string),
}
stubs = {
NuxtLink: RouterLinkStub,
}
propsData = {}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters: {
'auth/isModerator': () => true,
'auth/user': () => {
return { id: 'moderator' }
},
},
})
return mount(FiledReportsTable, {
propsData,
mocks,
localVue,
store,
stubs,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('given reports', () => {
beforeEach(() => {
filed = reports.map(report => report.filed)
propsData.filed = filed[0]
wrapper = Wrapper()
})
it('renders a table', () => {
expect(wrapper.find('.ds-table').exists()).toBe(true)
})
it('has 4 columns', () => {
expect(wrapper.findAll('.ds-table-col')).toHaveLength(4)
})
describe('FiledReport', () => {
it('renders the reporting user', () => {
const userSlug = wrapper.find('[data-test="filing-user"]')
expect(userSlug.text()).toContain('@community-moderator')
})
it('renders the reported date', () => {
const date = wrapper.find('[data-test="filed-date"]')
expect(date.text()).toEqual('10/02/2019')
})
it('renders the category text', () => {
const columns = wrapper.findAll('.ds-table-col')
const reasonCategory = columns.filter(
category =>
category.text() === 'report.reason.category.options.pornographic_content_links',
)
expect(reasonCategory.exists()).toBe(true)
})
it("renders the Post's content", () => {
const columns = wrapper.findAll('.ds-table-col')
const reasonDescription = columns.filter(
column => column.text() === 'This comment is porno!!!',
)
expect(reasonDescription.exists()).toBe(true)
})
})
})
})
})

View File

@ -0,0 +1,25 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import FiledReportsTable from '~/components/features/FiledReportsTable/FiledReportsTable'
import helpers from '~/storybook/helpers'
import { reports } from '~/components/features/ReportList/ReportList.story.js'
storiesOf('FiledReportsTable', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('with filed reports', () => ({
components: { FiledReportsTable },
store: helpers.store,
data: () => ({
filed: reports[0].filed,
}),
template: `<table>
<tbody class="report-row">
<tr class="row">
<td colspan="100%">
<filed-reports-table :filed="filed" />
</td>
</tr>
</tbody>
</table>`,
}))

View File

@ -0,0 +1,73 @@
<template>
<ds-table
class="nested-table"
v-if="filed && filed.length"
:data="filed"
:fields="fields"
condensed
>
<template #submitter="scope">
<hc-user
:user="scope.row.submitter"
:showAvatar="false"
:trunc="30"
data-test="filing-user"
/>
</template>
<template #reportedOn="scope">
<ds-text size="small">
<hc-relative-date-time :date-time="scope.row.createdAt" data-test="filed-date" />
</ds-text>
</template>
<template #reasonCategory="scope">
{{ $t('report.reason.category.options.' + scope.row.reasonCategory) }}
</template>
<template #reasonDescription="scope">
{{ scope.row.reasonDescription.length ? scope.row.reasonDescription : '—' }}
</template>
</ds-table>
</template>
<script>
import HcUser from '~/components/User/User'
import HcRelativeDateTime from '~/components/RelativeDateTime'
export default {
components: {
HcUser,
HcRelativeDateTime,
},
props: {
filed: { type: Array, default: () => [] },
},
computed: {
fields() {
return {
submitter: {
label: this.$t('moderation.reports.submitter'),
width: '15%',
},
reportedOn: {
label: this.$t('moderation.reports.reportedOn'),
width: '20%',
},
reasonCategory: {
label: this.$t('moderation.reports.reasonCategory'),
width: '30%',
},
reasonDescription: {
label: this.$t('moderation.reports.reasonDescription'),
width: '35%',
},
}
},
},
}
</script>
<style lang="scss">
.nested-table {
padding: $space-small;
border-top: $border-size-base solid $color-neutral-60;
border-bottom: $border-size-base solid $color-neutral-60;
}
</style>

View File

@ -0,0 +1,77 @@
import { config, mount } from '@vue/test-utils'
import Vuex from 'vuex'
import ReportList from './ReportList'
import { reports } from './ReportList.story'
import ReportsTable from '~/components/features/ReportsTable/ReportsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('ReportList', () => {
let mocks, mutations, getters, wrapper
beforeEach(() => {
mocks = {
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: { review: { disable: true, resourceId: 'some-resource', closed: true } },
})
.mockRejectedValue({ message: 'Unable to review' }),
},
$t: jest.fn(),
$toast: {
error: jest.fn(message => message),
},
}
mutations = {
'modal/SET_OPEN': jest.fn().mockResolvedValueOnce(),
}
getters = {
'auth/user': () => {
return { slug: 'awesome-user' }
},
'auth/isModerator': () => true,
}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
mutations,
getters,
})
return mount(ReportList, { mocks, localVue, store })
}
describe('renders children components', () => {
beforeEach(async () => {
wrapper = Wrapper()
})
it('renders DropdownFilter', () => {
expect(wrapper.find(DropdownFilter).exists()).toBe(true)
})
it('renders ReportsTable', () => {
expect(wrapper.find(ReportsTable).exists()).toBe(true)
})
})
describe('confirm is emitted by reports table', () => {
beforeEach(async () => {
wrapper = Wrapper()
wrapper.setData({ reports })
wrapper.find(ReportsTable).vm.$emit('confirm', reports[0])
})
it('calls modal/SET_OPEN', () => {
expect(mutations['modal/SET_OPEN']).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -0,0 +1,193 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import { post } from '~/components/PostCard/PostCard.story.js'
import { user } from '~/components/User/User.story.js'
import helpers from '~/storybook/helpers'
import ReportList from './ReportList'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import ReportsTable from '~/components/features/ReportsTable/ReportsTable'
helpers.init()
export const reports = [
{
__typename: 'Report',
closed: false,
createdAt: '2019-10-29T15:36:02.106Z',
updatedAt: '2019-12-02T15:56:35.651Z',
disable: false,
filed: [
{
__typename: 'FILED',
createdAt: '2019-10-02T15:56:35.676Z',
reasonCategory: 'pornographic_content_links',
reasonDescription: 'This comment is porno!!!',
submitter: {
...user,
name: 'Community moderator',
id: 'community-moderator',
slug: 'community-moderator',
},
},
],
resource: {
__typename: 'Comment',
id: 'b6b38937-3efc-4d5e-b12c-549e4d6551a5',
createdAt: '2019-10-29T15:38:25.184Z',
updatedAt: '2019-10-30T15:38:25.184Z',
disabled: false,
deleted: false,
content:
'<p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p>',
contentExcerpt:
'<p><a href="/profile/u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra …</p>',
post,
author: user,
},
reviewed: [
{
updatedAt: '2019-10-30T15:38:25.184Z',
moderator: {
__typename: 'User',
...user,
name: 'Moderator',
id: 'moderator',
slug: 'moderator',
},
},
{
updatedAt: '2019-10-29T15:38:25.184Z',
moderator: {
__typename: 'User',
...user,
name: 'Peter Lustig',
id: 'u3',
slug: 'peter-lustig',
},
},
],
},
{
__typename: 'Report',
closed: false,
createdAt: '2019-10-31T15:36:02.106Z',
updatedAt: '2019-12-03T15:56:35.651Z',
disable: true,
filed: [
{
__typename: 'FILED',
createdAt: '2019-10-31T15:36:02.106Z',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
submitter: {
...user,
name: 'Modertation team',
id: 'moderation-team',
slug: 'moderation-team',
},
},
],
resource: {
__typename: 'Post',
author: {
...user,
id: 'u7',
name: 'Dagobert',
slug: 'dagobert',
},
deleted: false,
disabled: false,
id: 'p2',
slug: 'bigoted-post',
title: "I'm a bigoted post!",
},
reviewed: null,
},
{
__typename: 'Report',
closed: true,
createdAt: '2019-10-30T15:36:02.106Z',
updatedAt: '2019-12-01T15:56:35.651Z',
disable: true,
filed: [
{
__typename: 'FILED',
createdAt: '2019-10-30T15:36:02.106Z',
reasonCategory: 'discrimination_etc',
reasonDescription: 'this user is attacking me for who I am!',
submitter: {
...user,
name: 'Helpful user',
id: 'helpful-user',
slug: 'helpful-user',
},
},
],
resource: {
__typename: 'User',
commentedCount: 0,
contributionsCount: 0,
deleted: false,
disabled: true,
followedByCount: 0,
id: 'u5',
name: 'Abusive user',
slug: 'abusive-user',
},
reviewed: [
{
updatedAt: '2019-12-01T15:56:35.651Z',
moderator: {
__typename: 'User',
...user,
name: 'Peter Lustig',
id: 'u3',
slug: 'peter-lustig',
},
},
{
updatedAt: '2019-11-30T15:56:35.651Z',
moderator: {
__typename: 'User',
...user,
name: 'Moderator',
id: 'moderator',
slug: 'moderator',
},
},
],
},
]
const unreviewedReports = reports.filter(report => !report.reviewed)
const reviewedReports = reports.filter(report => report.reviewed)
const closedReports = reports.filter(report => report.closed)
const filterOptions = [
{ label: 'All', value: reports },
{ label: 'Unreviewed', value: unreviewedReports },
{ label: 'Reviewed', value: reviewedReports },
{ label: 'Closed', value: closedReports },
]
storiesOf('ReportList', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('with reports', () => ({
components: { ReportList, DropdownFilter, ReportsTable },
store: helpers.store,
data: () => ({
filterOptions,
selected: filterOptions[0].label,
reports,
}),
methods: {
openModal: action('openModal'),
filter: action('filter'),
},
template: `<ds-card>
<div class="reports-header">
<h3 class="title">Reports</h3>
<dropdown-filter @filter="filter" :filterOptions="filterOptions" :selected="selected" />
</div>
<reports-table :reports="reports" @confirm="openModal" />
</ds-card>`,
}))

View File

@ -0,0 +1,142 @@
<template>
<ds-card>
<div class="reports-header">
<h3 class="title">{{ $t('moderation.reports.name') }}</h3>
<client-only>
<dropdown-filter @filter="filter" :filterOptions="filterOptions" :selected="selected" />
</client-only>
</div>
<reports-table :reports="reports" @confirm="openModal" />
</ds-card>
</template>
<script>
import { mapMutations } from 'vuex'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import ReportsTable from '~/components/features/ReportsTable/ReportsTable'
import { reportsListQuery, reviewMutation } from '~/graphql/Moderation.js'
export default {
components: {
DropdownFilter,
ReportsTable,
},
data() {
return {
reports: [],
allReports: [],
unreviewedReports: [],
reviewedReports: [],
closedReports: [],
selected: this.$t('moderation.reports.filterLabel.all'),
}
},
computed: {
filterOptions() {
return [
{ label: this.$t('moderation.reports.filterLabel.all'), value: this.allReports },
{
label: this.$t('moderation.reports.filterLabel.unreviewed'),
value: this.unreviewedReports,
},
{ label: this.$t('moderation.reports.filterLabel.reviewed'), value: this.reviewedReports },
{ label: this.$t('moderation.reports.filterLabel.closed'), value: this.closedReports },
]
},
modalData() {
return function(report) {
const identStart =
'moderation.reports.decideModal.' +
report.resource.__typename +
'.' +
(report.resource.disabled ? 'disable' : 'enable')
return {
name: 'confirm',
data: {
type: report.resource.__typename,
resource: report.resource,
modalData: {
titleIdent: identStart + '.title',
messageIdent: identStart + '.message',
messageParams: {
name:
report.resource.name ||
this.$filters.truncate(report.resource.title, 30) ||
this.$filters.truncate(
this.$filters.removeHtml(report.resource.contentExcerpt),
30,
),
},
buttons: {
confirm: {
danger: true,
icon: report.resource.disabled ? 'eye-slash' : 'eye',
textIdent: 'moderation.reports.decideModal.submit',
callback: () => {
this.confirmCallback(report.resource)
},
},
cancel: {
icon: 'close',
textIdent: 'moderation.reports.decideModal.cancel',
callback: () => {},
},
},
},
},
}
}
},
},
methods: {
...mapMutations({
commitModalData: 'modal/SET_OPEN',
}),
filter(option) {
this.reports = option.value
this.selected = option.label
},
async confirmCallback(resource) {
const { disabled: disable, id: resourceId } = resource
this.$apollo
.mutate({
mutation: reviewMutation(),
variables: { disable, resourceId, closed: true },
})
.then(() => {
this.$toast.success(this.$t('moderation.reports.DecisionSuccess'))
this.$apollo.queries.reportsList.refetch()
})
.catch(error => this.$toast.error(error.message))
},
openModal(report) {
this.commitModalData(this.modalData(report))
},
},
apollo: {
reportsList: {
query: reportsListQuery(),
update({ reports }) {
this.reports = reports
this.allReports = reports
this.unreviewedReports = reports.filter(report => !report.reviewed)
this.reviewedReports = reports.filter(report => report.reviewed)
this.closedReports = reports.filter(report => report.closed)
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
<style lang="scss">
.reports-header {
display: flex;
justify-content: space-between;
margin: $space-small 0;
> .title {
margin: 0;
font-size: $font-size-large;
}
}
</style>

View File

@ -0,0 +1,187 @@
import { config, mount, RouterLinkStub } from '@vue/test-utils'
import Vuex from 'vuex'
import ReportRow from './ReportRow.vue'
import BaseIcon from '~/components/_new/generic/BaseIcon/BaseIcon'
import { reports } from '~/components/features/ReportList/ReportList.story.js'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
describe('ReportRow', () => {
let propsData, mocks, stubs, getters, wrapper
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(string => string),
}
stubs = {
NuxtLink: RouterLinkStub,
}
getters = {
'auth/user': () => {
return { slug: 'awesome-user' }
},
'auth/isModerator': () => true,
}
})
describe('given a report ', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(ReportRow, { propsData, mocks, stubs, localVue, store })
}
describe('has not been closed', () => {
let confirmButton
beforeEach(() => {
propsData = { ...propsData, report: reports[1] }
wrapper = Wrapper()
confirmButton = wrapper.find('.ds-button-danger')
})
it('renders a confirm button', () => {
expect(confirmButton.exists()).toBe(true)
})
it('emits confirm event', () => {
confirmButton.trigger('click')
expect(wrapper.emitted('confirm-report')).toHaveLength(1)
})
})
describe('has been closed', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[2] }
wrapper = Wrapper()
})
it('renders a decided text', () => {
const decidedTitle = wrapper
.findAll('.title')
.filter(title => title.text() === 'moderation.reports.decided')
expect(decidedTitle.exists()).toBe(true)
})
})
describe('has not been reviewed', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[1] }
wrapper = Wrapper()
})
it('renders its current status', () => {
expect(wrapper.find('.status-line').text()).toEqual('moderation.reports.enabled')
})
})
describe('has been reviewed', () => {
describe('and disabled', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[2] }
wrapper = Wrapper()
})
it('renders the disabled icon', () => {
expect(
wrapper
.find('.status-line')
.find(BaseIcon)
.props().name,
).toEqual('eye-slash')
})
it('renders its current status', () => {
expect(wrapper.find('.status-line').text()).toEqual('moderation.reports.disabledBy')
})
})
describe('and enabled', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[0] }
wrapper = Wrapper()
})
it('renders the enabled icon', () => {
expect(
wrapper
.find('.status-line')
.find(BaseIcon)
.props().name,
).toEqual('eye')
})
it('renders its current status', () => {
expect(wrapper.find('.status-line').text()).toEqual('moderation.reports.enabledBy')
})
it('renders the moderator who reviewed the resource', () => {
const username = wrapper.find('[data-test="report-reviewer"]')
expect(username.text()).toContain('@moderator')
})
})
})
describe('concerns a Comment', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[0] }
wrapper = Wrapper()
})
it('renders a comments icon', () => {
const commentsIcon = wrapper.find(BaseIcon).props().name
expect(commentsIcon).toEqual('comments')
})
it('renders a link to the post, with the comment contentExcerpt', () => {
const postLink = wrapper.find('.title')
expect(postLink.text()).toEqual('@peter-lustig Lorem ipsum dolor sit amet, …')
})
it('renders the author', () => {
const userSlug = wrapper.find('[data-test="report-author"]')
expect(userSlug.text()).toContain('@louie')
})
})
describe('concerns a Post', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[1] }
wrapper = Wrapper()
})
it('renders a bookmark icon', () => {
const postIcon = wrapper.find(BaseIcon).props().name
expect(postIcon).toEqual('bookmark')
})
it('renders a link to the post', () => {
const postLink = wrapper.find('.title')
expect(postLink.text()).toEqual("I'm a bigoted post!")
})
it('renders the author', () => {
const username = wrapper.find('[data-test="report-author"]')
expect(username.text()).toContain('@dagobert')
})
})
describe('concerns a User', () => {
beforeEach(() => {
propsData = { ...propsData, report: reports[2] }
wrapper = Wrapper()
})
it('renders a user icon', () => {
const userIcon = wrapper.find(BaseIcon).props().name
expect(userIcon).toEqual('user')
})
it('renders a link to the user profile', () => {
const userLink = wrapper.find('[data-test="report-content"]')
expect(userLink.text()).toContain('@abusive-user')
})
})
})
})

View File

@ -0,0 +1,183 @@
<template>
<tbody class="report-row">
<tr>
<!-- Icon Column -->
<td class="ds-table-col">
<base-icon :name="iconName" :title="iconLabel" />
</td>
<!-- Number of Filed Reports Column -->
<td class="ds-table-col">
<span class="user-count">
{{ $t('moderation.reports.numberOfUsers', { count: report.filed.length }) }}
</span>
<ds-button size="small" @click="showFiledReports = !showFiledReports">
{{ $t('moderation.reports.moreDetails') }}
</ds-button>
</td>
<!-- Content Column -->
<td class="ds-table-col" data-test="report-content">
<client-only v-if="isUser">
<hc-user :user="report.resource" :showAvatar="false" :trunc="30" />
</client-only>
<nuxt-link v-else class="title" :to="linkTarget">
{{ linkText | truncate(50) }}
</nuxt-link>
</td>
<!-- Author Column -->
<td class="ds-table-col" data-test="report-author">
<client-only v-if="!isUser">
<hc-user :user="report.resource.author" :showAvatar="false" :trunc="30" />
</client-only>
<span v-else></span>
</td>
<!-- Status Column -->
<td class="ds-table-col" data-test="report-reviewer">
<span class="status-line">
<base-icon :name="statusIconName" :class="isDisabled ? '--disabled' : '--enabled'" />
{{ statusText }}
</span>
<client-only v-if="report.reviewed">
<hc-user
:user="moderatorOfLatestReview"
:showAvatar="false"
:trunc="30"
:date-time="report.updatedAt"
/>
</client-only>
</td>
<!-- Decision Column -->
<td class="ds-table-col">
<span v-if="report.closed" class="title">
{{ $t('moderation.reports.decided') }}
</span>
<ds-button
v-else
danger
data-test="confirm"
size="small"
:icon="statusIconName"
@click="$emit('confirm-report')"
>
{{ $t('moderation.reports.decideButton') }}
</ds-button>
</td>
</tr>
<tr class="row">
<td colspan="100%">
<filed-reports-table :filed="report.filed" v-if="showFiledReports" />
</td>
</tr>
</tbody>
</template>
<script>
import FiledReportsTable from '~/components/features/FiledReportsTable/FiledReportsTable'
import HcUser from '~/components/User/User'
export default {
components: {
FiledReportsTable,
HcUser,
},
props: {
report: {
type: Object,
required: true,
},
},
data() {
return {
showFiledReports: false,
}
},
computed: {
isPost() {
return this.report.resource.__typename === 'Post'
},
isComment() {
return this.report.resource.__typename === 'Comment'
},
isUser() {
return this.report.resource.__typename === 'User'
},
isDisabled() {
return this.report.resource.disabled
},
iconName() {
if (this.isPost) return 'bookmark'
else if (this.isComment) return 'comments'
else if (this.isUser) return 'user'
else return null
},
iconLabel() {
if (this.isPost) return this.$t('report.contribution.type')
else if (this.isComment) return this.$t('report.comment.type')
else if (this.isUser) return this.$t('report.user.type')
else return null
},
linkTarget() {
const { id, slug } = this.isComment ? this.report.resource.post : this.report.resource
return {
name: 'post-id-slug',
params: { id, slug },
hash: this.isComment ? `#commentId-${this.report.resource.id}` : '',
}
},
linkText() {
return (
this.report.resource.title || this.$filters.removeHtml(this.report.resource.contentExcerpt)
)
},
statusIconName() {
return this.isDisabled ? 'eye-slash' : 'eye'
},
statusText() {
if (!this.report.reviewed) return this.$t('moderation.reports.enabled')
else if (this.isDisabled) return this.$t('moderation.reports.disabledBy')
else return this.$t('moderation.reports.enabledBy')
},
moderatorOfLatestReview() {
return this.report.reviewed[0].moderator
},
},
}
</script>
<style lang="scss">
.report-row {
&:nth-child(2n + 1) {
background-color: $color-neutral-95;
}
.title {
font-weight: $font-weight-bold;
}
.status-line {
display: inline-flex;
> .base-icon {
margin-right: $space-xx-small;
}
}
.user-count {
display: block;
margin-bottom: $space-xx-small;
}
.--disabled {
color: $color-danger;
}
.--enabled {
color: $color-success;
}
}
</style>

View File

@ -0,0 +1,62 @@
import { config, mount } from '@vue/test-utils'
import Vuex from 'vuex'
import ReportsTable from './ReportsTable.vue'
import { reports } from '~/components/features/ReportList/ReportList.story.js'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('ReportsTable', () => {
let propsData, mocks, getters, wrapper, reportsTable
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(string => string),
}
getters = {
'auth/user': () => {
return { slug: 'awesome-user' }
},
'auth/isModerator': () => true,
}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(ReportsTable, { propsData, mocks, localVue, store })
}
describe('given no reports', () => {
beforeEach(() => {
propsData = { ...propsData, reports: [] }
wrapper = Wrapper()
})
it('shows a placeholder', () => {
expect(wrapper.text()).toContain('moderation.reports.empty')
})
})
describe('given reports', () => {
beforeEach(() => {
propsData = { ...propsData, reports }
wrapper = Wrapper()
reportsTable = wrapper.find('.ds-table')
})
it('renders a table', () => {
expect(reportsTable.exists()).toBe(true)
})
it('renders at least one ReportRow component', () => {
expect(wrapper.find('.report-row').exists()).toBe(true)
})
})
})
})

Some files were not shown because too many files have changed in this diff Show More