SoftDeleteMiddleware obfuscates deleted resources

This commit is contained in:
roschaefer 2019-09-02 21:08:56 +02:00
parent 580c048cfa
commit c4ba2c4aeb
10 changed files with 438 additions and 242 deletions

View File

@ -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,
}

View File

@ -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)
})
})

View File

@ -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 },

View File

@ -271,8 +271,8 @@ describe('DeleteComment', () => {
DeleteComment: {
id: 'c456',
deleted: true,
content: 'DELETED',
contentExcerpt: 'DELETED',
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
},
}
expect(data).toMatchObject(expected)

View File

@ -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
`,

View File

@ -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',
},
],
},

View File

@ -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: {

View File

@ -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)
})
})
})

View File

@ -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')

View File

@ -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