diff --git a/backend/src/middleware/softDeleteMiddleware.js b/backend/src/middleware/softDeleteMiddleware.js index cc5aa06c5..b07770fd9 100644 --- a/backend/src/middleware/softDeleteMiddleware.js +++ b/backend/src/middleware/softDeleteMiddleware.js @@ -13,8 +13,8 @@ const setDefaultFilters = (resolve, root, args, context, info) => { return resolve(root, args, context, info) } -const obfuscateDisabled = async (resolve, root, args, context, info) => { - if (!isModerator(context) && root.disabled) { +const obfuscate = async (resolve, root, args, context, info) => { + if (root.deleted || (!isModerator(context) && root.disabled)) { root.content = 'UNAVAILABLE' root.contentExcerpt = 'UNAVAILABLE' root.title = 'UNAVAILABLE' @@ -40,7 +40,7 @@ export default { } return resolve(root, args, context, info) }, - Post: obfuscateDisabled, - User: obfuscateDisabled, - Comment: obfuscateDisabled, + Post: obfuscate, + User: obfuscate, + Comment: obfuscate, } diff --git a/backend/src/middleware/softDeleteMiddleware.spec.js b/backend/src/middleware/softDeleteMiddleware.spec.js index 673c3026f..73446fe1e 100644 --- a/backend/src/middleware/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDeleteMiddleware.spec.js @@ -362,8 +362,8 @@ describe('softDeleteMiddleware', () => { authenticatedUser = await moderator.toJson() }) - it('shows deleted posts', async () => { - const expected = { data: { Post: [{ title: 'Deleted post' }] } } + it('does not show deleted posts', async () => { + const expected = { data: { Post: [{ title: 'UNAVAILABLE' }] } } await expect(action()).resolves.toMatchObject(expected) }) }) diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index dd55e8f44..1f6803e09 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -53,8 +53,8 @@ export default { ` MATCH (comment:Comment {id: $commentId}) SET comment.deleted = TRUE - SET comment.content = 'DELETED' - SET comment.contentExcerpt = 'DELETED' + SET comment.content = 'UNAVAILABLE' + SET comment.contentExcerpt = 'UNAVAILABLE' RETURN comment `, { commentId: args.id }, diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index b3cf1fda2..dcb2d31f8 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -271,8 +271,8 @@ describe('DeleteComment', () => { DeleteComment: { id: 'c456', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', }, } expect(data).toMatchObject(expected) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e9f430d07..1b97617cc 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -148,9 +148,9 @@ export default { MATCH (post:Post {id: $postId}) OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) SET post.deleted = TRUE - SET post.image = 'DELETED' - SET post.content = 'DELETED' - SET post.contentExcerpt = 'DELETED' + SET post.image = 'UNAVAILABLE' + SET post.content = 'UNAVAILABLE' + SET post.contentExcerpt = 'UNAVAILABLE' SET comment.deleted = TRUE RETURN post `, diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 107a90727..cd89a4055 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -468,9 +468,9 @@ describe('DeletePost', () => { DeletePost: { id: 'p4711', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', - image: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: 'UNAVAILABLE', comments: [], }, }, @@ -495,15 +495,15 @@ describe('DeletePost', () => { DeletePost: { id: 'p4711', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', - image: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: 'UNAVAILABLE', comments: [ { deleted: true, // Should we black out the comment content in the database, too? - content: 'to be deleted comment content', - contentExcerpt: 'to be deleted comment content', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', }, ], }, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 2ac1d6043..f0a179028 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -102,23 +102,41 @@ export default { const { resource } = params const session = context.driver.session() - if (resource && resource.length) { - await Promise.all( - resource.map(async node => { - await session.run( - ` + let user + try { + if (resource && resource.length) { + await Promise.all( + resource.map(async node => { + await session.run( + ` MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) SET resource.deleted = true + SET resource.content = 'UNAVAILABLE' + SET resource.contentExcerpt = 'UNAVAILABLE' + SET comment.deleted = true RETURN author`, - { - userId: context.user.id, - }, - ) - }), + { + userId: context.user.id, + }, + ) + }), + ) + } + const transactionResult = await session.run( + ` + MATCH (user:User {id: $userId}) + SET user.deleted = true + SET user.name = 'UNAVAILABLE' + SET user.about = 'UNAVAILABLE' + RETURN user`, + { userId: context.user.id }, ) + user = transactionResult.records.map(r => r.get('user').properties)[0] + } finally { session.close() } - return neo4jgraphql(object, params, context, resolveInfo, false) + return user }, }, User: { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index bf7a7ec2d..82174a2bf 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -1,203 +1,242 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' -let client const factory = Factory() -const instance = neode() const categoryIds = ['cat9'] let user +let query +let mutate +let authenticatedUser +let variables + +const driver = getDriver() +const neode = getNeode() + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + afterEach(async () => { await factory.cleanDatabase() }) -describe('users', () => { - describe('User', () => { - describe('query by email address', () => { - beforeEach(async () => { - await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) - }) - - const query = `query($email: String) { User(email: $email) { name } }` - const variables = { email: 'any-email-address@example.org' } - beforeEach(() => { - client = new GraphQLClient(host) - }) - - it('is forbidden', async () => { - await expect(client.request(query, variables)).rejects.toThrow('Not Authorised') - }) - - describe('as admin', () => { - beforeEach(async () => { - const userParams = { - role: 'admin', - email: 'admin@example.org', - password: '1234', - } - const factory = Factory() - await factory.create('User', userParams) - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) - - it('is permitted', async () => { - await expect(client.request(query, variables)).resolves.toEqual({ - User: [{ name: 'Johnny' }], - }) - }) - }) +describe('User', () => { + describe('query by email address', () => { + beforeEach(async () => { + await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) }) - }) - describe('UpdateUser', () => { - const userParams = { - email: 'user@example.org', - password: '1234', - id: 'u47', - name: 'John Doe', - } - const variables = { - id: 'u47', - name: 'John Doughnut', - } - - const mutation = ` - mutation($id: ID!, $name: String) { - UpdateUser(id: $id, name: $name) { - id + const userQuery = gql` + query($email: String) { + User(email: $email) { name } } ` + const variables = { email: 'any-email-address@example.org' } - beforeEach(async () => { - await factory.create('User', userParams) + it('is forbidden', async () => { + const { errors } = await query({ query: userQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) - describe('as another user', () => { + describe('as admin', () => { beforeEach(async () => { - const someoneElseParams = { - email: 'someone-else@example.org', + const userParams = { + role: 'admin', + email: 'admin@example.org', password: '1234', - name: 'James Doe', } - - await factory.create('User', someoneElseParams) - const headers = await login(someoneElseParams) - client = new GraphQLClient(host, { headers }) + const admin = await factory.create('User', userParams) + authenticatedUser = await admin.toJson() }) - it('is not allowed to change other user accounts', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + it('is permitted', async () => { + await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ + data: { User: [{ name: 'Johnny' }] }, + }) }) }) + }) +}) - describe('as the same user', () => { - beforeEach(async () => { - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) +describe('UpdateUser', () => { + const userParams = { + email: 'user@example.org', + password: '1234', + id: 'u47', + name: 'John Doe', + } + const variables = { + id: 'u47', + name: 'John Doughnut', + } - it('name within specifications', async () => { - const expected = { + const updateUserMutation = gql` + mutation($id: ID!, $name: String) { + UpdateUser(id: $id, name: $name) { + id + name + } + } + ` + + beforeEach(async () => { + user = await factory.create('User', userParams) + }) + + describe('as another user', () => { + beforeEach(async () => { + const someoneElseParams = { + email: 'someone-else@example.org', + password: '1234', + name: 'James Doe', + } + + const someoneElse = await factory.create('User', someoneElseParams) + authenticatedUser = await someoneElse.toJson() + }) + + it('is not allowed to change other user accounts', async () => { + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('as the same user', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('name within specifications', async () => { + const expected = { + data: { UpdateUser: { id: 'u47', name: 'John Doughnut', }, - } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) - }) + }, + } + await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( + expected, + ) + }) - it('with `null` as name', async () => { - const variables = { - id: 'u47', - name: null, - } - const expected = '"name" must be a string' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) + it('with `null` as name', async () => { + const variables = { + id: 'u47', + name: null, + } + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty( + 'message', + 'child "name" fails because ["name" contains an invalid value, "name" must be a string]', + ) + }) - it('with too short name', async () => { - const variables = { - id: 'u47', - name: ' ', + it('with too short name', async () => { + const variables = { + id: 'u47', + name: ' ', + } + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty( + 'message', + 'child "name" fails because ["name" length must be at least 3 characters long]', + ) + }) + }) +}) + +describe('DeleteUser', () => { + const deleteUserMutation = gql` + mutation($id: ID!, $resource: [Deletable]) { + DeleteUser(id: $id, resource: $resource) { + id + name + about + deleted + contributions { + id + content + contentExcerpt + deleted + comments { + id + content + contentExcerpt + deleted + } } - const expected = '"name" length must be at least 3 characters long' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) + comments { + id + content + contentExcerpt + deleted + } + } + } + ` + beforeEach(async () => { + variables = { id: ' u343', resource: [] } + + user = await factory.create('User', { + name: 'My name should be deleted', + about: 'along with my about', + id: 'u343', + }) + await factory.create('User', { + email: 'friends-account@example.org', + password: '1234', + id: 'u565', }) }) - describe('DeleteUser', () => { - let deleteUserVariables - const deleteUserMutation = gql` - mutation($id: ID!, $resource: [Deletable]) { - DeleteUser(id: $id, resource: $resource) { - id - contributions { - id - deleted - } - comments { - id - deleted - } - } - } - ` + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { beforeEach(async () => { - user = await factory.create('User', { - email: 'test@example.org', - password: '1234', - id: 'u343', - }) - await factory.create('User', { - email: 'friends-account@example.org', - password: '1234', - id: 'u565', - }) - deleteUserVariables = { id: 'u343', resource: [] } + authenticatedUser = await user.toJson() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) + describe("attempting to delete another user's account", () => { + beforeEach(() => { + variables = { ...variables, id: 'u565' } + }) + + it('throws an authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { headers }) + describe('attempting to delete my own account', () => { + beforeEach(() => { + variables = { ...variables, id: 'u343' } }) - describe("attempting to delete another user's account", () => { - it('throws an authorization error', async () => { - deleteUserVariables = { id: 'u565' } - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) - }) - }) - - describe('attempting to delete my own account', () => { - let expectedResponse + describe('given posts and comments', () => { beforeEach(async () => { - await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { + await factory.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', @@ -211,64 +250,192 @@ describe('users', () => { await factory.create('Comment', { author: user, id: 'c155', - postId: 'p139', content: 'Comment by user u343', }) - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: false }], + await factory.create('Comment', { + postId: 'p139', + id: 'c156', + content: "A comment by someone else on user u343's post", + }) + }) + + it("deletes my account, but doesn't delete posts or comments by default", async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, }, } - }) - it("deletes my account, but doesn't delete posts or comments by default", async () => { - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject( expectedResponse, ) }) - describe("deletes a user's", () => { - it('posts on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: false }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + describe('deletion of all post requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post'] } }) - it('comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: true }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + describe("marks user's posts as deleted", () => { + it('posts on request', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + }) + + describe('deletion of all comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Comment'] } }) - it('posts and comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: true }], + it('marks comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, }, } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + + describe('deletion of all post and comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post', 'Comment'] } + }) + + it('marks posts and comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) }) }) }) diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index 87f46f358..33d9464ae 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -3,7 +3,7 @@ import uuid from 'uuid/v4' export default function create() { return { - factory: async ({ args, neodeInstance }) => { + factory: async ({ args, neodeInstance, factoryInstance }) => { const defaults = { id: uuid(), content: [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), @@ -12,11 +12,22 @@ export default function create() { ...defaults, ...args, } - const { postId } = args - if (!postId) throw new Error('PostId is missing!') - const post = await neodeInstance.find('Post', postId) + args.contentExcerpt = args.contentExcerpt || args.content + + let { post, postId } = args + delete args.post delete args.postId - const author = args.author || (await neodeInstance.create('User', args)) + if (post && post) throw new Error('You provided both post and postId') + if (postId) post = await neodeInstance.find('Post', postId) + post = post || (await factoryInstance.create('Post')) + + let { author, authorId } = args + delete args.author + delete args.authorId + if (author && authorId) throw new Error('You provided both author and authorId') + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User')) + delete args.author const comment = await neodeInstance.create('Comment', args) await comment.relateTo(post, 'post') diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index cb3c163d8..e81251c53 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -27,13 +27,13 @@ export default function create() { args.slug = args.slug || slugify(args.title, { lower: true }) args.contentExcerpt = args.contentExcerpt || args.content - const { categoryIds } = args - if (!categoryIds.length) throw new Error('CategoryIds are empty!') - const categories = await Promise.all( - categoryIds.map(c => { - return neodeInstance.find('Category', c) - }), - ) + let { categories, categoryIds } = args + delete args.categories + delete args.categoryIds + if (categories && categoryIds) throw new Error('You provided both category and categoryIds') + if (categoryIds) + categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id))) + categories = categories || (await Promise.all([factoryInstance.create('Category')])) const { tagIds = [] } = args delete args.tags