diff --git a/.gitbook/assets/browserstack-logo.svg b/.gitbook/assets/browserstack-logo.svg new file mode 100644 index 000000000..195f64d2f --- /dev/null +++ b/.gitbook/assets/browserstack-logo.svg @@ -0,0 +1,90 @@ + + + + +Browserstack-logo-white + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.travis.yml b/.travis.yml index 1cc36fca4..a2aae3cdb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,13 +8,13 @@ addons: - docker - chromium -before_install: +install: - yarn global add wait-on # Install Codecov - yarn install - 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 -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 @@ -30,10 +30,6 @@ script: - 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: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 - docker-compose exec webapp yarn run lint - docker-compose exec webapp yarn run test --ci --verbose=false --coverage @@ -42,6 +38,7 @@ script: - docker-compose -f docker-compose.yml up -d - wait-on http://localhost:7474 - yarn run cypress:run --record + - yarn run cucumber # Coverage - yarn run codecov diff --git a/README.md b/README.md index 115a6760c..eaf71acb8 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,11 @@ Check out the [contribution guideline](./CONTRIBUTING.md), too! ## Attributions -Locale Icons made by [Freepik](http://www.freepik.com/) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) +Locale Icons made by [Freepik](http://www.freepik.com/) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/). + +Browser compatibility testing with [BrowserStack](https://www.browserstack.com/). + +BrowserStack Logo ## License See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 000000000..2d91b3635 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "10" + } + } + ] + ] +} diff --git a/backend/.babelrc b/backend/babel.config.json similarity index 100% rename from backend/.babelrc rename to backend/babel.config.json diff --git a/backend/package.json b/backend/package.json index 5016d7c9f..930c7d205 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,8 +10,6 @@ "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "lint": "eslint src --config .eslintrc.js", "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:seed": "babel-node src/seed/seed-db.js" }, @@ -35,7 +33,7 @@ }, "dependencies": { "@hapi/joi": "^16.1.8", - "@sentry/node": "^5.9.0", + "@sentry/node": "^5.10.0", "apollo-cache-inmemory": "~1.6.3", "apollo-client": "~2.6.4", "apollo-link-context": "~1.0.19", @@ -86,7 +84,7 @@ "neo4j-graphql-js": "^2.9.3", "neode": "^0.3.3", "node-fetch": "~2.6.0", - "nodemailer": "^6.3.1", + "nodemailer": "^6.4.0", "nodemailer-html-to-text": "^3.1.0", "npm-run-all": "~4.1.5", "request": "~2.88.0", diff --git a/backend/src/activitypub/routes/index.js b/backend/src/activitypub/routes/index.js index c7d31f1c4..fb4037004 100644 --- a/backend/src/activitypub/routes/index.js +++ b/backend/src/activitypub/routes/index.js @@ -1,27 +1,29 @@ import user from './user' import inbox from './inbox' -import webFinger from './webFinger' import express from 'express' import cors from 'cors' import verify from './verify' -const router = express.Router() - -router.use('/.well-known/webFinger', cors(), express.urlencoded({ extended: true }), webFinger) -router.use( - '/activitypub/users', - cors(), - express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), - express.urlencoded({ extended: true }), - user, -) -router.use( - '/activitypub/inbox', - cors(), - express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), - express.urlencoded({ extended: true }), - verify, - inbox, -) - -export default router +export default function() { + const router = express.Router() + router.use( + '/activitypub/users', + cors(), + express.json({ + type: ['application/activity+json', 'application/ld+json', 'application/json'], + }), + express.urlencoded({ extended: true }), + user, + ) + router.use( + '/activitypub/inbox', + cors(), + express.json({ + type: ['application/activity+json', 'application/ld+json', 'application/json'], + }), + express.urlencoded({ extended: true }), + verify, + inbox, + ) + return router +} diff --git a/backend/src/activitypub/routes/webFinger.js b/backend/src/activitypub/routes/webFinger.js deleted file mode 100644 index 7d52c69cd..000000000 --- a/backend/src/activitypub/routes/webFinger.js +++ /dev/null @@ -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 diff --git a/backend/src/activitypub/routes/webfinger.js b/backend/src/activitypub/routes/webfinger.js new file mode 100644 index 000000000..c2cb96a6d --- /dev/null +++ b/backend/src/activitypub/routes/webfinger.js @@ -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:@" 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 +} diff --git a/backend/src/activitypub/routes/webfinger.spec.js b/backend/src/activitypub/routes/webfinger.spec.js new file mode 100644 index 000000000..4e9b2196d --- /dev/null +++ b/backend/src/activitypub/routes/webfinger.spec.js @@ -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:@" 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:@" 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:@" 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', + }) + }) + }) + }) + }) +}) diff --git a/backend/src/activitypub/utils/actor.js b/backend/src/activitypub/utils/actor.js index a08065778..e07397bdc 100644 --- a/backend/src/activitypub/utils/actor.js +++ b/backend/src/activitypub/utils/actor.js @@ -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}`, - }, - ], - } -} diff --git a/backend/src/config/index.js b/backend/src/config/index.js index b67cea0f9..80573e180 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -1,6 +1,7 @@ import dotenv from 'dotenv' +import path from 'path' -dotenv.config() +dotenv.config({ path: path.resolve(__dirname, '../../.env') }) const { MAPBOX_TOKEN, diff --git a/backend/src/jwt/decode.js b/backend/src/jwt/decode.js index 842f8f537..5b7881d20 100644 --- a/backend/src/jwt/decode.js +++ b/backend/src/jwt/decode.js @@ -11,15 +11,21 @@ export default async (driver, authorizationHeader) => { } catch (err) { return null } - const session = driver.session() const query = ` MATCH (user:User {id: $id, deleted: false, disabled: false }) SET user.lastActiveAt = toString(datetime()) RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} LIMIT 1 ` - const result = await session.run(query, { id }) - session.close() + const session = driver.session() + let result + + try { + result = await session.run(query, { id }) + } finally { + session.close() + } + const [currentUser] = await result.records.map(record => { return record.get('user') }) diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.js b/backend/src/middleware/hashtags/hashtagsMiddleware.js index c9156398d..53a8fed20 100644 --- a/backend/src/middleware/hashtags/hashtagsMiddleware.js +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.js @@ -3,7 +3,6 @@ import extractHashtags from '../hashtags/extractHashtags' const updateHashtagsOfPost = async (postId, hashtags, context) => { if (!hashtags.length) return - const session = context.driver.session() // 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 // and no new Hashtags and relations will be created. @@ -19,14 +18,18 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => { MERGE (p)-[:TAGGED]->(t) RETURN p, t ` - await session.run(cypherDeletePreviousRelations, { - postId, - }) - await session.run(cypherCreateNewTagsAndRelations, { - postId, - hashtags, - }) - session.close() + const session = context.driver.session() + try { + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { + postId, + hashtags, + }) + } finally { + session.close() + } } const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index 718f0b1e4..ac199a67d 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -1,15 +1,19 @@ import extractMentionedUsers from './mentions/extractMentionedUsers' const postAuthorOfComment = async (comment, { context }) => { - const session = context.driver.session() const cypherFindUser = ` MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) RETURN user { .id } ` - const result = await session.run(cypherFindUser, { - commentId: comment.id, - }) - session.close() + const session = context.driver.session() + let result + try { + result = await session.run(cypherFindUser, { + commentId: comment.id, + }) + } finally { + session.close() + } const [postAuthor] = await result.records.map(record => { 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!') } - const session = context.driver.session() let cypher switch (reason) { case 'mentioned_in_post': { @@ -85,12 +88,16 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => { break } } - await session.run(cypher, { - id, - idsOfUsers, - reason, - }) - session.close() + const session = context.driver.session() + try { + await session.run(cypher, { + id, + idsOfUsers, + reason, + }) + } finally { + session.close() + } } 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) if (comment) { - const session = context.driver.session() const cypherFindUser = ` MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) RETURN user { .id } ` - const result = await session.run(cypherFindUser, { - commentId: comment.id, - }) - session.close() + const session = context.driver.session() + let result + try { + result = await session.run(cypherFindUser, { + commentId: comment.id, + }) + } finally { + session.close() + } const [postAuthor] = await result.records.map(record => { return record.get('user') }) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js index 502ddaa8e..2122d009b 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -371,7 +371,7 @@ describe('notifications', () => { expect(readAfter).toEqual(false) }) - it('updates the `createdAt` attribute', async () => { + it('does not update the `createdAt` attribute', async () => { await createPostAction() await markAsReadAction() const { diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index e729123c9..8e4569a52 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -45,8 +45,8 @@ const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { if (!user) return false - const session = driver.session() const { id: resourceId } = args + const session = driver.session() try { const result = await session.run( ` @@ -112,7 +112,7 @@ export default shield( CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, - report: isAuthenticated, + fileReport: isAuthenticated, CreateSocialMedia: isAuthenticated, UpdateSocialMedia: isMySocialMedia, DeleteSocialMedia: isMySocialMedia, @@ -125,8 +125,7 @@ export default shield( shout: isAuthenticated, unshout: isAuthenticated, changePassword: isAuthenticated, - enable: isModerator, - disable: isModerator, + review: isModerator, CreateComment: isAuthenticated, UpdateComment: isAuthor, DeleteComment: isAuthor, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index fed9b4da7..cda3fd335 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -3,11 +3,14 @@ import uniqueSlug from './slugify/uniqueSlug' const isUniqueFor = (context, type) => { return async slug => { const session = context.driver.session() - const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, { - slug, - }) - session.close() - return response.records.length === 0 + try { + const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, { + slug, + }) + return response.records.length === 0 + } finally { + session.close() + } } } diff --git a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js index 6da080ebb..1c97cb874 100644 --- a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js @@ -8,14 +8,8 @@ const factory = Factory() const neode = getNeode() const driver = getDriver() -let query -let mutate -let graphqlQuery const categoryIds = ['cat9'] -let authenticatedUser -let user -let moderator -let troll +let query, graphqlQuery, authenticatedUser, user, moderator, troll const action = () => { return query({ query: graphqlQuery }) @@ -38,18 +32,17 @@ beforeAll(async () => { avatar: '/some/offensive/avatar.jpg', about: 'This self description is very offensive', }), + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), ]) user = users[0] moderator = users[1] troll = users[2] - await neode.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) - await Promise.all([ user.relateTo(troll, 'following'), factory.create('Post', { @@ -70,33 +63,32 @@ beforeAll(async () => { }), ]) - await Promise.all([ + const resources = await Promise.all([ factory.create('Comment', { author: user, id: 'c2', postId: 'p3', 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({ context: () => { return { @@ -108,20 +100,57 @@ beforeAll(async () => { }) const client = createTestClient(server) query = client.query - mutate = client.mutate - authenticatedUser = await moderator.toJson() - const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } - ` - await Promise.all([ - mutate({ mutation: disableMutation, variables: { id: 'c1' } }), - mutate({ mutation: disableMutation, variables: { id: 'u2' } }), - mutate({ mutation: disableMutation, variables: { id: 'p2' } }), + const trollingPost = resources[1] + const trollingComment = resources[2] + + const reports = await Promise.all([ + factory.create('Report'), + factory.create('Report'), + factory.create('Report'), + ]) + const reportAgainstTroll = reports[0] + 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 () => { diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index bd4805ed8..f36458e61 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -5,7 +5,7 @@ const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_CATEGORIES_ERR_MESSAGE = '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 { 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!`) } const session = context.driver.session() - const postQueryRes = await session.run( - ` + try { + const postQueryRes = await session.run( + ` MATCH (post:Post {id: $postId}) RETURN post`, - { - postId, - }, - ) - session.close() - const [post] = postQueryRes.records.map(record => { - return record.get('post') - }) + { + postId, + }, + ) + const [post] = postQueryRes.records.map(record => { + return record.get('post') + }) - if (!post) { - throw new UserInputError(NO_POST_ERR_MESSAGE) - } else { - return resolve(root, args, context, info) + if (!post) { + throw new UserInputError(NO_POST_ERR_MESSAGE) + } else { + return resolve(root, args, context, info) + } + } finally { + session.close() } } const validateUpdateComment = async (resolve, root, args, context, info) => { - const COMMENT_MIN_LENGTH = 1 const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() if (!args.content || content.length < COMMENT_MIN_LENGTH) { 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 { resourceId } = args - const { user, driver } = context + const { user } = context if (resourceId === user.id) throw new Error('You cannot report yourself!') - const session = driver.session() - const reportQueryRes = await session.run( - ` - MATCH (:User {id:$submitterId})-[:REPORTED]->(resource {id:$resourceId}) - RETURN labels(resource)[0] as label - `, - { - resourceId, - submitterId: user.id, - }, - ) - session.close() - const [existingReportedResource] = reportQueryRes.records.map(record => { - return { - label: record.get('label'), - } - }) + return resolve(root, args, context, info) +} + +const validateReview = async (resolve, root, args, context, info) => { + const { resourceId } = args + let existingReportedResource + const { user, driver } = context + if (resourceId === user.id) throw new Error('You cannot review yourself!') + const session = driver.session() + const reportReadTxPromise = session.writeTransaction(async txc => { + const validateReviewTransactionResponse = await txc.run( + ` + MATCH (resource {id: $resourceId}) + WHERE resource:User OR resource:Post OR resource:Comment + 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) } export default { Mutation: { - CreateComment: validateCommentCreation, + CreateComment: validateCreateComment, UpdateComment: validateUpdateComment, CreatePost: validatePost, UpdatePost: validateUpdatePost, - report: validateReport, + fileReport: validateReport, + review: validateReview, }, } diff --git a/backend/src/middleware/validation/validationMiddleware.spec.js b/backend/src/middleware/validation/validationMiddleware.spec.js new file mode 100644 index 000000000..97bb6254b --- /dev/null +++ b/backend/src/middleware/validation/validationMiddleware.spec.js @@ -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: '' } + 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: '' } + 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!' }], + }) + }) + }) +}) diff --git a/backend/src/models/Comment.js b/backend/src/models/Comment.js index c89103e5d..54cbda675 100644 --- a/backend/src/models/Comment.js +++ b/backend/src/models/Comment.js @@ -25,12 +25,6 @@ module.exports = { target: 'User', direction: 'in', }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, notified: { type: 'relationship', relationship: 'NOTIFIED', diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index af2baf604..14171b88b 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -17,12 +17,6 @@ module.exports = { image: { type: 'string', allow: [null] }, deleted: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, notified: { type: 'relationship', relationship: 'NOTIFIED', @@ -46,4 +40,5 @@ module.exports = { }, language: { type: 'string', allow: [null] }, blurImage: { type: 'boolean', default: false }, + imageAspectRatio: { type: 'float', default: 1.0 }, } diff --git a/backend/src/models/Report.js b/backend/src/models/Report.js new file mode 100644 index 000000000..2ace4ea73 --- /dev/null +++ b/backend/src/models/Report.js @@ -0,0 +1,52 @@ +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' }, + 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 }, + }, + }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index fd6e88c27..32f053e2b 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -42,12 +42,6 @@ module.exports = { }, }, friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, rewarded: { type: 'relationship', relationship: 'REWARDED', diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 239076adc..0b9378162 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -12,4 +12,5 @@ export default { Tag: require('./Tag.js'), Location: require('./Location.js'), Donations: require('./Donations.js'), + Report: require('./Report.js'), } diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index 4252bd817..274697238 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -17,7 +17,9 @@ export default makeAugmentedSchema({ 'Location', 'SocialMedia', 'NOTIFIED', - 'REPORTED', + 'FILED', + 'REVIEWED', + 'Report', 'Donations', ], }, diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index e0b69b153..97b461511 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -13,7 +13,8 @@ export default { params.id = params.id || uuid() const session = context.driver.session() - const createCommentCypher = ` + try { + const createCommentCypher = ` MATCH (post:Post {id: $postId}) MATCH (author:User {id: $userId}) WITH post, author @@ -23,45 +24,53 @@ export default { MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) RETURN comment ` - const transactionRes = await session.run(createCommentCypher, { - userId: context.user.id, - postId, - params, - }) - session.close() + const transactionRes = await session.run(createCommentCypher, { + userId: context.user.id, + postId, + params, + }) - 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) => { const session = context.driver.session() - const updateCommentCypher = ` + try { + const updateCommentCypher = ` MATCH (comment:Comment {id: $params.id}) SET comment += $params SET comment.updatedAt = toString(datetime()) RETURN comment ` - const transactionRes = await session.run(updateCommentCypher, { params }) - session.close() - const [comment] = transactionRes.records.map(record => record.get('comment').properties) - return comment + const transactionRes = await session.run(updateCommentCypher, { params }) + const [comment] = transactionRes.records.map(record => record.get('comment').properties) + return comment + } finally { + session.close() + } }, DeleteComment: async (_parent, args, context, _resolveInfo) => { const session = context.driver.session() - const transactionRes = await session.run( - ` + try { + const transactionRes = await session.run( + ` MATCH (comment:Comment {id: $commentId}) SET comment.deleted = TRUE SET comment.content = 'UNAVAILABLE' SET comment.contentExcerpt = 'UNAVAILABLE' RETURN comment `, - { commentId: args.id }, - ) - session.close() - const [comment] = transactionRes.records.map(record => record.get('comment').properties) - return comment + { commentId: args.id }, + ) + const [comment] = transactionRes.records.map(record => record.get('comment').properties) + return comment + } finally { + session.close() + } }, }, Comment: { @@ -69,7 +78,6 @@ export default { hasOne: { author: '<-[:WROTE]-(related:User)', post: '-[:COMMENTS]->(related:Post)', - disabledBy: '<-[:DISABLED]-(related:User)', }, }), }, diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index c0f86ffe3..d2692aa8a 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -111,42 +111,6 @@ describe('CreateComment', () => { }, ) }) - - describe('comment content is empty', () => { - beforeEach(() => { - variables = { ...variables, content: '

' } - }) - - 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: '

' } - }) - - 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) }) - describe('if `content` empty', () => { - beforeEach(() => { - variables = { ...variables, content: '

' } - }) - - 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', () => { beforeEach(() => { variables = { ...variables, id: 'does-not-exist' } diff --git a/backend/src/schema/resolvers/donations.js b/backend/src/schema/resolvers/donations.js index 88149077d..3052ff13d 100644 --- a/backend/src/schema/resolvers/donations.js +++ b/backend/src/schema/resolvers/donations.js @@ -2,8 +2,8 @@ export default { Mutation: { UpdateDonations: async (_parent, params, context, _resolveInfo) => { const { driver } = context - const session = driver.session() let donations + const session = driver.session() const writeTxResultPromise = session.writeTransaction(async txc => { const updateDonationsTransactionResponse = await txc.run( ` diff --git a/backend/src/schema/resolvers/helpers/createPasswordReset.js b/backend/src/schema/resolvers/helpers/createPasswordReset.js index d73cfaa81..41214b501 100644 --- a/backend/src/schema/resolvers/helpers/createPasswordReset.js +++ b/backend/src/schema/resolvers/helpers/createPasswordReset.js @@ -4,7 +4,6 @@ export default async function createPasswordReset(options) { const { driver, nonce, email, issuedAt = new Date() } = options const normalizedEmail = normalizeEmail(email) const session = driver.session() - let response = {} try { const cypher = ` 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 return { email, nonce, name } }) - response = records[0] || {} + return records[0] || {} } finally { session.close() } - return response } diff --git a/backend/src/schema/resolvers/moderation.js b/backend/src/schema/resolvers/moderation.js index d61df7545..4bdf82d50 100644 --- a/backend/src/schema/resolvers/moderation.js +++ b/backend/src/schema/resolvers/moderation.js @@ -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 { Mutation: { - disable: async (object, params, { user, driver }) => { - const { id } = params - const { id: userId } = user - const cypher = ` - 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} - ` + review: async (_object, params, context, _resolveInfo) => { + const { user: moderator, driver } = context + + let createdRelationshipWithNestedAttributes = null // return value const session = driver.session() - const res = await session.run(cypher, { id, userId }) - session.close() - const [resource] = res.records.map(record => { - return record.get('resource') - }) - if (!resource) return null - return resource.id - }, - enable: async (object, params, { user, driver }) => { - const { id } = params - const cypher = ` - MATCH (resource {id: $id})<-[d:DISABLED]-() - SET resource.disabled = false - DELETE d - RETURN resource {.id} - ` - const session = driver.session() - const res = await session.run(cypher, { id }) - session.close() - const [resource] = res.records.map(record => { - return record.get('resource') - }) - if (!resource) return null - return resource.id + try { + const cypher = ` + MATCH (moderator:User {id: $moderatorId}) + MATCH (resource {id: $params.resourceId})<-[:BELONGS_TO]-(report:Report {closed: false}) + WHERE resource:User OR resource:Post OR resource:Comment + MERGE (report)<-[review:REVIEWED]-(moderator) + ON CREATE SET review.createdAt = $dateTime, review.updatedAt = review.createdAt + ON MATCH SET review.updatedAt = $dateTime + SET review.disable = $params.disable + SET report.updatedAt = $dateTime, report.closed = $params.closed + SET resource.disabled = review.disable + + RETURN review, report, resource, labels(resource)[0] AS type + ` + const reviewWriteTxResultPromise = session.writeTransaction(async txc => { + const reviewTransactionResponse = await txc.run(cypher, { + params, + moderatorId: moderator.id, + dateTime: new Date().toISOString(), + }) + return reviewTransactionResponse.records.map(transformReturnType) + }) + const txResult = await reviewWriteTxResultPromise + if (!txResult[0]) return null + createdRelationshipWithNestedAttributes = txResult[0] + } finally { + session.close() + } + + return createdRelationshipWithNestedAttributes }, }, } diff --git a/backend/src/schema/resolvers/moderation.spec.js b/backend/src/schema/resolvers/moderation.spec.js index 765126c52..5e280a6f5 100644 --- a/backend/src/schema/resolvers/moderation.spec.js +++ b/backend/src/schema/resolvers/moderation.spec.js @@ -8,45 +8,53 @@ const factory = Factory() const neode = getNeode() const driver = getDriver() -let query, mutate, authenticatedUser, variables, moderator, nonModerator +let mutate, + authenticatedUser, + disableVariables, + enableVariables, + moderator, + nonModerator, + closeReportVariables -const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } -` -const enableMutation = gql` - mutation($id: ID!) { - enable(id: $id) - } -` - -const commentQuery = gql` - query($id: ID!) { - Comment(id: $id) { - id - disabled - disabledBy { - id +const reviewMutation = gql` + mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) { + review(resourceId: $resourceId, disable: $disable, closed: $closed) { + createdAt + updatedAt + resource { + __typename + ... on User { + id + disabled + } + ... on Post { + id + disabled + } + ... on Comment { + id + disabled + } } - } - } -` - -const postQuery = gql` - query($id: ID) { - Post(id: $id) { - id - disabled - disabledBy { + report { id + createdAt + updatedAt + closed + reviewed { + createdAt + moderator { + id + } + } } } } ` describe('moderate resources', () => { - beforeAll(() => { + beforeAll(async () => { + await factory.cleanDatabase() authenticatedUser = undefined const { server } = createServer({ context: () => { @@ -58,11 +66,19 @@ describe('moderate resources', () => { }, }) mutate = createTestClient(server).mutate - query = createTestClient(server).query }) beforeEach(async () => { - variables = {} + disableVariables = { + resourceId: 'undefined-resource', + disable: true, + closed: false, + } + enableVariables = { + resourceId: 'undefined-resource', + disable: false, + closed: false, + } authenticatedUser = null moderator = await factory.create('User', { id: 'moderator-id', @@ -71,155 +87,392 @@ describe('moderate resources', () => { password: '1234', role: 'moderator', }) + nonModerator = await factory.create('User', { + id: 'non-moderator', + name: 'Non Moderator', + email: 'non.moderator@example.org', + password: '1234', + }) }) afterEach(async () => { await factory.cleanDatabase() }) - describe('disable', () => { - beforeEach(() => { - variables = { - id: 'some-resource', - } - }) + describe('review to close report, leaving resource enabled', () => { describe('unauthenticated', () => { 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!' }], }) }) }) + describe('authenticated', () => { - describe('non moderator', () => { - beforeEach(async () => { - nonModerator = await factory.create('User', { - id: 'non-moderator', - name: 'Non Moderator', - email: 'non.moderator@example.org', - password: '1234', - }) - authenticatedUser = await nonModerator.toJson() + 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!' }], }) - 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 () => { - 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) ', () => { - beforeEach(async () => { - variables = { - id: 'sample-tag-id', - } - await factory.create('Tag', { id: 'sample-tag-id' }) - }) - - it('returns null', async () => { - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: null }, - }) + it('returns disabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + }, + }, + errors: undefined, }) }) - describe('moderate a comment', () => { - beforeEach(async () => { - variables = {} - await factory.create('Comment', { - id: 'comment-id', - }) - }) - - it('returns disabled resource id', async () => { - variables = { id: 'comment-id' } - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'comment-id' }, - 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) + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, + errors: undefined, }) }) - describe('moderate a post', () => { - beforeEach(async () => { - variables = {} - await factory.create('Post', { - id: 'sample-post-id', - }) + it('updates .disabled on post', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Post', id: 'post-id', disabled: true } } }, + errors: undefined, }) + }) - it('returns disabled resource id', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'sample-post-id' }, - }) + 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: 'Post', id: 'post-id' }, + report: { id: expect.any(String), closed: true }, + }, + }, + errors: undefined, }) + }) + }) - it('changes .disabledBy', async () => { - variables = { id: 'sample-post-id' } - const before = { data: { Post: [{ id: 'sample-post-id', disabledBy: null }] } } - const expected = { - 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) + 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'), + ]) + disableVariables = { + ...disableVariables, + resourceId: 'user-id', + } + }) - it('updates .disabled on post', async () => { - const before = { data: { Post: [{ id: 'sample-post-id', disabled: false }] } } - const expected = { data: { Post: [{ id: 'sample-post-id', disabled: true }] } } - variables = { id: 'sample-post-id' } + it('returns disabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'User', id: 'user-id' } } }, + errors: undefined, + }) + }) - 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) + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).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' } }, + ]), + }, + }, + }, + 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', () => { it('throws authorization error', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ + enableVariables = { + ...enableVariables, + resourceId: 'post-id', + } + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], }) }) @@ -228,17 +481,17 @@ describe('moderate resources', () => { describe('authenticated user', () => { describe('non moderator', () => { beforeEach(async () => { - nonModerator = await factory.create('User', { - id: 'non-moderator', - name: 'Non Moderator', - email: 'non.moderator@example.org', - password: '1234', - }) authenticatedUser = await nonModerator.toJson() }) + it('throws authorization error', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ + enableVariables = { + ...enableVariables, + resourceId: 'post-id', + } + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], }) }) @@ -248,101 +501,197 @@ describe('moderate resources', () => { beforeEach(async () => { 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', () => { beforeEach(async () => { - variables = { id: 'comment-id' } - await factory.create('Comment', { + const trollingComment = await factory.create('Comment', { 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 () => { - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Comment', id: 'comment-id' } } }, }) }) - it('changes .disabledBy', async () => { - const expected = { - data: { Comment: [{ id: 'comment-id', disabledBy: null }] }, - errors: undefined, - } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).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' } }, + ]), + }, + }, + }, }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) }) it('updates .disabled on comment', async () => { - const expected = { - data: { Comment: [{ id: 'comment-id', disabled: false }] }, - errors: undefined, - } - - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'Comment', id: 'comment-id', disabled: false } }, + }, }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) }) }) describe('moderate a post', () => { beforeEach(async () => { - variables = { id: 'post-id' } - await factory.create('Post', { + const trollingPost = await factory.create('Post', { 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 () => { - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Post', id: 'post-id' } } }, }) }) - it('changes .disabledBy', async () => { - const expected = { - data: { Post: [{ id: 'post-id', disabledBy: null }] }, - errors: undefined, - } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + 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 () => { - const expected = { - data: { Post: [{ id: 'post-id', disabled: false }] }, - errors: undefined, - } - - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'Post', id: 'post-id', disabled: false } }, + }, + }) + }) + }) + + 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) }) }) }) diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index 9e6f5c91a..7f9c52e1e 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -18,7 +18,7 @@ export default { notifications: async (_parent, args, context, _resolveInfo) => { const { user: currentUser } = context const session = context.driver.session() - let notifications, whereClause, orderByClause + let whereClause, orderByClause switch (args.read) { case true: @@ -42,27 +42,25 @@ export default { } const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : '' 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}) ${whereClause} RETURN resource, notification, user ${orderByClause} ${offset} ${limit} ` + try { const result = await session.run(cypher, { id: currentUser.id }) - notifications = await result.records.map(transformReturnType) + return result.records.map(transformReturnType) } finally { session.close() } - return notifications }, }, Mutation: { markAsRead: async (parent, args, context, resolveInfo) => { const { user: currentUser } = context const session = context.driver.session() - let notification try { const cypher = ` 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 notifications = await result.records.map(transformReturnType) - notification = notifications[0] + return notifications[0] } finally { session.close() } - return notification }, }, NOTIFIED: { diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index 7c0d9e747..dfbfe8183 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -9,7 +9,6 @@ export default { return createPasswordReset({ driver, nonce, email }) }, resetPassword: async (_parent, { email, nonce, newPassword }, { driver }) => { - const session = driver.session() const stillValid = new Date() stillValid.setDate(stillValid.getDate() - 1) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) @@ -21,16 +20,20 @@ export default { SET u.encryptedPassword = $encryptedNewPassword RETURN pr ` - const transactionRes = await session.run(cypher, { - stillValid, - email, - nonce, - encryptedNewPassword, - }) - const [reset] = transactionRes.records.map(record => record.get('pr')) - const response = !!(reset && reset.properties.usedAt) - session.close() - return response + const session = driver.session() + try { + const transactionRes = await session.run(cypher, { + stillValid, + email, + nonce, + encryptedNewPassword, + }) + const [reset] = transactionRes.records.map(record => record.get('pr')) + const response = !!(reset && reset.properties.usedAt) + return response + } finally { + session.close() + } }, }, } diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js index 8b36b8c85..97aa6a020 100644 --- a/backend/src/schema/resolvers/passwordReset.spec.js +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -15,10 +15,13 @@ let variables const getAllPasswordResets = async () => { const session = driver.session() - const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') - const resets = transactionRes.records.map(record => record.get('r')) - session.close() - return resets + try { + const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') + const resets = transactionRes.records.map(record => record.get('r')) + return resets + } finally { + session.close() + } } beforeEach(() => { diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e805a6a91..3eeab182c 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -5,6 +5,7 @@ import { getBlockedUsers, getBlockedByUsers } from './users.js' import { mergeWith, isArray, isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' + const filterForBlockedUsers = async (params, context) => { if (!context.user) return params const [blockedUsers, blockedByUsers] = await Promise.all([ @@ -54,37 +55,41 @@ export default { return neo4jgraphql(object, params, context, resolveInfo) }, PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { - const session = context.driver.session() const { postId, data } = params - const transactionRes = await session.run( - `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + const session = context.driver.session() + try { + const transactionRes = await session.run( + `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() RETURN COUNT(DISTINCT emoted) as emotionsCount `, - { postId, data }, - ) - session.close() + { postId, data }, + ) - const [emotionsCount] = transactionRes.records.map(record => { - return record.get('emotionsCount').low - }) - - return emotionsCount + const [emotionsCount] = transactionRes.records.map(record => { + return record.get('emotionsCount').low + }) + return emotionsCount + } finally { + session.close() + } }, PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { - const session = context.driver.session() const { postId } = params - const transactionRes = await session.run( - `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + const session = context.driver.session() + try { + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) 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') - }) - return emotions + const [emotions] = transactionRes.records.map(record => { + return record.get('emotion') + }) + return emotions + } finally { + session.close() + } }, }, Mutation: { @@ -93,8 +98,6 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() - let post - const createPostCypher = `CREATE (post:Post {params}) SET post.createdAt = toString(datetime()) SET post.updatedAt = toString(datetime()) @@ -113,7 +116,7 @@ export default { try { const transactionRes = await session.run(createPostCypher, createPostVariables) const posts = transactionRes.records.map(record => record.get('post').properties) - post = posts[0] + return posts[0] } catch (e) { if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') throw new UserInputError('Post with this slug already exists!') @@ -121,55 +124,55 @@ export default { } finally { session.close() } - - return post }, UpdatePost: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - const session = context.driver.session() let updatePostCypher = `MATCH (post:Post {id: $params.id}) SET post += $params SET post.updatedAt = toString(datetime()) WITH post ` - if (categoryIds && categoryIds.length) { - const cypherDeletePreviousRelations = ` + const session = context.driver.session() + try { + if (categoryIds && categoryIds.length) { + const cypherDeletePreviousRelations = ` MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) DELETE previousRelations RETURN post, category ` - await session.run(cypherDeletePreviousRelations, { params }) + await session.run(cypherDeletePreviousRelations, { params }) - updatePostCypher += ` + updatePostCypher += ` UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) 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) => { const session = context.driver.session() - // we cannot set slug to 'UNAVAILABE' because of unique constraints - const transactionRes = await session.run( - ` + try { + // we cannot set slug to 'UNAVAILABE' because of unique constraints + const transactionRes = await session.run( + ` MATCH (post:Post {id: $postId}) OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) SET post.deleted = TRUE @@ -180,51 +183,60 @@ export default { REMOVE post.image RETURN post `, - { postId: args.id }, - ) - session.close() - const [post] = transactionRes.records.map(record => record.get('post').properties) - return post + { postId: args.id }, + ) + const [post] = transactionRes.records.map(record => record.get('post').properties) + return post + } finally { + session.close() + } }, AddPostEmotions: async (object, params, context, resolveInfo) => { - const session = context.driver.session() const { to, data } = params const { user } = context - const transactionRes = await session.run( - `MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id}) + const session = context.driver.session() + 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) RETURN userFrom, postTo, emotedRelation`, - { user, to, data }, - ) - session.close() - const [emoted] = transactionRes.records.map(record => { - return { - from: { ...record.get('userFrom').properties }, - to: { ...record.get('postTo').properties }, - ...record.get('emotedRelation').properties, - } - }) - return emoted + { user, to, data }, + ) + + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + ...record.get('emotedRelation').properties, + } + }) + return emoted + } finally { + session.close() + } }, RemovePostEmotions: async (object, params, context, resolveInfo) => { - const session = context.driver.session() const { to, data } = params const { id: from } = context.user - const transactionRes = await session.run( - `MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) + const session = context.driver.session() + try { + const transactionRes = await session.run( + `MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) DELETE emotedRelation RETURN userFrom, postTo`, - { from, to, data }, - ) - session.close() - const [emoted] = transactionRes.records.map(record => { - return { - from: { ...record.get('userFrom').properties }, - to: { ...record.get('postTo').properties }, - emotion: data.emotion, - } - }) - return emoted + { from, to, data }, + ) + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + emotion: data.emotion, + } + }) + return emoted + } finally { + session.close() + } }, pinPost: async (_parent, params, context, _resolveInfo) => { let pinnedPostWithNestedAttributes @@ -242,25 +254,25 @@ export default { ) return deletePreviousRelationsResponse.records.map(record => record.get('post').properties) }) - await writeTxResultPromise + try { + await writeTxResultPromise - writeTxResultPromise = session.writeTransaction(async transaction => { - const pinPostTransactionResponse = await transaction.run( - ` + writeTxResultPromise = session.writeTransaction(async transaction => { + const pinPostTransactionResponse = await transaction.run( + ` MATCH (user:User {id: $userId}) WHERE user.role = 'admin' MATCH (post:Post {id: $params.id}) MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) SET post.pinned = true RETURN post, pinned.createdAt as pinnedAt `, - { userId, params }, - ) - return pinPostTransactionResponse.records.map(record => ({ - pinnedPost: record.get('post').properties, - pinnedAt: record.get('pinnedAt'), - })) - }) - try { + { userId, params }, + ) + return pinPostTransactionResponse.records.map(record => ({ + pinnedPost: record.get('post').properties, + pinnedAt: record.get('pinnedAt'), + })) + }) const [transactionResult] = await writeTxResultPromise const { pinnedPost, pinnedAt } = transactionResult pinnedPostWithNestedAttributes = { @@ -305,6 +317,7 @@ export default { 'pinnedAt', 'pinned', 'blurImage', + 'imageAspectRatio', ], hasMany: { tags: '-[:TAGGED]->(related:Tag)', @@ -315,7 +328,6 @@ export default { }, hasOne: { author: '<-[:WROTE]-(related:User)', - disabledBy: '<-[:DISABLED]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)', }, count: { diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index d6a97191d..98475b182 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -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, ) }) - - 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', - ) - }) - }) }) }) diff --git a/backend/src/schema/resolvers/reports.js b/backend/src/schema/resolvers/reports.js index 083c94362..fc93229ae 100644 --- a/backend/src/schema/resolvers/reports.js +++ b/backend/src/schema/resolvers/reports.js @@ -1,18 +1,32 @@ +const transformReturnType = record => { + return { + ...record.get('report').properties, + resource: { + __typename: record.get('type'), + ...record.get('resource').properties, + }, + } +} + export default { Mutation: { - report: async (_parent, params, context, _resolveInfo) => { + fileReport: async (_parent, params, context, _resolveInfo) => { let createdRelationshipWithNestedAttributes const { resourceId, reasonCategory, reasonDescription } = params const { driver, user } = context const session = driver.session() - const writeTxResultPromise = session.writeTransaction(async txc => { - const reportRelationshipTransactionResponse = await txc.run( + const reportWriteTxResultPromise = session.writeTransaction(async txc => { + const reportTransactionResponse = await txc.run( ` MATCH (submitter:User {id: $submitterId}) MATCH (resource {id: $resourceId}) - WHERE resource:User OR resource:Comment OR resource:Post - CREATE (resource)<-[report:REPORTED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter) - RETURN report, submitter, resource, labels(resource)[0] as type + WHERE resource:User OR resource:Post OR resource:Comment + MERGE (resource)<-[:BELONGS_TO]-(report:Report {closed: false}) + 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, @@ -22,36 +36,12 @@ export default { reasonDescription, }, ) - return reportRelationshipTransactionResponse.records.map(record => ({ - report: record.get('report'), - submitter: record.get('submitter'), - resource: record.get('resource').properties, - type: record.get('type'), - })) + return reportTransactionResponse.records.map(transformReturnType) }) try { - const txResult = await writeTxResultPromise + const txResult = await reportWriteTxResultPromise if (!txResult[0]) return null - const { report, submitter, resource, type } = 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 - } + createdRelationshipWithNestedAttributes = txResult[0] } finally { session.close() } @@ -62,8 +52,7 @@ export default { reports: async (_parent, params, context, _resolveInfo) => { const { driver } = context const session = driver.session() - let response - let orderByClause + let reports, orderByClause switch (params.orderBy) { case 'createdAt_asc': orderByClause = 'ORDER BY report.createdAt ASC' @@ -74,55 +63,97 @@ export default { default: orderByClause = '' } - try { - const cypher = ` - MATCH (submitter:User)-[report:REPORTED]->(resource) - WHERE resource:User OR resource:Comment OR resource:Post - RETURN report, submitter, resource, labels(resource)[0] as type + const reportReadTxPromise = session.readTransaction(async tx => { + const allReportsTransactionResponse = await tx.run( + ` + MATCH (submitter:User)-[filed:FILED]->(report:Report)-[:BELONGS_TO]->(resource) + WHERE resource:User OR resource:Post OR resource:Comment + RETURN DISTINCT report, resource, labels(resource)[0] as type ${orderByClause} - ` - const result = await session.run(cypher, {}) - const dbResponse = result.records.map(r => { - return { - report: r.get('report'), - submitter: r.get('submitter'), - resource: r.get('resource'), - type: r.get('type'), + `, + {}, + ) + return allReportsTransactionResponse.records.map(transformReturnType) + }) + try { + const txResult = await reportReadTxPromise + 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, } - }) - 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) + return relationshipWithNestedAttributes }) } finally { session.close() } - - return response + return filed + }, + 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 }, }, } diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 2ecd1f20d..c0a9d3afb 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -8,31 +8,41 @@ const factory = Factory() const instance = getNeode() const driver = getDriver() -describe('report resources', () => { - let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser +describe('file a report on a resource', () => { + let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser const categoryIds = ['cat9'] const reportMutation = gql` mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report( + fileReport( resourceId: $resourceId reasonCategory: $reasonCategory reasonDescription: $reasonDescription ) { + id createdAt - reasonCategory - reasonDescription - type - submitter { - email + updatedAt + disable + closed + rule + resource { + __typename + ... on User { + name + } + ... on Post { + title + } + ... on Comment { + content + } } - user { - name - } - post { - title - } - comment { - content + filed { + submitter { + id + } + createdAt + reasonCategory + reasonDescription } } } @@ -67,7 +77,7 @@ describe('report resources', () => { it('throws authorization error', async () => { authenticatedUser = null await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: [{ message: 'Not Authorised!' }], }) }) @@ -81,6 +91,12 @@ describe('report resources', () => { email: 'test@example.org', password: '1234', }) + otherReportingUser = await factory.create('User', { + id: 'other-reporting-user-id', + role: 'user', + email: 'reporting@example.org', + password: '1234', + }) await factory.create('User', { id: 'abusive-user-id', role: 'user', @@ -99,15 +115,15 @@ describe('report resources', () => { describe('invalid resource id', () => { it('returns null', async () => { await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: undefined, }) }) }) describe('valid resource', () => { - describe('reported resource is a user', () => { - it('returns type "User"', async () => { + describe('creates report', () => { + it('which belongs to resource', async () => { await expect( mutate({ mutation: reportMutation, @@ -115,15 +131,28 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'User', + fileReport: { + id: expect.any(String), }, }, 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( mutate({ mutation: reportMutation, @@ -131,8 +160,46 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - user: { + fileReport: { + 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', }, }, @@ -149,10 +216,14 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - submitter: { - email: 'test@example.org', - }, + fileReport: { + filed: [ + { + submitter: { + id: 'current-user-id', + }, + }, + ], }, }, errors: undefined, @@ -167,7 +238,7 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { + fileReport: { createdAt: expect.any(String), }, }, @@ -187,8 +258,12 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonCategory: 'criminal_behavior_violation_german_law', + fileReport: { + filed: [ + { + reasonCategory: 'criminal_behavior_violation_german_law', + }, + ], }, }, errors: undefined, @@ -228,15 +303,19 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonDescription: 'My reason!', + fileReport: { + filed: [ + { + reasonDescription: 'My reason!', + }, + ], }, }, errors: undefined, }) }) - it('sanitize the reason description', async () => { + it('sanitizes the reason description', async () => { await expect( mutate({ mutation: reportMutation, @@ -248,8 +327,12 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonDescription: 'My reason !', + fileReport: { + filed: [ + { + reasonDescription: 'My reason !', + }, + ], }, }, errors: undefined, @@ -278,8 +361,10 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'Post', + fileReport: { + resource: { + __typename: 'Post', + }, }, }, errors: undefined, @@ -297,8 +382,9 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - post: { + fileReport: { + resource: { + __typename: 'Post', title: 'This is a post that is going to be reported', }, }, @@ -306,25 +392,6 @@ describe('report resources', () => { 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', () => { @@ -356,8 +423,10 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'Comment', + fileReport: { + resource: { + __typename: 'Comment', + }, }, }, errors: undefined, @@ -375,8 +444,9 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - comment: { + fileReport: { + resource: { + __typename: 'Comment', content: 'Post comment to be reported.', }, }, @@ -403,7 +473,7 @@ describe('report resources', () => { }, }), ).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: undefined, }) }) @@ -411,25 +481,35 @@ describe('report resources', () => { }) }) }) + describe('query for reported resource', () => { const reportsQuery = gql` query { reports(orderBy: createdAt_desc) { + id createdAt - reasonCategory - reasonDescription - submitter { - id + updatedAt + disable + closed + resource { + __typename + ... on User { + id + } + ... on Post { + id + } + ... on Comment { + id + } } - type - user { - id - } - post { - id - } - comment { - id + filed { + submitter { + id + } + createdAt + reasonCategory + reasonDescription } } } @@ -437,7 +517,6 @@ describe('report resources', () => { beforeEach(async () => { authenticatedUser = null - moderator = await factory.create('User', { id: 'moderator-1', role: 'moderator', @@ -518,6 +597,7 @@ describe('report resources', () => { ]) authenticatedUser = null }) + describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null @@ -527,6 +607,7 @@ describe('report resources', () => { }) }) }) + describe('authenticated', () => { it('role "user" gets no reports', async () => { authenticatedUser = await currentUser.toJson() @@ -538,49 +619,69 @@ describe('report resources', () => { it('role "moderator" gets reports', async () => { const expected = { - // to check 'orderBy: createdAt_desc' is not possible here, because 'createdAt' does not differ reports: expect.arrayContaining([ expect.objectContaining({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'User', - user: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'User', id: 'abusive-user-1', - }), - post: null, - comment: null, + }, + filed: expect.arrayContaining([ + 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({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'other', - reasonDescription: 'This comment is bigoted', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'Post', - user: null, - post: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'Post', 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({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'discrimination_etc', - reasonDescription: 'This post is bigoted', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'Comment', - user: null, - post: null, - comment: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'Comment', 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', + }), + ]), }), ]), } diff --git a/backend/src/schema/resolvers/shout.js b/backend/src/schema/resolvers/shout.js index 05de9b103..ada1172a4 100644 --- a/backend/src/schema/resolvers/shout.js +++ b/backend/src/schema/resolvers/shout.js @@ -4,48 +4,51 @@ export default { const { id, type } = params const session = context.driver.session() - const transactionRes = await session.run( - `MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId}) + try { + 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 MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node) RETURN COUNT(relation) > 0 as isShouted`, - { - id, - type, - userId: context.user.id, - }, - ) + { + id, + type, + userId: context.user.id, + }, + ) - const [isShouted] = transactionRes.records.map(record => { - return record.get('isShouted') - }) + const [isShouted] = transactionRes.records.map(record => { + return record.get('isShouted') + }) - session.close() - - return isShouted + return isShouted + } finally { + session.close() + } }, unshout: async (_object, params, context, _resolveInfo) => { const { id, type } = params const session = context.driver.session() - - const transactionRes = await session.run( - `MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id}) + try { + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id}) WHERE $type IN labels(node) DELETE relation RETURN COUNT(relation) > 0 as isShouted`, - { - id, - type, - userId: context.user.id, - }, - ) - const [isShouted] = transactionRes.records.map(record => { - return record.get('isShouted') - }) - session.close() - - return isShouted + { + id, + type, + userId: context.user.id, + }, + ) + const [isShouted] = transactionRes.records.map(record => { + return record.get('isShouted') + }) + return isShouted + } finally { + session.close() + } }, }, } diff --git a/backend/src/schema/resolvers/statistics.js b/backend/src/schema/resolvers/statistics.js index 7b06f8705..07b9e4cea 100644 --- a/backend/src/schema/resolvers/statistics.js +++ b/backend/src/schema/resolvers/statistics.js @@ -1,6 +1,6 @@ export default { Query: { - statistics: async (parent, args, { driver, user }) => { + statistics: async (_parent, _args, { driver }) => { const session = driver.session() const response = {} try { @@ -33,10 +33,10 @@ export default { * Note: invites count is calculated this way because invitation codes are not in use yet */ response.countInvites = response.countEmails - response.countUsers + return response } finally { session.close() } - return response }, }, } diff --git a/backend/src/schema/resolvers/statistics.spec.js b/backend/src/schema/resolvers/statistics.spec.js new file mode 100644 index 000000000..7ffa8ebd0 --- /dev/null +++ b/backend/src/schema/resolvers/statistics.spec.js @@ -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, + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index 81550d8cf..4c4c3fc90 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -24,29 +24,32 @@ export default { // } email = normalizeEmail(email) 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}) RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 `, - { userEmail: email }, - ) - session.close() - const [currentUser] = await result.records.map(record => { - return record.get('user') - }) + { userEmail: email }, + ) + const [currentUser] = await result.records.map(record => { + return record.get('user') + }) - if ( - currentUser && - (await bcrypt.compareSync(password, currentUser.encryptedPassword)) && - !currentUser.disabled - ) { - delete currentUser.encryptedPassword - return encode(currentUser) - } else if (currentUser && currentUser.disabled) { - throw new AuthenticationError('Your account has been disabled.') - } else { - throw new AuthenticationError('Incorrect email address or password.') + if ( + currentUser && + (await bcrypt.compareSync(password, currentUser.encryptedPassword)) && + !currentUser.disabled + ) { + delete currentUser.encryptedPassword + return encode(currentUser) + } else if (currentUser && currentUser.disabled) { + throw new AuthenticationError('Your account has been disabled.') + } else { + throw new AuthenticationError('Incorrect email address or password.') + } + } finally { + session.close() } }, changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index df8454ebb..e67b90c8d 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -9,25 +9,25 @@ import { neode as getNeode } from '../../bootstrap/neo4j' const factory = Factory() const neode = getNeode() -let query -let mutate -let variables -let req -let user +let query, mutate, variables, req, user const disable = async id => { - await factory.create('User', { id: 'u2', role: 'moderator' }) - const moderatorBearerToken = encode({ id: 'u2' }) - req = { headers: { authorization: `Bearer ${moderatorBearerToken}` } } - await mutate({ - mutation: gql` - mutation($id: ID!) { - disable(id: $id) - } - `, - variables: { id }, - }) - req = { headers: {} } + const moderator = await factory.create('User', { id: 'u2', role: 'moderator' }) + const user = await neode.find('User', id) + const reportAgainstUser = await factory.create('Report') + await Promise.all([ + reportAgainstUser.relateTo(moderator, 'filed', { + resourceId: id, + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', + }), + reportAgainstUser.relateTo(user, 'belongsTo'), + ]) + 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(() => { diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 02ed8dbac..c44e3f44b 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -212,7 +212,6 @@ export default { }, hasOne: { invitedBy: '<-[:INVITED]-(related:User)', - disabledBy: '<-[:DISABLED]-(related:User)', location: '-[:IS_IN]->(related:Location)', }, hasMany: { diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 27fd2206c..35998b935 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -24,8 +24,6 @@ type Mutation { changePassword(oldPassword: String!, newPassword: String!): String! requestPasswordReset(email: 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(id: ID!, type: ShoutTypeEnum): Boolean! # Unshout the given Type and ID diff --git a/backend/src/schema/types/type/Comment.gql b/backend/src/schema/types/type/Comment.gql index ba9d7a3fc..cf53df41d 100644 --- a/backend/src/schema/types/type/Comment.gql +++ b/backend/src/schema/types/type/Comment.gql @@ -47,7 +47,6 @@ type Comment { updatedAt: String deleted: Boolean disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") } type Query { diff --git a/backend/src/schema/types/type/FILED.gql b/backend/src/schema/types/type/FILED.gql new file mode 100644 index 000000000..955af0bb8 --- /dev/null +++ b/backend/src/schema/types/type/FILED.gql @@ -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 +} diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 5557cbd54..af91460f7 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -26,7 +26,7 @@ enum NotificationReason { type Query { notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED] } - + type Mutation { markAsRead(id: ID!): NOTIFIED } diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 0cb479d96..d028767e3 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -115,16 +115,16 @@ type Post { objectId: String author: User @relation(name: "WROTE", direction: "IN") title: String! - slug: String + slug: String! content: String! contentExcerpt: String image: String imageUpload: Upload + imageAspectRatio: Float visibility: Visibility deleted: Boolean disabled: Boolean pinned: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") createdAt: String updatedAt: String language: String @@ -185,6 +185,7 @@ type Mutation { categoryIds: [ID] contentExcerpt: String blurImage: Boolean + imageAspectRatio: Float ): Post UpdatePost( id: ID! @@ -198,6 +199,7 @@ type Mutation { language: String categoryIds: [ID] blurImage: Boolean + imageAspectRatio: Float ): Post DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED @@ -223,6 +225,7 @@ type Query { offset: Int orderBy: [_PostOrdering] filter: _PostFilter + imageAspectRatio: Float ): [Post] PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] diff --git a/backend/src/schema/types/type/REPORTED.gql b/backend/src/schema/types/type/REPORTED.gql deleted file mode 100644 index 5042672a7..000000000 --- a/backend/src/schema/types/type/REPORTED.gql +++ /dev/null @@ -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 -} diff --git a/backend/src/schema/types/type/REVIEWED.gql b/backend/src/schema/types/type/REVIEWED.gql new file mode 100644 index 000000000..aea005abe --- /dev/null +++ b/backend/src/schema/types/type/REVIEWED.gql @@ -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 +} diff --git a/backend/src/schema/types/type/Report.gql b/backend/src/schema/types/type/Report.gql new file mode 100644 index 000000000..49e5bdae3 --- /dev/null +++ b/backend/src/schema/types/type/Report.gql @@ -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] +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 53e739988..243f45322 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -33,7 +33,6 @@ type User { coverImg: String deleted: Boolean disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") role: UserGroup! publicKey: String invitedBy: User @relation(name: "INVITED", direction: "IN") @@ -44,8 +43,6 @@ type User { about: String socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - # createdAt: DateTime - # updatedAt: DateTime createdAt: String updatedAt: String diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 5054155fc..441fe47d5 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -10,6 +10,7 @@ import createLocation from './locations.js' import createEmailAddress from './emailAddresses.js' import createDonations from './donations.js' import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' +import createReport from './reports.js' const factories = { Badge: createBadge, @@ -23,12 +24,13 @@ const factories = { EmailAddress: createEmailAddress, UnverifiedEmailAddress: createUnverifiedEmailAddresss, Donations: createDonations, + Report: createReport, } export const cleanDatabase = async (options = {}) => { const { driver = getDriver() } = options - const session = driver.session() const cypher = 'MATCH (n) DETACH DELETE n' + const session = driver.session() try { return await session.run(cypher) } finally { diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 3058204a1..2443619ae 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -19,6 +19,7 @@ export default function create() { visibility: 'public', deleted: false, categoryIds: [], + imageAspectRatio: 1.333, } args = { ...defaults, diff --git a/backend/src/seed/factories/reports.js b/backend/src/seed/factories/reports.js new file mode 100644 index 000000000..e2d5ec4dc --- /dev/null +++ b/backend/src/seed/factories/reports.js @@ -0,0 +1,7 @@ +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + return neodeInstance.create('Report', args) + }, + } +} diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index d93c9c1a6..19b783364 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -350,17 +350,19 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] author: peterLustig, id: 'p0', language: sample(languages), - image: faker.image.unsplash.food(), + image: faker.image.unsplash.food(300, 169), categoryIds: ['cat16'], blurImage: true, + imageAspectRatio: 300 / 169, }), factory.create('Post', { author: bobDerBaumeister, id: 'p1', language: sample(languages), - image: faker.image.unsplash.technology(), + image: faker.image.unsplash.technology(300, 1500), categoryIds: ['cat1'], blurImage: false, + imageAspectRatio: 300 / 1500, }), factory.create('Post', { author: huey, @@ -387,9 +389,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] authorId: 'u1', id: 'p6', language: sample(languages), - image: faker.image.unsplash.buildings(), + image: faker.image.unsplash.buildings(300, 857), categoryIds: ['cat6'], blurImage: false, + imageAspectRatio: 300 / 857, }), factory.create('Post', { author: huey, @@ -408,9 +411,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] author: louie, id: 'p11', language: sample(languages), - image: faker.image.unsplash.people(), + image: faker.image.unsplash.people(300, 901), categoryIds: ['cat11'], blurImage: false, + imageAspectRatio: 300 / 901, }), factory.create('Post', { author: bobDerBaumeister, @@ -423,9 +427,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] author: jennyRostock, id: 'p14', language: sample(languages), - image: faker.image.unsplash.objects(), + image: faker.image.unsplash.objects(300, 200), categoryIds: ['cat14'], blurImage: false, + imageAspectRatio: 300 / 450, }), factory.create('Post', { author: huey, @@ -452,6 +457,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] $content: String! $categoryIds: [ID] $blurImage: Boolean + $imageAspectRatio: Float ) { CreatePost( id: $id @@ -459,6 +465,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] content: $content categoryIds: $categoryIds blurImage: $blurImage + imageAspectRatio: $imageAspectRatio ) { id } @@ -474,6 +481,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] content: hashtag1, categoryIds: ['cat2'], blurImage: false, + imageAspectRatio: 300 / 200, }, }), mutate({ @@ -484,6 +492,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] content: `${mention1} ${faker.lorem.paragraph()}`, categoryIds: ['cat7'], blurImage: false, + imageAspectRatio: 300 / 180, }, }), mutate({ @@ -495,6 +504,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] content: hashtagAndMention1, categoryIds: ['cat8'], blurImage: false, + imageAspectRatio: 300 / 900, }, }), mutate({ @@ -505,6 +515,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] content: `${mention2} ${faker.lorem.paragraph()}`, categoryIds: ['cat12'], blurImage: false, + imageAspectRatio: 300 / 200, }, }), ]) @@ -552,7 +563,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ]) authenticatedUser = null - await Promise.all([ + const comments = await Promise.all([ factory.create('Comment', { author: jennyRostock, id: 'c1', @@ -569,7 +580,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] postId: 'p3', }), factory.create('Comment', { - author: bobDerBaumeister, + author: jennyRostock, id: 'c5', postId: 'p3', }), @@ -609,6 +620,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] postId: 'p15', }), ]) + const trollingComment = comments[0] await Promise.all([ democracy.relateTo(p3, 'post'), @@ -672,68 +684,107 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] louie.relateTo(p10, 'shouted'), ]) - const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } - ` - authenticatedUser = await bobDerBaumeister.toJson() - await Promise.all([ - mutate({ - mutation: disableMutation, - variables: { - id: 'p11', - }, - }), - mutate({ - mutation: disableMutation, - variables: { - id: 'c5', - }, - }), + const reports = await Promise.all([ + factory.create('Report'), + factory.create('Report'), + factory.create('Report'), ]) - 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? - const reportMutation = gql` - mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report( - resourceId: $resourceId - reasonCategory: $reasonCategory - reasonDescription: $reasonDescription - ) { - type - } - } - ` - authenticatedUser = await huey.toJson() + // report resource first time await Promise.all([ - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'c1', - reasonCategory: 'other', - reasonDescription: 'This comment is bigoted', - }, + reportAgainstDagobert.relateTo(jennyRostock, 'filed', { + resourceId: 'u7', + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'p1', - reasonCategory: 'discrimination_etc', - reasonDescription: 'This post is bigoted', - }, + reportAgainstDagobert.relateTo(dagobert, 'belongsTo'), + reportAgainstTrollingPost.relateTo(jennyRostock, 'filed', { + resourceId: 'p2', + reasonCategory: 'doxing', + reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!", }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'u1', - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - }, + reportAgainstTrollingPost.relateTo(p2, 'belongsTo'), + reportAgainstTrollingComment.relateTo(huey, 'filed', { + resourceId: 'c1', + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', }), + 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( [...Array(30).keys()].map(i => { diff --git a/backend/src/server.js b/backend/src/server.js index 70eae86f1..053a3e4b3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -6,6 +6,7 @@ import middleware from './middleware' import { neode as getNeode, getDriver } from './bootstrap/neo4j' import decode from './jwt/decode' import schema from './schema' +import webfinger from './activitypub/routes/webfinger' // check required configs and throw error // 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 app = express() + + app.set('driver', driver) app.use(helmet()) + app.use('/.well-known/', webfinger()) app.use(express.static('public')) server.applyMiddleware({ app, path: '/' }) diff --git a/backend/test/features/webfinger.feature b/backend/test/features/webfinger.feature index 72062839a..cbca5ac10 100644 --- a/backend/test/features/webfinger.feature +++ b/backend/test/features/webfinger.feature @@ -9,32 +9,6 @@ Feature: Webfinger discovery | Slug | | 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 When I send a GET request to "/activitypub/users/peter-lustiger" Then I receive the following json: diff --git a/backend/yarn.lock b/backend/yarn.lock index 8e2ca5446..b83a272b1 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1101,60 +1101,72 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@sentry/core@5.8.0": - version "5.8.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.8.0.tgz#bbfd2f4711491951a8e3a0e8fa8b172fdf7bff6f" - integrity sha512-aAh2KLidIXJVGrxmHSVq2eVKbu7tZiYn5ylW6yzJXFetS5z4MA+JYaSBaG2inVYDEEqqMIkb17TyWxxziUDieg== +"@sentry/apm@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.10.0.tgz#ba0c34298f599c8821d03b7fa0e95435b6340801" + integrity sha512-GyMWR38DaTOZ0Zdu677kt3/HDbZI4SyNNGvt/8/kzqRhmPUhEuLfuh1CJVA8ysUMD+ucllJifCGP2TflMA7LYQ== dependencies: - "@sentry/hub" "5.8.0" - "@sentry/minimal" "5.8.0" - "@sentry/types" "5.7.1" - "@sentry/utils" "5.8.0" + "@sentry/hub" "5.10.0" + "@sentry/minimal" "5.10.0" + "@sentry/types" "5.10.0" + "@sentry/utils" "5.10.0" tslib "^1.9.3" -"@sentry/hub@5.8.0": - version "5.8.0" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.8.0.tgz#56aaeb7324cb66d90db838011cb0127f5558007f" - integrity sha512-VdApn1ZCNwH1wwQwoO6pu53PM/qgHG+DQege0hbByluImpLBhAj9w50nXnF/8KzV4UoMIVbzCb6jXzMRmqqp9A== +"@sentry/core@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.10.0.tgz#9f65ce9077e980a370bd5410f6464f01962a8f67" + integrity sha512-sPtgZIRFDKgIvmASi5/kLn+bTRuqhj/NkBlY2SkVgCKfo4Plu1uLJt4zEFF7UC3+MP+2PQA4F6gnAwWIqisbXQ== dependencies: - "@sentry/types" "5.7.1" - "@sentry/utils" "5.8.0" + "@sentry/hub" "5.10.0" + "@sentry/minimal" "5.10.0" + "@sentry/types" "5.10.0" + "@sentry/utils" "5.10.0" tslib "^1.9.3" -"@sentry/minimal@5.8.0": - version "5.8.0" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.8.0.tgz#b7ad5113504ab67f1ef2b0f465b7ba608e6b8dc5" - integrity sha512-MIlFOgd+JvAUrBBmq7vr9ovRH1HvckhnwzHdoUPpKRBN+rQgTyZy1o6+kA2fASCbrRqFCP+Zk7EHMACKg8DpIw== +"@sentry/hub@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.10.0.tgz#7f64f7d86a754e5aaba4d4ac0f8b39a54e24deaa" + integrity sha512-GJjsmu6oI02uL+HnO504XvExhsD6TW7qwOKuIdy27Apq9d/+ZGsjnMigI9bR9UT3JqVQr3OzreDC4LBCGehTqw== dependencies: - "@sentry/hub" "5.8.0" - "@sentry/types" "5.7.1" + "@sentry/types" "5.10.0" + "@sentry/utils" "5.10.0" tslib "^1.9.3" -"@sentry/node@^5.9.0": - version "5.9.0" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.9.0.tgz#9a8da70990e64c88a391ef86dcf29f43e0a52e59" - integrity sha512-1CWwSGhRfMr4Bvt1i0vIms+BBZd4dBzlDyWpyCboodCXF1rTJRci9roQ+Wh9XWwFEWvgDD2PzuKzfvu638v2Wg== +"@sentry/minimal@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.10.0.tgz#8bf22cfd362da2679afe29495d3bdb7ed712d22b" + integrity sha512-ZZd+IJewSZDuxKKQgzLdSKGNDsDIL6IW/9jGHY+uX1D9t7NnZIBmfpaIUsMPe1rJxag+fEk0FJH+g/z4uIZI2w== dependencies: - "@sentry/core" "5.8.0" - "@sentry/hub" "5.8.0" - "@sentry/types" "5.7.1" - "@sentry/utils" "5.8.0" + "@sentry/hub" "5.10.0" + "@sentry/types" "5.10.0" + tslib "^1.9.3" + +"@sentry/node@^5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.10.0.tgz#950f763e68361fbca822e9474de78ee1e00fd5c8" + integrity sha512-G8fiwYRq/KB3/fNsGQ4A8OByH0LNbyUvoJGUhsfkkQS7GqC/vtn6CrR+GuKIwFjxTF4MN5amIPntSdVZjehxug== + dependencies: + "@sentry/apm" "5.10.0" + "@sentry/core" "5.10.0" + "@sentry/hub" "5.10.0" + "@sentry/types" "5.10.0" + "@sentry/utils" "5.10.0" cookie "^0.3.1" https-proxy-agent "^3.0.0" lru_map "^0.3.3" tslib "^1.9.3" -"@sentry/types@5.7.1": - version "5.7.1" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.7.1.tgz#4c4c1d4d891b6b8c2c3c7b367d306a8b1350f090" - integrity sha512-tbUnTYlSliXvnou5D4C8Zr+7/wJrHLbpYX1YkLXuIJRU0NSi81bHMroAuHWILcQKWhVjaV/HZzr7Y/hhWtbXVQ== +"@sentry/types@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.10.0.tgz#4f0ba31b6e4d5371112c38279f11f66c73b43746" + integrity sha512-TW20GzkCWsP6uAxR2JIpIkiitCKyIOfkyDsKBeLqYj4SaZjfvBPnzgNCcYR0L0UsP1/Es6oHooZfIGSkp6GGxQ== -"@sentry/utils@5.8.0": - version "5.8.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.8.0.tgz#34683088159b9935f973b6e6cad1a1cc26bbddac" - integrity sha512-KDxUvBSYi0/dHMdunbxAxD3389pcQioLtcO6CI6zt/nJXeVFolix66cRraeQvqupdLhvOk/el649W4fCPayTHw== +"@sentry/utils@5.10.0": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.10.0.tgz#98ee0db868438c4572b0bad03231ab2e888c134d" + integrity sha512-wcxwqtAomr1O65aXx41oHsgl/AGJTJ9C4c03FAMg9wHWEfzEby0el6BZCMq3IAG09zY7vY43zhEFWFghI5u2eg== dependencies: - "@sentry/types" "5.7.1" + "@sentry/types" "5.10.0" tslib "^1.9.3" "@sindresorhus/is@^0.14.0": @@ -6206,10 +6218,10 @@ nodemailer-html-to-text@^3.1.0: dependencies: html-to-text "^5.1.1" -nodemailer@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.3.1.tgz#2784beebac6b9f014c424c54dbdcc5c4d1221346" - integrity sha512-j0BsSyaMlyadEDEypK/F+xlne2K5m6wzPYMXS/yxKI0s7jmT1kBx6GEKRVbZmyYfKOsjkeC/TiMVDJBI/w5gMQ== +nodemailer@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.0.tgz#91482ebc09d39156d933eb9e6159642cd27bf02c" + integrity sha512-UBqPOfQGD1cM3HnjhuQe+0u3DWx47WWK7lBjG5UtPnGOysr7oDK5lNCzcjK6zzeBSdTk4m1tGx1xNbWFZQmMNA== nodemon@~2.0.1: version "2.0.1" diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 17481befe..9f62a2818 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -129,8 +129,8 @@ Given('somebody reported the following posts:', table => { .create('User', submitter) .authenticateAs(submitter) .mutate(`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) { - type + fileReport(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) { + id } }`, { resourceId, diff --git a/deployment/minikube/README.md b/deployment/minikube/README.md index e77ddd667..342675b1b 100644 --- a/deployment/minikube/README.md +++ b/deployment/minikube/README.md @@ -9,7 +9,7 @@ open your 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). If all the pods and services have settled and everything looks green in your diff --git a/features/support/steps.js b/features/support/steps.js new file mode 100644 index 000000000..923dc9766 --- /dev/null +++ b/features/support/steps.js @@ -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) +}) + diff --git a/features/webfinger.feature b/features/webfinger.feature new file mode 100644 index 000000000..1a17e7ea3 --- /dev/null +++ b/features/webfinger.feature @@ -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\"." + } + """ diff --git a/features/world.js b/features/world.js new file mode 100644 index 000000000..f46a63d4e --- /dev/null +++ b/features/world.js @@ -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) diff --git a/neo4j/change_disabled_relationship_to_report_node.sh b/neo4j/change_disabled_relationship_to_report_node.sh new file mode 100755 index 000000000..2f44b8e59 --- /dev/null +++ b/neo4j/change_disabled_relationship_to_report_node.sh @@ -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 + diff --git a/neo4j/change_report_node_to_relationship.sh b/neo4j/change_report_node_to_relationship.sh deleted file mode 100755 index f8dd639be..000000000 --- a/neo4j/change_report_node_to_relationship.sh +++ /dev/null @@ -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 diff --git a/neo4j/db_manipulation/add_image_aspect_ratio.sh b/neo4j/db_manipulation/add_image_aspect_ratio.sh new file mode 100755 index 000000000..7fe2c5871 --- /dev/null +++ b/neo4j/db_manipulation/add_image_aspect_ratio.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +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 + +shopt -s nullglob +for image in uploads/*; do + [ -e "$image" ] || continue + IMAGE_WIDTH=$( identify -format '%w' "$image" ) + IMAGE_HEIGHT=$( identify -format '%h' "$image" ) + IMAGE_ASPECT_RATIO=$(echo | awk "{ print ${IMAGE_WIDTH}/${IMAGE_HEIGHT}}") + + + echo "$image" + echo "$IMAGE_ASPECT_RATIO" + echo " + match (post:Post {image: '/"${image}"'}) + set post.imageAspectRatio = "${IMAGE_ASPECT_RATIO}" + return post; + " | cypher-shell +done diff --git a/neo4j/db_manipulation/change_disabled_relationship_to_report_node.sh b/neo4j/db_manipulation/change_disabled_relationship_to_report_node.sh new file mode 100755 index 000000000..e611382f0 --- /dev/null +++ b/neo4j/db_manipulation/change_disabled_relationship_to_report_node.sh @@ -0,0 +1,51 @@ +#!/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 " + :begin + MATCH(user)-[reported:REPORTED]->(resource) + WITH reported, resource, COLLECT(user) as users + MERGE(report:Report)-[:BELONGS_TO]->(resource) + SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false + WITH report, users, reported + UNWIND users as user + MERGE (user)-[filed:FILED]->(report) + SET filed = reported + DELETE reported; + + MATCH(moderator)-[disabled:DISABLED]->(resource) + MATCH(report:Report)-[:BELONGS_TO]->(resource) + WITH disabled, resource, COLLECT(moderator) as moderators, report + DELETE disabled + WITH report, moderators, disabled + UNWIND moderators as moderator + MERGE (moderator)-[review:REVIEWED {disable: true}]->(report) + SET review.createdAt = toString(datetime()), review.updatedAt = review.createdAt, review.disable = true; + + MATCH(moderator)-[disabled:DISABLED]->(resource) + WITH disabled, resource, COLLECT(moderator) as moderators + MERGE(report:Report)-[:BELONGS_TO]->(resource) + SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false + DELETE disabled + WITH report, moderators, disabled + UNWIND moderators as moderator + MERGE(moderator)-[filed:FILED]->(report) + SET filed.createdAt = toString(datetime()), filed.reasonCategory = 'other', filed.reasonDescription = 'Old DISABLED relations didn\'t enforce mandatory reporting !!! Created automatically to ensure database consistency! Creation date is when the database manipulation happened.' + MERGE (moderator)-[review:REVIEWED {disable: true}]->(report) + SET review.createdAt = toString(datetime()), review.updatedAt = review.createdAt, review.disable = true; + :commit +" | cypher-shell \ No newline at end of file diff --git a/package.json b/package.json index 2c1793041..ac7e575df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "nitro-cypress", + "name": "human-connection", "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", "license": "MIT", "cypress-cucumber-preprocessor": { @@ -16,19 +16,26 @@ "cypress:setup": "run-p cypress:backend cypress:webapp", "cypress:run": "cross-env cypress run --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" }, "devDependencies": { + "@babel/core": "^7.7.2", + "@babel/preset-env": "^7.7.4", + "@babel/register": "^7.7.4", "auto-changelog": "^1.16.2", "bcryptjs": "^2.4.3", "codecov": "^3.6.1", "cross-env": "^6.0.3", + "cucumber": "^6.0.5", "cypress": "^3.7.0", "cypress-cucumber-preprocessor": "^1.17.0", "cypress-file-upload": "^3.5.0", "cypress-plugin-retries": "^1.5.0", "date-fns": "^2.8.1", "dotenv": "^8.2.0", + "expect": "^24.9.0", "faker": "Marak/faker.js#master", "graphql-request": "^1.8.2", "neo4j-driver": "^1.7.6", diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index 888c167e9..25ed62f68 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -1,19 +1,9 @@ - - diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js index 485c43145..8f93aa4a4 100644 --- a/webapp/components/ContentMenu/ContentMenu.spec.js +++ b/webapp/components/ContentMenu/ContentMenu.spec.js @@ -85,7 +85,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'post.menu.delete') .at(0) .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') .at(0) .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') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release comments', () => { @@ -350,7 +350,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.comment.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release users', () => { @@ -368,7 +368,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.user.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release organizations', () => { @@ -386,7 +386,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.organization.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) }) diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index f0d9dc8d3..d4c567437 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -70,7 +70,7 @@ export default { routes.push({ name: this.$t(`post.menu.delete`), callback: () => { - this.openModal('delete') + this.openModal('confirm', 'delete') }, icon: 'trash', }) @@ -108,7 +108,7 @@ export default { routes.push({ name: this.$t(`comment.menu.delete`), callback: () => { - this.openModal('delete') + this.openModal('confirm', 'delete') }, icon: 'trash', }) @@ -137,7 +137,7 @@ export default { routes.push({ name: this.$t(`release.${this.resourceType}.title`), callback: () => { - this.openModal('release', this.resource.id) + this.openModal('release') }, icon: 'eye', }) @@ -190,13 +190,13 @@ export default { } toggleMenu() }, - openModal(dialog) { + openModal(dialog, modalDataName = null) { this.$store.commit('modal/SET_OPEN', { name: dialog, data: { type: this.resourceType, resource: this.resource, - modalsData: this.modalsData, + modalData: modalDataName ? this.modalsData[modalDataName] : {}, }, }) }, diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 53057a771..c8d36459e 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -198,6 +198,7 @@ describe('ContributionForm.vue', () => { id: null, categoryIds: ['cat12'], imageUpload: null, + imageAspectRatio: null, image: null, blurImage: false, }, @@ -364,6 +365,7 @@ describe('ContributionForm.vue', () => { categoryIds: ['cat12'], image, imageUpload: null, + imageAspectRatio: null, }, } }) diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index 7831d8fe0..5d6ff1351 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -11,6 +11,7 @@ :contribution="contribution" @addTeaserImage="addTeaserImage" :class="{ 'images-set-blur': checkedBlur }" + @addImageAspectRatio="addImageAspectRatio" > { @@ -289,6 +293,9 @@ export default { addTeaserImage(file) { this.form.teaserImage = file }, + addImageAspectRatio(aspectRatio) { + this.form.imageAspectRatio = aspectRatio + }, categoryIds(categories) { return categories.map(c => c.id) }, diff --git a/webapp/components/DropdownFilter/DropdownFilter.spec.js b/webapp/components/DropdownFilter/DropdownFilter.spec.js index 8020a487f..8fb1b408f 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.spec.js +++ b/webapp/components/DropdownFilter/DropdownFilter.spec.js @@ -64,9 +64,9 @@ describe('DropdownFilter.vue', () => { expect(unreadLink.text()).toEqual('Unread') }) - it('clicking on menu item emits filterNotifications', () => { + it('clicking on menu item emits filter', () => { allLink.trigger('click') - expect(wrapper.emitted().filterNotifications[0]).toEqual( + expect(wrapper.emitted().filter[0]).toEqual( propsData.filterOptions.filter(option => option.label === 'All'), ) }) diff --git a/webapp/components/DropdownFilter/DropdownFilter.story.js b/webapp/components/DropdownFilter/DropdownFilter.story.js index 0703c5c47..9bd750ac1 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.story.js +++ b/webapp/components/DropdownFilter/DropdownFilter.story.js @@ -20,10 +20,10 @@ storiesOf('DropdownFilter', module) selected: filterOptions[0].label, }), methods: { - filterNotifications: action('filterNotifications'), + filter: action('filter'), }, template: ``, diff --git a/webapp/components/DropdownFilter/DropdownFilter.vue b/webapp/components/DropdownFilter/DropdownFilter.vue index 2a3637f41..bfa78e709 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.vue +++ b/webapp/components/DropdownFilter/DropdownFilter.vue @@ -25,7 +25,7 @@ class="dropdown-menu-item" :route="item.route" :parents="item.parents" - @click.stop.prevent="filterNotifications(item.route, toggleMenu)" + @click.stop.prevent="filter(item.route, toggleMenu)" > {{ item.route.label }} @@ -44,8 +44,8 @@ export default { filterOptions: { type: Array, default: () => [] }, }, methods: { - filterNotifications(option, toggleMenu) { - this.$emit('filterNotifications', option) + filter(option, toggleMenu) { + this.$emit('filter', option) toggleMenu() }, }, diff --git a/webapp/components/MasonryGrid/MasonryGrid.spec.js b/webapp/components/MasonryGrid/MasonryGrid.spec.js index 8eed5e150..b42b9b221 100644 --- a/webapp/components/MasonryGrid/MasonryGrid.spec.js +++ b/webapp/components/MasonryGrid/MasonryGrid.spec.js @@ -6,23 +6,36 @@ const localVue = global.localVue describe('MasonryGrid', () => { let wrapper - let masonryGrid + let masonryGridItem beforeEach(() => { wrapper = mount(MasonryGrid, { localVue }) - masonryGrid = wrapper.vm.$children[0] + masonryGridItem = wrapper.vm.$children[0] }) - it('adds the "reset-grid-height" class when one or more children are updating', () => { - masonryGrid.$emit('calculating-item-height') + it('adds the "reset-grid-height" class when itemsCalculating is more than 0', () => { + wrapper.setData({ itemsCalculating: 1 }) expect(wrapper.classes()).toContain('reset-grid-height') }) - it('removes the "reset-grid-height" class when all children have completed updating', () => { - wrapper.setData({ itemsCalculating: 1 }) - masonryGrid.$emit('finished-calculating-item-height') + it('removes the "reset-grid-height" class when itemsCalculating is 0', () => { + wrapper.setData({ itemsCalculating: 0 }) expect(wrapper.classes()).not.toContain('reset-grid-height') }) + + it('adds 1 to itemsCalculating when a child emits "calculating-item-height"', () => { + wrapper.setData({ itemsCalculating: 0 }) + masonryGridItem.$emit('calculating-item-height') + + expect(wrapper.vm.itemsCalculating).toBe(1) + }) + + it('subtracts 1 from itemsCalculating when a child emits "finished-calculating-item-height"', () => { + wrapper.setData({ itemsCalculating: 2 }) + masonryGridItem.$emit('finished-calculating-item-height') + + expect(wrapper.vm.itemsCalculating).toBe(1) + }) }) diff --git a/webapp/components/MasonryGrid/MasonryGrid.vue b/webapp/components/MasonryGrid/MasonryGrid.vue index c27f134e8..5ef8f9006 100644 --- a/webapp/components/MasonryGrid/MasonryGrid.vue +++ b/webapp/components/MasonryGrid/MasonryGrid.vue @@ -1,6 +1,5 @@