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