diff --git a/.travis.yml b/.travis.yml index eaba8dba3..f4a01b147 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ install: script: - docker-compose exec backend yarn run lint - - docker-compose exec backend yarn run test + - docker-compose exec backend yarn run test --ci - docker-compose exec backend yarn run test:coverage - docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:seed diff --git a/README.md b/README.md index da8758334..1b12562d2 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ npm run db:reset **Beware**: We have no multiple database setup at the moment. We clean the database after each test, running the tests will wipe out all your data! +Run the tests: ```bash yarn run test # -or- diff --git a/dist/.gitkeep b/dist/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/package.json b/package.json index 3efca40be..116d923fb 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,16 @@ "scripts": { "build": "babel src/ -d dist/ --copy-files", "start": "node dist/", - "dev": "nodemon --exec babel-node src/index.js", + "dev": "nodemon --exec babel-node src/", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js", "lint": "eslint src --config .eslintrc.js", "test": "nyc --reporter=text-lcov yarn run test:jest", - "test:jest": "$npm_package_config_no_auth run-p --race start test:cmd:jest", - "test:cmd:jest": "jest --forceExit", + "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", + "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled babel-node src/ 2> /dev/null", + "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", + "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", + "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", + "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", "test:coverage": "nyc report --reporter=text-lcov > coverage.lcov", "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", "db:script:reset": "wait-on tcp:4001 && babel-node src/seed/reset-db.js", @@ -25,7 +29,9 @@ "license": "MIT", "jest": { "verbose": true, - "testMatch": ["**/src/**/?(*.)+(spec|test).js?(x)" ] + "testMatch": [ + "**/src/**/?(*.)+(spec|test).js?(x)" + ] }, "dependencies": { "apollo-cache-inmemory": "~1.4.0", diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 000000000..f1f641af1 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1 @@ +#!/usr/bin/env bash diff --git a/src/graphql-schema.test.js b/src/graphql-schema.spec.js similarity index 72% rename from src/graphql-schema.test.js rename to src/graphql-schema.spec.js index a58fb3599..69073444c 100644 --- a/src/graphql-schema.test.js +++ b/src/graphql-schema.spec.js @@ -1,23 +1,7 @@ import { request } from 'graphql-request' -import createServer from './server' -import mocks from './mocks' import { create, cleanDatabase } from './seed/factories' import jwt from 'jsonwebtoken' - -let getHost -let app -let port - -beforeEach(async () => { - const server = createServer({ mocks }) - app = await server.start({ port: 0 }) - port = app.address().port - getHost = () => `http://127.0.0.1:${port}` -}) - -afterEach(async () => { - await app.close() -}) +import { host } from './jest/helpers' describe('login', () => { const mutation = (params) => { @@ -45,7 +29,7 @@ describe('login', () => { describe('asking for a `token`', () => { describe('with valid email/password combination', () => { it('responds with a JWT token', async () => { - const data = await request(getHost(), mutation({ email: 'test@example.org', password: '1234' })) + 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') @@ -57,7 +41,7 @@ describe('login', () => { describe('with a valid email but incorrect password', () => { it('responds with "Incorrect email address or password."', async () => { try { - await request(getHost(), mutation({ email: 'test@example.org', password: 'wrong' })) + await request(host, mutation({ email: 'test@example.org', password: 'wrong' })) } catch (error) { expect(error.response.errors[0].message).toEqual('Incorrect email address or password.') } @@ -67,7 +51,7 @@ describe('login', () => { describe('with a non-existing email', () => { it('responds with "Incorrect email address or password."', async () => { try { - await request(getHost(), mutation({ email: 'non-existent@example.org', password: 'wrong' })) + await request(host, mutation({ email: 'non-existent@example.org', password: 'wrong' })) } catch (error) { expect(error.response.errors[0].message).toEqual('Incorrect email address or password.') } diff --git a/src/jest/helpers.js b/src/jest/helpers.js new file mode 100644 index 000000000..01a26e9d3 --- /dev/null +++ b/src/jest/helpers.js @@ -0,0 +1,16 @@ +import { request } from 'graphql-request' + +export const host = 'http://127.0.0.1:4123' + +export async function authenticatedHeaders ({ email, password }) { + const mutation = ` + mutation { + login(email:"${email}", password:"${password}"){ + token + } + }` + const response = await request(host, mutation) + return { + authorization: `Bearer ${response.login.token}` + } +} diff --git a/src/middleware/passwordMiddleware.js b/src/middleware/passwordMiddleware.js index 16480b126..0aff222c8 100644 --- a/src/middleware/passwordMiddleware.js +++ b/src/middleware/passwordMiddleware.js @@ -13,7 +13,7 @@ export default { Query: async (resolve, root, args, context, info) => { const result = await resolve(root, args, context, info) return walkRecursive(result, ['password'], () => { - // replace password with asterix + // replace password with asterisk return '*****' }) } diff --git a/src/middleware/permissionsMiddleware.spec.js b/src/middleware/permissionsMiddleware.spec.js new file mode 100644 index 000000000..cf86d11c9 --- /dev/null +++ b/src/middleware/permissionsMiddleware.spec.js @@ -0,0 +1,72 @@ +import { create, cleanDatabase } from '../seed/factories' +import { host, authenticatedHeaders } from '../jest/helpers' +import { GraphQLClient } from 'graphql-request' + +describe('authorization', () => { + describe('given two existing users', () => { + beforeEach(async () => { + await create('user', { + email: 'owner@example.org', + name: 'Owner', + password: 'iamtheowner' + }) + await create('user', { + email: 'someone@example.org', + name: 'Someone else', + password: 'else' + }) + }) + + afterEach(async () => { + await cleanDatabase() + }) + + describe('access email address', () => { + let headers = {} + const action = async (headers) => { + const graphQLClient = new GraphQLClient(host, { headers }) + return graphQLClient.request(`{ + User(name: "Owner") { + email + } + }`) + } + + describe('not logged in', async () => { + it('does not expose the owner\'s email address', async () => { + try { + await action(headers) + } catch (error) { + expect(error.response.errors[0].message).toEqual('Not Authorised!') + expect(error.response.data).toEqual({ User: [ { email: null } ] }) + } + }) + }) + + describe('as owner', () => { + it('exposes the owner\'s email address', async () => { + headers = await authenticatedHeaders({ + email: 'owner@example.org', + password: 'iamtheowner' + }) + expect(await action(headers)).toEqual({ User: [ { email: 'owner@example.org' } ] }) + }) + }) + + describe('as someone else', () => { + it('does not expose the owner\'s email address', async () => { + headers = await authenticatedHeaders({ + email: 'someone@example.org', + password: 'else' + }) + try { + await action(headers) + } catch (error) { + expect(error.response.errors[0].message).toEqual('Not Authorised!') + expect(error.response.data).toEqual({ User: [ { email: null } ] }) + } + }) + }) + }) + }) +}) diff --git a/src/middleware/permissionsMiddleware.test.js b/src/middleware/permissionsMiddleware.test.js deleted file mode 100644 index 3cf808e39..000000000 --- a/src/middleware/permissionsMiddleware.test.js +++ /dev/null @@ -1,15 +0,0 @@ -describe('query', () => { - describe('statistics', () => { - describe('authenticated user', () => { - describe('read', () => { - xit('is forbidden', () => {}) - }) - }) - - describe('admin', () => { - describe('read', () => { - xit('is permitted', () => {}) - }) - }) - }) -}) diff --git a/src/middleware/userMiddleware.js b/src/middleware/userMiddleware.js index 55b181bc9..d4648413a 100644 --- a/src/middleware/userMiddleware.js +++ b/src/middleware/userMiddleware.js @@ -1,4 +1,5 @@ import createOrUpdateLocations from './nodes/locations' +import find from 'lodash/find' export default { Mutation: { @@ -12,5 +13,28 @@ export default { await createOrUpdateLocations(args.id, args.locationName, context.driver) return result } + }, + Query: { + User: async (resolve, root, args, context, info) => { + let isIdPresent + let removeIdFromResult + try { + isIdPresent = find(info.fieldNodes[0].selectionSet.selections, item => item.name.value === 'id') + if (!isIdPresent) { + // add id to request as the user did not ask but we need it + info.fieldNodes[0].selectionSet.selections.unshift({ + kind: 'Field', + name: { kind: 'Name', value: 'id' } + }) + removeIdFromResult = true + } + } catch (err) {} + const result = await resolve(root, args, context, info) + if (!isIdPresent && removeIdFromResult) { + // remove id if the user did not ask for it + info.fieldNodes[0].selectionSet.selections.shift() + } + return result + } } } diff --git a/src/seed/data/index.js b/src/seed/data/index.js index 33bc56f36..1184408d9 100644 --- a/src/seed/data/index.js +++ b/src/seed/data/index.js @@ -33,6 +33,7 @@ export default async function (client) { } catch (err) { /* eslint-disable-next-line no-console */ console.error(err) + process.exit(1) } }) /* eslint-disable-next-line no-console */ diff --git a/src/seed/factories/index.js b/src/seed/factories/index.js index d665de4a0..e62e98869 100644 --- a/src/seed/factories/index.js +++ b/src/seed/factories/index.js @@ -4,7 +4,6 @@ import dotenv from 'dotenv' import { HttpLink } from 'apollo-link-http' import { InMemoryCache } from 'apollo-cache-inmemory' import neo4j from '../../bootstrap/neo4j' -import { query } from '../../graphql-schema' import fetch from 'node-fetch' dotenv.config() @@ -14,12 +13,11 @@ if (process.env.NODE_ENV === 'production') { } const client = new ApolloClient({ - link: new HttpLink({ uri: process.env.GRAPHQL_URI, fetch }), + link: new HttpLink({ uri: 'http://localhost:4001', fetch }), cache: new InMemoryCache() }) const driver = neo4j().getDriver() -const session = driver.session() const builders = { 'user': require('./users.js').default @@ -29,12 +27,20 @@ const buildMutation = (model, parameters) => { return builders[model](parameters) } -const create = async (model, parameters) => { - await client.mutate({ mutation: gql(buildMutation(model, parameters)) }) +const create = (model, parameters) => { + return client.mutate({ mutation: gql(buildMutation(model, parameters)) }) } const cleanDatabase = async () => { - await query('MATCH (n) DETACH DELETE n', session) + const session = driver.session() + const cypher = 'MATCH (n) DETACH DELETE n' + try { + const result = await session.run(cypher) + session.close() + return result + } catch (error) { + console.log(error) + } } export {