diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..9d4508b38 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Human-Connection gGmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-compose.cypress.yml b/docker-compose.cypress.yml new file mode 100644 index 000000000..3d577e638 --- /dev/null +++ b/docker-compose.cypress.yml @@ -0,0 +1,18 @@ +version: "3.7" + +services: + neo4j: + environment: + - NEO4J_AUTH=none + ports: + - 7687:7687 + - 7474:7474 + backend: + ports: + - 4001:4001 + - 4123:4123 + image: humanconnection/nitro-backend:builder + build: + context: . + target: builder + command: yarn run test:cypress diff --git a/humanconnection.png b/humanconnection.png new file mode 100644 index 000000000..f0576413f Binary files /dev/null and b/humanconnection.png differ diff --git a/src/jwt/decode.js b/src/jwt/decode.js new file mode 100644 index 000000000..0ab1e4529 --- /dev/null +++ b/src/jwt/decode.js @@ -0,0 +1,29 @@ +import jwt from 'jsonwebtoken' + +export default async (driver, authorizationHeader) => { + if (!authorizationHeader) return null + const token = authorizationHeader.replace('Bearer ', '') + let id = null + try { + const decoded = await jwt.verify(token, process.env.JWT_SECRET) + id = decoded.sub + } catch { + return null + } + const session = driver.session() + const query = ` + MATCH (user:User {id: {id} }) + RETURN user {.id, .slug, .name, .avatar, .email, .role} as user + LIMIT 1 + ` + const result = await session.run(query, { id }) + session.close() + const [currentUser] = await result.records.map((record) => { + return record.get('user') + }) + if (!currentUser) return null + return { + token, + ...currentUser + } +} diff --git a/src/jwt/encode.js b/src/jwt/encode.js new file mode 100644 index 000000000..f32fc12da --- /dev/null +++ b/src/jwt/encode.js @@ -0,0 +1,17 @@ + +import jwt from 'jsonwebtoken' +import ms from 'ms' + +// Generate an Access Token for the given User ID +export default function encode (user) { + const token = jwt.sign(user, process.env.JWT_SECRET, { + expiresIn: ms('1d'), + issuer: process.env.GRAPHQL_URI, + audience: process.env.CLIENT_URI, + subject: user.id.toString() + }) + // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => { + // console.log('token verification:', err, data) + // }) + return token +} diff --git a/src/middleware/softDeleteMiddleware.spec.js b/src/middleware/softDeleteMiddleware.spec.js new file mode 100644 index 000000000..e9bc461f1 --- /dev/null +++ b/src/middleware/softDeleteMiddleware.spec.js @@ -0,0 +1,130 @@ +import Factory from '../seed/factories' +import { host, login } from '../jest/helpers' +import { GraphQLClient } from 'graphql-request' + +const factory = Factory() +let client +let query +let action + +beforeEach(async () => { + await Promise.all([ + factory.create('User', { role: 'user', email: 'user@example.org', password: '1234' }), + factory.create('User', { id: 'm1', role: 'moderator', email: 'moderator@example.org', password: '1234' }) + ]) + await factory.authenticateAs({ email: 'user@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { title: 'Deleted post', deleted: true }), + factory.create('Post', { id: 'p2', title: 'Disabled post', deleted: false }), + factory.create('Post', { title: 'Publicly visible post', deleted: false }) + ]) + const moderatorFactory = Factory() + await moderatorFactory.authenticateAs({ email: 'moderator@example.org', password: '1234' }) + const disableMutation = ` + mutation { + disable(resource: { + id: "p2" + type: contribution + }) + } + ` + await moderatorFactory.mutate(disableMutation) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('softDeleteMiddleware', () => { + describe('Post', () => { + action = () => { + return client.request(query) + } + + beforeEach(() => { + query = '{ Post { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('hides deleted or disabled posts', async () => { + const expected = { Post: [{ title: 'Publicly visible post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('hides deleted or disabled posts', async () => { + const expected = { Post: [{ title: 'Publicly visible post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + + describe('filter (deleted: true)', () => { + beforeEach(() => { + query = '{ Post(deleted: true) { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorisation error', async () => { + await expect(action()).rejects.toThrow('Not Authorised!') + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('shows deleted posts', async () => { + const expected = { Post: [{ title: 'Deleted post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + }) + + describe('filter (disabled: true)', () => { + beforeEach(() => { + query = '{ Post(disabled: true) { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorisation error', async () => { + await expect(action()).rejects.toThrow('Not Authorised!') + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('shows disabled posts', async () => { + const expected = { Post: [{ title: 'Disabled post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/src/resolvers/badges.spec.js b/src/resolvers/badges.spec.js new file mode 100644 index 000000000..e38f54381 --- /dev/null +++ b/src/resolvers/badges.spec.js @@ -0,0 +1,223 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +describe('badges', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'user@example.org', + role: 'user', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + role: 'moderator', + email: 'moderator@example.org' + }) + await factory.create('User', { + id: 'u3', + role: 'admin', + email: 'admin@example.org' + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('CreateBadge', () => { + const variables = { + id: 'b1', + key: 'indiegogo_en_racoon', + type: 'crowdfunding', + status: 'permanent', + icon: '/img/badges/indiegogo_en_racoon.svg' + } + + const mutation = ` + mutation( + $id: ID + $key: String! + $type: BadgeTypeEnum! + $status: BadgeStatusEnum! + $icon: String! + ) { + CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) { + id, + key, + type, + status, + icon + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('creates a badge', async () => { + const expected = { + CreateBadge: { + icon: '/img/badges/indiegogo_en_racoon.svg', + id: 'b1', + key: 'indiegogo_en_racoon', + status: 'permanent', + type: 'crowdfunding' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + }) + + describe('UpdateBadge', () => { + beforeEach(async () => { + await factory.authenticateAs({ email: 'admin@example.org', password: '1234' }) + await factory.create('Badge', { id: 'b1' }) + }) + const variables = { + id: 'b1', + key: 'whatever' + } + + const mutation = ` + mutation($id: ID!, $key: String!) { + UpdateBadge(id: $id, key: $key) { + id + key + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('updates a badge', async () => { + const expected = { + UpdateBadge: { + id: 'b1', + key: 'whatever' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + }) + + describe('DeleteBadge', () => { + beforeEach(async () => { + await factory.authenticateAs({ email: 'admin@example.org', password: '1234' }) + await factory.create('Badge', { id: 'b1' }) + }) + const variables = { + id: 'b1' + } + + const mutation = ` + mutation($id: ID!) { + DeleteBadge(id: $id) { + id + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('deletes a badge', async () => { + const expected = { + DeleteBadge: { + id: 'b1' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + }) +}) diff --git a/src/resolvers/follow.spec.js b/src/resolvers/follow.spec.js new file mode 100644 index 000000000..3c16560e5 --- /dev/null +++ b/src/resolvers/follow.spec.js @@ -0,0 +1,115 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let clientUser1 + +const mutationFollowUser = (id) => ` + mutation { + follow(id: "${id}", type: User) + } +` +const mutationUnfollowUser = (id) => ` + mutation { + unfollow(id: "${id}", type: User) + } +` + +beforeEach(async () => { + await factory.create('User', { + id: 'u1', + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + email: 'test2@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('follow ', () => { + describe('(un)follow user', () => { + let headersUser1 + beforeEach(async () => { + headersUser1 = await login({ email: 'test@example.org', password: '1234' }) + clientUser1 = new GraphQLClient(host, { headers: headersUser1 }) + }) + + it('I can follow another user', async () => { + const res = await clientUser1.request( + mutationFollowUser('u2') + ) + const expected = { + follow: true + } + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u2") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [ + { id: 'u1' } + ], + followedByCurrentUser: true + } + expect(User[0]).toMatchObject(expected2) + }) + + it('I can unfollow a user', async () => { + // follow + await clientUser1.request( + mutationFollowUser('u2') + ) + const expected = { + unfollow: true + } + // unfollow + const res = await clientUser1.request(mutationUnfollowUser('u2')) + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u2") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [], + followedByCurrentUser: false + } + expect(User[0]).toMatchObject(expected2) + }) + + it('I can`t follow myself', async () => { + const res = await clientUser1.request( + mutationFollowUser('u1') + ) + const expected = { + follow: false + } + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u1") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [], + followedByCurrentUser: false + } + expect(User[0]).toMatchObject(expected2) + }) + }) +}) diff --git a/src/resolvers/moderation.js b/src/resolvers/moderation.js new file mode 100644 index 000000000..db44790b9 --- /dev/null +++ b/src/resolvers/moderation.js @@ -0,0 +1,30 @@ +export default { + Mutation: { + disable: async (object, params, { user, driver }) => { + const { resource: { id } } = params + const { id: userId } = user + const cypher = ` + MATCH (u:User {id: $userId}) + MATCH (r {id: $id}) + SET r.disabled = true + MERGE (r)<-[:DISABLED]-(u) + ` + const session = driver.session() + const res = await session.run(cypher, { id, userId }) + session.close() + return Boolean(res) + }, + enable: async (object, params, { user, driver }) => { + const { resource: { id } } = params + const cypher = ` + MATCH (r {id: $id})<-[d:DISABLED]-() + SET r.disabled = false + DELETE d + ` + const session = driver.session() + const res = await session.run(cypher, { id }) + session.close() + return Boolean(res) + } + } +} diff --git a/src/resolvers/moderation.spec.js b/src/resolvers/moderation.spec.js new file mode 100644 index 000000000..c1d4a75fe --- /dev/null +++ b/src/resolvers/moderation.spec.js @@ -0,0 +1,370 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let client + +const setupAuthenticateClient = (params) => { + const authenticateClient = async () => { + await factory.create('User', params) + const headers = await login(params) + client = new GraphQLClient(host, { headers }) + } + return authenticateClient +} + +let setup +const runSetup = async () => { + await setup.createResource() + await setup.authenticateClient() +} + +beforeEach(() => { + setup = { + createResource: () => { + }, + authenticateClient: () => { + client = new GraphQLClient(host) + } + } +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('disable', () => { + const mutation = ` + mutation($id: ID!, $type: ResourceEnum!) { + disable(resource: { id: $id, type: $type }) + } + ` + let variables + + beforeEach(() => { + // our defaul set of variables + variables = { + id: 'blabla', + type: 'contribution' + } + }) + + const action = async () => { + return client.request(mutation, variables) + } + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + email: 'user@example.org', + password: '1234' + }) + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('as moderator', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + id: 'u7', + email: 'moderator@example.org', + password: '1234', + role: 'moderator' + }) + }) + + describe('on a comment', () => { + beforeEach(async () => { + variables = { + id: 'c47', + type: 'comment' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u45', email: 'commenter@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'commenter@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { id: 'p3' }), + factory.create('Comment', { id: 'c47' }) + ]) + await Promise.all([ + factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' }), + factory.relate('Comment', 'Post', { from: 'c47', to: 'p3' }) + ]) + } + }) + + it('returns true', async () => { + const expected = { disable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Comment: [{ id: 'c47', disabledBy: null }] } + const expected = { Comment: [{ id: 'c47', disabledBy: { id: 'u7' } }] } + + await runSetup() + await expect(client.request( + '{ Comment { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on comment', async () => { + const before = { Comment: [ { id: 'c47', disabled: false } ] } + const expected = { Comment: [ { id: 'c47', disabled: true } ] } + + await runSetup() + await expect(client.request( + '{ Comment { id disabled } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment(disabled: true) { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + + describe('on a post', () => { + beforeEach(async () => { + variables = { + id: 'p9', + type: 'contribution' + } + + setup.createResource = async () => { + await factory.create('User', { email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await factory.create('Post', { + id: 'p9' // that's the ID we will look for + }) + } + }) + + it('returns true', async () => { + const expected = { disable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Post: [{ id: 'p9', disabledBy: null }] } + const expected = { Post: [{ id: 'p9', disabledBy: { id: 'u7' } }] } + + await runSetup() + await expect(client.request( + '{ Post { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Post: [ { id: 'p9', disabled: false } ] } + const expected = { Post: [ { id: 'p9', disabled: true } ] } + + await runSetup() + await expect(client.request( + '{ Post { id disabled } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post(disabled: true) { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + }) + }) +}) + +describe('enable', () => { + const mutation = ` + mutation($id: ID!, $type: ResourceEnum!) { + enable(resource: { id: $id, type: $type }) + } + ` + let variables + + const action = async () => { + return client.request(mutation, variables) + } + + beforeEach(() => { + // our defaul set of variables + variables = { + id: 'blabla', + type: 'contribution' + } + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + email: 'user@example.org', + password: '1234' + }) + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('as moderator', () => { + beforeEach(async () => { + setup.authenticateClient = setupAuthenticateClient({ + role: 'moderator', + email: 'someUser@example.org', + password: '1234' + }) + }) + + describe('on a comment', () => { + beforeEach(async () => { + variables = { + id: 'c456', + type: 'comment' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u123', email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { id: 'p9' }), + factory.create('Comment', { id: 'c456' }) + ]) + await Promise.all([ + factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' }), + factory.relate('Comment', 'Post', { from: 'c456', to: 'p9' }) + ]) + + const disableMutation = ` + mutation { + disable(resource: { + id: "c456" + type: comment + }) + } + ` + await factory.mutate(disableMutation) // that's we want to delete + } + }) + + it('returns true', async () => { + const expected = { enable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Comment: [{ id: 'c456', disabledBy: { id: 'u123' } }] } + const expected = { Comment: [{ id: 'c456', disabledBy: null }] } + + await runSetup() + await expect(client.request( + '{ Comment(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Comment: [ { id: 'c456', disabled: true } ] } + const expected = { Comment: [ { id: 'c456', disabled: false } ] } + + await runSetup() + await expect(client.request( + '{ Comment(disabled: true) { id disabled } }' + )).resolves.toEqual(before) + await action() // this updates .disabled + await expect(client.request( + '{ Comment { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + + describe('on a post', () => { + beforeEach(async () => { + variables = { + id: 'p9', + type: 'contribution' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u123', email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await factory.create('Post', { + id: 'p9' // that's the ID we will look for + }) + + const disableMutation = ` + mutation { + disable(resource: { + id: "p9" + type: contribution + }) + } + ` + await factory.mutate(disableMutation) // that's we want to delete + } + }) + + it('returns true', async () => { + const expected = { enable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Post: [{ id: 'p9', disabledBy: { id: 'u123' } }] } + const expected = { Post: [{ id: 'p9', disabledBy: null }] } + + await runSetup() + await expect(client.request( + '{ Post(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Post: [ { id: 'p9', disabled: true } ] } + const expected = { Post: [ { id: 'p9', disabled: false } ] } + + await runSetup() + await expect(client.request( + '{ Post(disabled: true) { id disabled } }' + )).resolves.toEqual(before) + await action() // this updates .disabled + await expect(client.request( + '{ Post { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/src/resolvers/posts.js b/src/resolvers/posts.js new file mode 100644 index 000000000..c813ec3f1 --- /dev/null +++ b/src/resolvers/posts.js @@ -0,0 +1,63 @@ +import { neo4jgraphql } from 'neo4j-graphql-js' +import { activityPub } from '../activitypub/ActivityPub' +import uuid from 'uuid/v4' +import as from 'activitystrea.ms' +/* +import as from 'activitystrea.ms' +import request from 'request' +*/ + +const debug = require('debug')('backend:schema') + +export default { + Mutation: { + CreatePost: async (object, params, context, resolveInfo) => { + params.activityId = uuid() + const result = await neo4jgraphql(object, params, context, resolveInfo, false) + + const session = context.driver.session() + const author = await session.run( + 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + + 'MERGE (post)<-[:WROTE]-(author) ' + + 'RETURN author', { + userId: context.user.id, + postId: result.id + }) + + debug(`actorId = ${author.records[0]._fields[0].properties.actorId}`) + if (Array.isArray(author.records) && author.records.length > 0) { + const actorId = author.records[0]._fields[0].properties.actorId + const createActivity = await new Promise((resolve, reject) => { + as.create() + .id(`${actorId}/status/${params.activityId}`) + .actor(`${actorId}`) + .object( + as.article() + .id(`${actorId}/status/${result.id}`) + .content(result.content) + .to('https://www.w3.org/ns/activitystreams#Public') + .publishedNow() + .attributedTo(`${actorId}`) + ).prettyWrite((err, doc) => { + if (err) { + reject(err) + } else { + debug(doc) + const parsedDoc = JSON.parse(doc) + parsedDoc.send = true + resolve(JSON.stringify(parsedDoc)) + } + }) + }) + try { + await activityPub.sendActivity(createActivity) + } catch (e) { + debug(`error sending post activity = ${JSON.stringify(e, null, 2)}`) + } + } + session.close() + + return result + } + } +} diff --git a/src/resolvers/posts.spec.js b/src/resolvers/posts.spec.js new file mode 100644 index 000000000..5603683eb --- /dev/null +++ b/src/resolvers/posts.spec.js @@ -0,0 +1,202 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let client + +beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('CreatePost', () => { + const mutation = ` + mutation { + CreatePost(title: "I am a title", content: "Some content") { + title + content + slug + disabled + deleted + } + } + ` + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('creates a post', async () => { + const expected = { + CreatePost: { + title: 'I am a title', + content: 'Some content' + } + } + await expect(client.request(mutation)).resolves.toMatchObject(expected) + }) + + it('assigns the authenticated user as author', async () => { + await client.request(mutation) + const { User } = await client.request(`{ + User(email:"test@example.org") { + contributions { + title + } + } + }`, { headers }) + expect(User).toEqual([ { contributions: [ { title: 'I am a title' } ] } ]) + }) + + describe('disabled and deleted', () => { + it('initially false', async () => { + const expected = { CreatePost: { disabled: false, deleted: false } } + await expect(client.request(mutation)).resolves.toMatchObject(expected) + }) + }) + }) +}) + +describe('UpdatePost', () => { + const mutation = ` + mutation($id: ID!, $content: String) { + UpdatePost(id: $id, content: $content) { + id + content + } + } + ` + + let variables = { + id: 'p1', + content: 'New content' + } + + beforeEach(async () => { + const asAuthor = Factory() + await asAuthor.create('User', { + email: 'author@example.org', + password: '1234' + }) + await asAuthor.authenticateAs({ + email: 'author@example.org', + password: '1234' + }) + await asAuthor.create('Post', { + id: 'p1', + content: 'Old content' + }) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated but not the author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated as author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'author@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('updates a post', async () => { + const expected = { UpdatePost: { id: 'p1', content: 'New content' } } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) +}) + +describe('DeletePost', () => { + const mutation = ` + mutation($id: ID!) { + DeletePost(id: $id) { + id + content + } + } + ` + + let variables = { + id: 'p1' + } + + beforeEach(async () => { + const asAuthor = Factory() + await asAuthor.create('User', { + email: 'author@example.org', + password: '1234' + }) + await asAuthor.authenticateAs({ + email: 'author@example.org', + password: '1234' + }) + await asAuthor.create('Post', { + id: 'p1', + content: 'To be deleted' + }) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated but not the author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated as author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'author@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('deletes a post', async () => { + const expected = { DeletePost: { id: 'p1', content: 'To be deleted' } } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) +}) diff --git a/src/resolvers/reports.js b/src/resolvers/reports.js new file mode 100644 index 000000000..c471d7b7a --- /dev/null +++ b/src/resolvers/reports.js @@ -0,0 +1,51 @@ +import uuid from 'uuid/v4' + +export default { + Mutation: { + report: async (parent, { resource, description }, { driver, req, user }, resolveInfo) => { + const contextId = uuid() + const session = driver.session() + const data = { + id: contextId, + type: resource.type, + createdAt: (new Date()).toISOString(), + description: resource.description + } + await session.run( + 'CREATE (r:Report $report) ' + + 'RETURN r.id, r.type, r.description', { + report: data + } + ) + let contentType + + switch (resource.type) { + case 'post': + case 'contribution': + contentType = 'Post' + break + case 'comment': + contentType = 'Comment' + break + case 'user': + contentType = 'User' + break + } + + await session.run( + `MATCH (author:User {id: $userId}), (context:${contentType} {id: $resourceId}), (report:Report {id: $contextId}) ` + + 'MERGE (report)<-[:REPORTED]-(author) ' + + 'MERGE (context)<-[:REPORTED]-(report) ' + + 'RETURN context', { + resourceId: resource.id, + userId: user.id, + contextId: contextId + } + ) + session.close() + + // TODO: output Report compatible object + return data + } + } +} diff --git a/src/resolvers/reports.spec.js b/src/resolvers/reports.spec.js new file mode 100644 index 000000000..253cdadcc --- /dev/null +++ b/src/resolvers/reports.spec.js @@ -0,0 +1,68 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +describe('report', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + name: 'abusive-user', + role: 'user', + email: 'abusive-user@example.org' + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('unauthenticated', () => { + let client + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`) + ).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + let headers + let response + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + response = await client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`, + { headers } + ) + }) + it('creates a report', () => { + let { id, createdAt } = response.report + expect(response).toEqual({ + report: { id, createdAt } + }) + }) + }) + }) +}) diff --git a/src/resolvers/shout.spec.js b/src/resolvers/shout.spec.js new file mode 100644 index 000000000..490191c7a --- /dev/null +++ b/src/resolvers/shout.spec.js @@ -0,0 +1,126 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let clientUser1, clientUser2 + +const mutationShoutPost = (id) => ` + mutation { + shout(id: "${id}", type: Post) + } +` +const mutationUnshoutPost = (id) => ` + mutation { + unshout(id: "${id}", type: Post) + } +` + +beforeEach(async () => { + await factory.create('User', { + id: 'u1', + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + email: 'test2@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('shout ', () => { + describe('(un)shout foreign post', () => { + let headersUser1, headersUser2 + beforeEach(async () => { + headersUser1 = await login({ email: 'test@example.org', password: '1234' }) + headersUser2 = await login({ email: 'test2@example.org', password: '1234' }) + clientUser1 = new GraphQLClient(host, { headers: headersUser1 }) + clientUser2 = new GraphQLClient(host, { headers: headersUser2 }) + + await clientUser1.request(` + mutation { + CreatePost(id: "p1", title: "Post Title 1", content: "Some Post Content 1") { + id + title + } + } + `) + await clientUser2.request(` + mutation { + CreatePost(id: "p2", title: "Post Title 2", content: "Some Post Content 2") { + id + title + } + } + `) + }) + + it('I shout a post of another user', async () => { + const res = await clientUser1.request( + mutationShoutPost('p2') + ) + const expected = { + shout: true + } + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p2") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: true + } + expect(Post[0]).toMatchObject(expected2) + }) + + it('I unshout a post of another user', async () => { + // shout + await clientUser1.request( + mutationShoutPost('p2') + ) + const expected = { + unshout: true + } + // unshout + const res = await clientUser1.request(mutationUnshoutPost('p2')) + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p2") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: false + } + expect(Post[0]).toMatchObject(expected2) + }) + + it('I can`t shout my own post', async () => { + const res = await clientUser1.request( + mutationShoutPost('p1') + ) + const expected = { + shout: false + } + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p1") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: false + } + expect(Post[0]).toMatchObject(expected2) + }) + }) +}) diff --git a/src/resolvers/statistics.js b/src/resolvers/statistics.js new file mode 100644 index 000000000..17c4be956 --- /dev/null +++ b/src/resolvers/statistics.js @@ -0,0 +1,67 @@ +export const query = (cypher, session) => { + return new Promise((resolve, reject) => { + let data = [] + session + .run(cypher) + .subscribe({ + onNext: function (record) { + let item = {} + record.keys.forEach(key => { + item[key] = record.get(key) + }) + data.push(item) + }, + onCompleted: function () { + session.close() + resolve(data) + }, + onError: function (error) { + reject(error) + } + }) + }) +} +const queryOne = (cypher, session) => { + return new Promise((resolve, reject) => { + query(cypher, session) + .then(res => { + resolve(res.length ? res.pop() : {}) + }) + .catch(err => { + reject(err) + }) + }) +} + +export default { + Query: { + statistics: async (parent, args, { driver, user }) => { + return new Promise(async (resolve) => { + const session = driver.session() + const queries = { + countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers', + countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts', + countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments', + countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications', + countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations', + countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects', + countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites', + countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', + countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts' + } + let data = { + countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, + countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, + countComments: (await queryOne(queries.countComments, session)).countComments.low, + countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low, + countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low, + countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, + countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, + countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, + countShouts: (await queryOne(queries.countShouts, session)).countShouts.low + } + resolve(data) + }) + } + } +} diff --git a/src/resolvers/user_management.js b/src/resolvers/user_management.js new file mode 100644 index 000000000..ec4ae7ce2 --- /dev/null +++ b/src/resolvers/user_management.js @@ -0,0 +1,51 @@ +import encode from '../jwt/encode' +import bcrypt from 'bcryptjs' +import { AuthenticationError } from 'apollo-server' +import { neo4jgraphql } from 'neo4j-graphql-js' + +export default { + Query: { + isLoggedIn: (parent, args, { driver, user }) => { + return Boolean(user && user.id) + }, + currentUser: async (object, params, ctx, resolveInfo) => { + const { user } = ctx + if (!user) return null + return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, false) + } + }, + Mutation: { + signup: async (parent, { email, password }, { req }) => { + // if (data[email]) { + // throw new Error('Another User with same email exists.') + // } + // data[email] = { + // password: await bcrypt.hashSync(password, 10), + // } + + return true + }, + login: async (parent, { email, password }, { driver, req, user }) => { + // if (user && user.id) { + // throw new Error('Already logged in.') + // } + const session = driver.session() + return session.run( + 'MATCH (user:User {email: $userEmail}) ' + + 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role} as user LIMIT 1', { + userEmail: email + }) + .then(async (result) => { + session.close() + const [currentUser] = await result.records.map(function (record) { + return record.get('user') + }) + + if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { + delete currentUser.password + return encode(currentUser) + } else throw new AuthenticationError('Incorrect email address or password.') + }) + } + } +} diff --git a/src/resolvers/user_management.spec.js b/src/resolvers/user_management.spec.js new file mode 100644 index 000000000..a3bf6fdd0 --- /dev/null +++ b/src/resolvers/user_management.spec.js @@ -0,0 +1,179 @@ +import Factory from '../seed/factories' +import { GraphQLClient, request } from 'graphql-request' +import jwt from 'jsonwebtoken' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +// here is the decoded JWT token: +// { +// role: 'user', +// locationName: null, +// name: 'Jenny Rostock', +// about: null, +// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', +// id: 'u3', +// email: 'user@example.org', +// slug: 'jenny-rostock', +// iat: 1550846680, +// exp: 1637246680, +// aud: 'http://localhost:3000', +// iss: 'http://localhost:4000', +// sub: 'u3' +// } +const jennyRostocksHeaders = { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc' } + +beforeEach(async () => { + await factory.create('User', { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('isLoggedIn', () => { + const query = '{ isLoggedIn }' + describe('unauthenticated', () => { + it('returns false', async () => { + await expect(request(host, query)).resolves.toEqual({ isLoggedIn: false }) + }) + }) + + describe('with malformed JWT Bearer token', () => { + const headers = { authorization: 'blah' } + const client = new GraphQLClient(host, { headers }) + + it('returns false', async () => { + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: false }) + }) + }) + + describe('with valid JWT Bearer token', () => { + const client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + + it('returns false', async () => { + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: false }) + }) + + describe('and a corresponding user in the database', () => { + it('returns true', async () => { + // see the decoded token above + await factory.create('User', { id: 'u3' }) + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: true }) + }) + }) + }) +}) + +describe('currentUser', () => { + const query = `{ + currentUser { + id + slug + name + avatar + email + role + } + }` + + describe('unauthenticated', () => { + it('returns null', async () => { + const expected = { currentUser: null } + await expect(request(host, query)).resolves.toEqual(expected) + }) + }) + + describe('with valid JWT Bearer Token', () => { + let client + let headers + + describe('but no corresponding user in the database', () => { + beforeEach(async () => { + client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + }) + + it('returns null', async () => { + const expected = { currentUser: null } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + + describe('and corresponding user in the database', () => { + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('returns the whole user object', async () => { + const expected = { + currentUser: { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + email: 'test@example.org', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user' + } + } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + }) +}) + +describe('login', () => { + const mutation = (params) => { + const { email, password } = params + return ` + mutation { + login(email:"${email}", password:"${password}") + }` + } + + describe('ask for a `token`', () => { + describe('with valid email/password combination', () => { + it('responds with a JWT token', async () => { + const data = await request(host, mutation({ + email: 'test@example.org', + password: '1234' + })) + const token = data.login + jwt.verify(token, process.env.JWT_SECRET, (err, data) => { + expect(data.email).toEqual('test@example.org') + expect(err).toBeNull() + }) + }) + }) + + describe('with a valid email but incorrect password', () => { + it('responds with "Incorrect email address or password."', async () => { + await expect( + request(host, mutation({ + email: 'test@example.org', + password: 'wrong' + })) + ).rejects.toThrow('Incorrect email address or password.') + }) + }) + + describe('with a non-existing email', () => { + it('responds with "Incorrect email address or password."', async () => { + await expect( + request(host, mutation({ + email: 'non-existent@example.org', + password: 'wrong' + })) + ).rejects.toThrow('Incorrect email address or password.') + }) + }) + }) +})