From 69233bb7e1e13e62cfdfa7a28662c3ab3eb74bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Babi=C4=8D?= Date: Mon, 14 Oct 2019 18:36:15 +0200 Subject: [PATCH] make tests run in parallel --- .gitignore | 2 - jest.config.js | 7 +- package-lock.json | 175 +++++++++++++++++ package.json | 16 +- src/app.spec.ts | 49 ----- src/app/UserResolver.spec.ts | 152 --------------- src/app/userResolver/auth.ts | 58 ------ src/bootstrap.ts | 24 +-- src/jestGlobalSetup.ts | 13 ++ src/server.spec.ts | 26 +++ src/{app.ts => server.ts} | 9 +- src/server/UserResolver.spec.ts | 178 ++++++++++++++++++ src/{app => server}/UserResolver.ts | 6 +- src/server/connection.ts | 15 ++ src/{app => server}/schema.ts | 21 +-- src/server/testing.ts | 40 ++++ .../userResolver/LoginTokens.ts | 0 src/{app => server}/userResolver/User.ts | 0 src/server/userResolver/auth.ts | 43 +++++ 19 files changed, 517 insertions(+), 317 deletions(-) delete mode 100644 src/app.spec.ts delete mode 100644 src/app/UserResolver.spec.ts delete mode 100644 src/app/userResolver/auth.ts create mode 100644 src/jestGlobalSetup.ts create mode 100644 src/server.spec.ts rename src/{app.ts => server.ts} (51%) create mode 100644 src/server/UserResolver.spec.ts rename src/{app => server}/UserResolver.ts (80%) create mode 100644 src/server/connection.ts rename src/{app => server}/schema.ts (55%) create mode 100644 src/server/testing.ts rename src/{app => server}/userResolver/LoginTokens.ts (100%) rename src/{app => server}/userResolver/User.ts (100%) create mode 100644 src/server/userResolver/auth.ts diff --git a/.gitignore b/.gitignore index c5d9c49..007ca3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.key -*.pub .vscode/ # Created by https://www.gitignore.io/api/node # Edit at https://www.gitignore.io/?templates=node diff --git a/jest.config.js b/jest.config.js index 91a2d2c..a9753ca 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', -}; \ No newline at end of file + preset: "ts-jest", + testEnvironment: "node", + globalSetup: "./src/jestGlobalSetup.ts", +} diff --git a/package-lock.json b/package-lock.json index 74e90e8..198dfbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1064,6 +1064,131 @@ "apollo-server-types": "^0.2.4" } }, + "apollo-server-testing": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/apollo-server-testing/-/apollo-server-testing-2.9.6.tgz", + "integrity": "sha512-pbURQD5VjNFk4GMVVxyCds9rY4/NIqjvjE4tyf1k89RHwMdk+zuVggt/DGudteorZtqAqtsOIHWojMBU4s2klA==", + "dev": true, + "requires": { + "apollo-server-core": "^2.9.6" + }, + "dependencies": { + "apollo-cache-control": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.8.5.tgz", + "integrity": "sha512-2yQ1vKgJQ54SGkoQS/ZLZrDX3La6cluAYYdruFYJMJtL4zQrSdeOCy11CQliCMYEd6eKNyE70Rpln51QswW2Og==", + "dev": true, + "requires": { + "apollo-server-env": "^2.4.3", + "graphql-extensions": "^0.10.4" + } + }, + "apollo-engine-reporting": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-1.4.7.tgz", + "integrity": "sha512-qsKDz9VkoctFhojM3Nj3nvRBO98t8TS2uTgtiIjUGs3Hln2poKMP6fIQ37Nm2Q2B3JJst76HQtpPwXmRJd1ZUg==", + "dev": true, + "requires": { + "apollo-engine-reporting-protobuf": "^0.4.1", + "apollo-graphql": "^0.3.4", + "apollo-server-caching": "^0.5.0", + "apollo-server-env": "^2.4.3", + "apollo-server-types": "^0.2.5", + "async-retry": "^1.2.1", + "graphql-extensions": "^0.10.4" + } + }, + "apollo-engine-reporting-protobuf": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.1.tgz", + "integrity": "sha512-d7vFFZ2oUrvGaN0Hpet8joe2ZG0X0lIGilN+SwgVP38dJnOuadjsaYMyrD9JudGQJg0bJA5wVQfYzcCVy0slrw==", + "dev": true, + "requires": { + "protobufjs": "^6.8.6" + } + }, + "apollo-graphql": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.3.4.tgz", + "integrity": "sha512-w+Az1qxePH4oQ8jvbhQBl5iEVvqcqynmU++x/M7MM5xqN1C7m1kyIzpN17gybXlTJXY4Oxej2WNURC2/hwpfYw==", + "dev": true, + "requires": { + "apollo-env": "^0.5.1", + "lodash.sortby": "^4.7.0" + } + }, + "apollo-server-core": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.9.6.tgz", + "integrity": "sha512-2tHAWQxP7HrETI/BZvg2fem6YlahF9HUp4Y6SSL95WP3uNMOJBlN12yM1y+O2u5K5e4jwdPNaLjoL2A/26XrLw==", + "dev": true, + "requires": { + "@apollographql/apollo-tools": "^0.4.0", + "@apollographql/graphql-playground-html": "1.6.24", + "@types/graphql-upload": "^8.0.0", + "@types/ws": "^6.0.0", + "apollo-cache-control": "^0.8.5", + "apollo-datasource": "^0.6.3", + "apollo-engine-reporting": "^1.4.7", + "apollo-server-caching": "^0.5.0", + "apollo-server-env": "^2.4.3", + "apollo-server-errors": "^2.3.3", + "apollo-server-plugin-base": "^0.6.5", + "apollo-server-types": "^0.2.5", + "apollo-tracing": "^0.8.5", + "fast-json-stable-stringify": "^2.0.0", + "graphql-extensions": "^0.10.4", + "graphql-tag": "^2.9.2", + "graphql-tools": "^4.0.0", + "graphql-upload": "^8.0.2", + "sha.js": "^2.4.11", + "subscriptions-transport-ws": "^0.9.11", + "ws": "^6.0.0" + } + }, + "apollo-server-plugin-base": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.5.tgz", + "integrity": "sha512-z2ve7HEPWmZI3EzL0iiY9qyt1i0hitT+afN5PzssCw594LB6DfUQWsI14UW+W+gcw8hvl8VQUpXByfUntAx5vw==", + "dev": true, + "requires": { + "apollo-server-types": "^0.2.5" + } + }, + "apollo-server-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.2.5.tgz", + "integrity": "sha512-6iJQsPh59FWu4K7ABrVmpnQVgeK8Ockx8BcawBh+saFYWTlVczwcLyGSZPeV1tPSKwFwKZutyEslrYSafcarXQ==", + "dev": true, + "requires": { + "apollo-engine-reporting-protobuf": "^0.4.1", + "apollo-server-caching": "^0.5.0", + "apollo-server-env": "^2.4.3" + } + }, + "apollo-tracing": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.8.5.tgz", + "integrity": "sha512-lZn10/GRBZUlMxVYLghLMFsGcLN0jTYDd98qZfBtxw+wEWUx+PKkZdljDT+XNoOm/kDvEutFGmi5tSLhArIzWQ==", + "dev": true, + "requires": { + "apollo-server-env": "^2.4.3", + "graphql-extensions": "^0.10.4" + } + }, + "graphql-extensions": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.10.4.tgz", + "integrity": "sha512-lE6MroluEYocbR/ICwccv39w+Pz4cBPadJ11z1rJkbZv5wstISEganbDOwl9qN21rcZGiWzh7QUNxUiFUXXEDw==", + "dev": true, + "requires": { + "@apollographql/apollo-tools": "^0.4.0", + "apollo-server-env": "^2.4.3", + "apollo-server-types": "^0.2.5" + } + } + } + }, "apollo-server-types": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.2.4.tgz", @@ -1191,6 +1316,15 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, + "async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "dev": true, + "requires": { + "stack-chain": "^1.3.7" + } + }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -1718,6 +1852,17 @@ "wrap-ansi": "^5.1.0" } }, + "cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "dev": true, + "requires": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + } + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2106,6 +2251,15 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "dev": true, + "requires": { + "shimmer": "^1.2.0" + } + }, "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -5890,6 +6044,12 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -6126,6 +6286,12 @@ "tweetnacl": "~0.14.0" } }, + "stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU=", + "dev": true + }, "stack-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz", @@ -6705,6 +6871,15 @@ } } }, + "typeorm-transactional-cls-hooked": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/typeorm-transactional-cls-hooked/-/typeorm-transactional-cls-hooked-0.1.8.tgz", + "integrity": "sha512-JNBuwThSNKbtT2CB6C5ZrwBgXLvzteNJQbAxqwHyJIEDTc/h4CSpRuN+8dHiJVI4Eq4wmbOrk720rvjhucIbYQ==", + "dev": true, + "requires": { + "cls-hooked": "^4.2.2" + } + }, "typescript": { "version": "3.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.3.tgz", diff --git a/package.json b/package.json index d5e82f3..ba04e2a 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,15 @@ "version": "0.0.1", "description": "", "main": "src/app.js", + "keywords": [], + "author": "", + "license": "ISC", "scripts": { "dev": "ts-node-dev --respawn src/bootstrap.ts", - "test": "jest --coverage", - "test:debug": "jest -i --verbose --detectOpenHandles", - "test:watch": "jest -i --watch", - "gen:key": "ssh-keygen -t rsa -b 2048 -f src/app/userResolver/auth/jwtRS256.key && openssl rsa -in src/app/userResolver/auth/wtRS256.key -pubout -outform PEM -out src/app/userResolver/auth/wtRS256.key.pub", + "cov": "jest --coverage", + "test": "jest -i --watch", "debug": "ts-node-dev --inspect --respawn --transpileOnly src/bootstrap.ts" }, - "keywords": [], - "author": "", - "license": "ISC", "dependencies": { "apollo-server": "^2.9.3", "apollo-server-express": "^2.9.4", @@ -33,11 +31,11 @@ "@types/jest": "^24.0.18", "@types/jsonwebtoken": "^8.3.3", "@types/node": "^12.7.5", - "class-transformer": "^0.2.3", - "isomorphic-fetch": "^2.2.1", + "apollo-server-testing": "^2.9.6", "jest": "^24.9.0", "ts-jest": "^24.1.0", "ts-node-dev": "^1.0.0-pre.42", + "typeorm-transactional-cls-hooked": "^0.1.8", "typescript": "^3.6.3" } } diff --git a/src/app.spec.ts b/src/app.spec.ts deleted file mode 100644 index 82c1d20..0000000 --- a/src/app.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -require("isomorphic-fetch") -import { ApolloServer } from "apollo-server-express" -import { getConnection } from "typeorm" -import { bootstrap } from "./app" -import { connectionOptionsforTesting } from "./app/schema" -import { signToken } from "./app/userResolver/auth" -import { User } from "./app/userResolver/User" - -let server: ApolloServer -let port: number - -beforeAll(async () => { - port = 4001 - server = await bootstrap(connectionOptionsforTesting(), port) -}) - -describe("app should", () => { - it("accept auth header correctly", async () => { - const user = await User.create({ - email: "email@email.com", - }).save() - - let token = signToken({ userId: user.id }) - - const url = `http://localhost:${port}/graphql` - - const rawResponse = await fetch(url, { - method: "post", - headers: { - "content-Type": "application/json", - authorization: "Bearer " + token, - }, - body: JSON.stringify({ - query: "{me{email}}", - }), - }) - - const response = await rawResponse.json() - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - me: { email: user.email }, - }) - - await getConnection().synchronize(true) - await getConnection().close() - await server.stop() - }) -}) diff --git a/src/app/UserResolver.spec.ts b/src/app/UserResolver.spec.ts deleted file mode 100644 index ad17c81..0000000 --- a/src/app/UserResolver.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { gql } from "apollo-server" -import { createConnection, getConnection } from "typeorm" -import { callSchema, connectionOptionsforTesting } from "./schema" -import { signToken, verifyToken } from "./userResolver/auth" -import { LoginTokens } from "./userResolver/LoginTokens" -import { User } from "./userResolver/User" - -beforeAll(async () => { - return await createConnection(connectionOptionsforTesting()) -}) - -afterAll(async () => { - return await getConnection().close() -}) - -afterEach(async () => { - return await getConnection().synchronize(true) -}) - -describe("resolver of user", () => { - describe("createUser mutation should", () => { - it("return email as it creates user with mutation", async () => { - const createUserMutation = gql` - mutation { - createUser(email: "email@email.com", password: "password") { - email - } - } - ` - - const response = await callSchema(createUserMutation) - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - createUser: { email: "email@email.com" }, - }) - }) - }) - - describe("users query should", () => { - it("return emails of registered users", async () => { - const usersQuery = gql` - query { - users { - email - } - } - ` - - const user = await User.create({ - email: "email@email.com", - }).save() - - const response = await callSchema(usersQuery) - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - users: [{ email: user.email }], - }) - }) - }) - - describe("loginTokens query should", () => { - const loginTokensQuery = gql` - query { - loginTokens(email: "email@email.com", password: "good-password") { - accessToken - } - } - ` - - it("return error for non-existent user", async () => { - const response = await callSchema(loginTokensQuery) - - expect(response.errors).not.toBeUndefined() - expect(response.data).toBeNull() - }) - - it("return error for bad password", async () => { - await User.create({ - email: "email@email.com", - password: "BAD-password", - }).save() - - const response = await callSchema(loginTokensQuery) - - expect(response.errors).not.toBeUndefined() - expect(response.data).toBeNull() - }) - - it("return a valid access token with good credentials", async () => { - await User.create({ - email: "email@email.com", - password: "good-password", - }).save() - - const response = await callSchema(loginTokensQuery) - const accessToken = response.data!.loginTokens.accessToken - const loginTokens = new LoginTokens() - loginTokens.accessToken = accessToken - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ loginTokens }) - expect(verifyToken(accessToken)).toBeTruthy() - }) - }) - - describe("me query should", () => { - const meQuery = gql` - query { - me { - email - } - } - ` - - it("return an error without a valid jwt token", async () => { - const context = { - req: { - headers: { - authorization: "Bearer INVALID-TOKEN", - }, - }, - } - const response = await callSchema(meQuery, context) - - expect(response.errors).not.toBeUndefined() - expect(response.data).toBeNull() - }) - - it("return an user with a valid jwt token", async () => { - const user = await User.create({ - email: "email@email.com", - }).save() - - const context = { - req: { - headers: { - authorization: "Bearer " + signToken({ userId: user.id }), - }, - }, - } - - const response = await callSchema(meQuery, context) - - expect(response.errors).toBeUndefined() - expect(response.data).toMatchObject({ - me: { email: user.email }, - }) - }) - }) -}) diff --git a/src/app/userResolver/auth.ts b/src/app/userResolver/auth.ts deleted file mode 100644 index 4be1e50..0000000 --- a/src/app/userResolver/auth.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { argon2id, hash, verify as argonVerify } from "argon2" -import { Request, Response } from "express" -import { readFileSync } from "fs" -import { sign, verify as jwtVerify } from "jsonwebtoken" -import { join } from "path" -import { AuthChecker } from "type-graphql" - -let PRIVATE_KEY: Buffer -let PUBLIC_KEY: Buffer - -export type Payload = { - userId: number -} - -export interface MyContext { - req: Request - res: Response - payload?: Payload -} - -export const hashPassword = async (password: string) => - await hash(password, { type: argon2id }) - -export const comparePassword = async (hash: string, plain: string) => - await argonVerify(hash, plain, { type: argon2id }) - -export const signToken = (payload: Payload) => { - if (!PRIVATE_KEY) { - PRIVATE_KEY = readKeyFile("jwtRS256.key") - } - - return sign(payload, PRIVATE_KEY, { algorithm: "RS256" }) -} - -export const verifyToken = (token: string) => { - if (!PUBLIC_KEY) { - PUBLIC_KEY = readKeyFile("jwtRS256.key.pub") - } - - return jwtVerify(token, PUBLIC_KEY) -} - -export const customAuthChecker: AuthChecker = ({ context }) => { - try { - const authHeader = context.req.headers["authorization"] - const accessToken = authHeader!.split(" ")[1] - const payload = verifyToken(accessToken) - context.payload = payload as any - - return true - } catch (error) { - throw new Error("the valid authorization header is required: " + error) - } -} - -export const contextFunction = ({ req, res }: MyContext) => ({ req, res }) - -const readKeyFile = (fileName: string) => readFileSync(join(__dirname, "auth", fileName)) diff --git a/src/bootstrap.ts b/src/bootstrap.ts index a7b159a..0534fd3 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,18 +1,10 @@ require("dotenv").config() -import { ConnectionOptions } from "typeorm" -import { bootstrap } from "./app" -import { User } from "./app/userResolver/User" +import { createConnection } from "typeorm" +import { createServer } from "./server" +import { connectionOptions } from "./server/connection" +;(async () => { + await createConnection(connectionOptions()) -let connectionOptions: ConnectionOptions = { - type: "postgres", - host: process.env.DB_HOST, - port: 5432, - database: process.env.DB_NAME, - username: process.env.DB_USER, - password: process.env.DB_PASS, - entities: [User], - synchronize: true, - logging: false, -} - -bootstrap(connectionOptions, 4000) + const port = 4000 + await createServer(port) +})() diff --git a/src/jestGlobalSetup.ts b/src/jestGlobalSetup.ts new file mode 100644 index 0000000..c6f727b --- /dev/null +++ b/src/jestGlobalSetup.ts @@ -0,0 +1,13 @@ +require("dotenv").config() +import { createConnection } from "typeorm" +import { testingConnectionOptions } from "./server/testing" + +module.exports = async function() { + const connection = await createConnection({ + ...testingConnectionOptions(), + dropSchema: true, + synchronize: true, + }) + + await connection.close() +} diff --git a/src/server.spec.ts b/src/server.spec.ts new file mode 100644 index 0000000..e4bcc15 --- /dev/null +++ b/src/server.spec.ts @@ -0,0 +1,26 @@ +import { ApolloServer } from "apollo-server-express" +import { createTestClient } from "apollo-server-testing" +import { createConnection, getConnection } from "typeorm" +import { createServer } from "./server" +import { testingConnectionOptions } from "./server/testing" +import auth = require("./server/userResolver/auth") + +describe("app should", () => { + it("call the context function on apollo server", async () => { + const spy = jest.spyOn(auth, "contextFunction") + await createConnection(testingConnectionOptions()) + + const port = 4001 + const server = (await createServer(port)) as any + const { query } = createTestClient(server) + await query({ query: "{me{email}}" }) + + expect(server).toBeInstanceOf(ApolloServer) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockRestore() + + await server.stop() + await getConnection().close() + }) +}) diff --git a/src/app.ts b/src/server.ts similarity index 51% rename from src/app.ts rename to src/server.ts index fcf6456..a01c4e2 100644 --- a/src/app.ts +++ b/src/server.ts @@ -1,12 +1,9 @@ import express = require("express") import { ApolloServer } from "apollo-server-express" -import { ConnectionOptions, createConnection } from "typeorm" -import { createSchema } from "./app/schema" -import { contextFunction } from "./app/userResolver/auth" - -export const bootstrap = async (connectionOptions: ConnectionOptions, port: number) => { - await createConnection(connectionOptions) +import { createSchema } from "./server/schema" +import { contextFunction } from "./server/userResolver/auth" +export const createServer = async (port: number) => { const server = new ApolloServer({ schema: await createSchema(), playground: true, diff --git a/src/server/UserResolver.spec.ts b/src/server/UserResolver.spec.ts new file mode 100644 index 0000000..e551c5f --- /dev/null +++ b/src/server/UserResolver.spec.ts @@ -0,0 +1,178 @@ +import { gql } from "apollo-server" +import { Request, Response } from "express" +import { createConnection, getConnection } from "typeorm" +import { + initializeTransactionalContext, + patchTypeORMRepositoryWithBaseRepository, +} from "typeorm-transactional-cls-hooked" +import { callSchema } from "./schema" +import { runInTransaction, testingConnectionOptions } from "./testing" +import { ContextInterface, signAccessToken, verifyAccessToken } from "./userResolver/auth" +import { LoginTokens } from "./userResolver/LoginTokens" +import { User } from "./userResolver/User" + +beforeAll(async () => { + initializeTransactionalContext() + patchTypeORMRepositoryWithBaseRepository() + + await createConnection(testingConnectionOptions()) +}) + +afterAll(async () => { + await getConnection().close() +}) + +describe("resolver of user", () => { + describe("createUser mutation should", () => { + it( + "return email as it creates user with mutation", + runInTransaction(async () => { + const createUserMutation = gql` + mutation { + createUser(email: "email@email.com", password: "password") { + email + } + } + ` + + const response = await callSchema(createUserMutation) + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ + createUser: { email: "email@email.com" }, + }) + }) + ) + }) + + describe("users query should", () => { + it( + "return emails of registered users", + runInTransaction(async () => { + const usersQuery = gql` + query { + users { + email + } + } + ` + + const user = await User.create({ + email: "email@email.com", + }).save() + + const response = await callSchema(usersQuery) + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ + users: [{ email: user.email }], + }) + }) + ) + }) + + describe("loginTokens query should", () => { + const loginTokensQuery = gql` + query { + loginTokens(email: "email@email.com", password: "good-password") { + accessToken + } + } + ` + + it( + "return error for non-existent user", + runInTransaction(async () => { + const response = await callSchema(loginTokensQuery) + + expect(response.errors).not.toBeUndefined() + expect(response.data).toBeNull() + }) + ) + + it( + "return error for bad password", + runInTransaction(async () => { + await User.create({ + email: "email@email.com", + password: "BAD-password", + }).save() + + const response = await callSchema(loginTokensQuery) + + expect(response.errors).not.toBeUndefined() + expect(response.data).toBeNull() + }) + ) + + it( + "return a valid access token with good credentials", + runInTransaction(async () => { + await User.create({ + email: "email@email.com", + password: "good-password", + }).save() + + const response = await callSchema(loginTokensQuery) + const accessToken = response.data!.loginTokens.accessToken + const loginTokens = new LoginTokens() + loginTokens.accessToken = accessToken + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ loginTokens }) + expect(verifyAccessToken(accessToken)).toBeTruthy() + }) + ) + }) + + describe("me query should", () => { + const meQuery = gql` + query { + me { + email + } + } + ` + + it( + "return an error without a valid jwt token", + runInTransaction(async () => { + const contextWithInvalidToken = contextWithAuthHeader( + "Bearer INVALID-TOKEN" + ) + const response = await callSchema(meQuery, contextWithInvalidToken) + + expect(response.errors).not.toBeUndefined() + expect(response.data).toBeNull() + }) + ) + + it( + "return an user with a valid jwt token", + runInTransaction(async () => { + const user = await User.create({ + email: "email@email.com", + }).save() + + const contextWithValidToken = contextWithAuthHeader( + "Bearer " + signAccessToken({ userId: user.id }) + ) + const response = await callSchema(meQuery, contextWithValidToken) + + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ + me: { email: user.email }, + }) + }) + ) + }) +}) + +const contextWithAuthHeader = (header: string): ContextInterface => ({ + req: { + headers: { + authorization: header, + }, + } as Request, + res: {} as Response, +}) diff --git a/src/app/UserResolver.ts b/src/server/UserResolver.ts similarity index 80% rename from src/app/UserResolver.ts rename to src/server/UserResolver.ts index 7fad970..3a4c367 100644 --- a/src/app/UserResolver.ts +++ b/src/server/UserResolver.ts @@ -1,6 +1,6 @@ import "reflect-metadata" import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql" -import { comparePassword, MyContext, signToken } from "./userResolver/auth" +import { comparePassword, ContextInterface, signAccessToken } from "./userResolver/auth" import { LoginTokens } from "./userResolver/LoginTokens" import { User } from "./userResolver/User" @@ -22,7 +22,7 @@ export class UserResolver { throw new Error() } - const accessToken = signToken({ userId: user!.id }) + const accessToken = signAccessToken({ userId: user!.id }) return { accessToken, @@ -34,7 +34,7 @@ export class UserResolver { @Query(() => User) @Authorized() - async me(@Ctx() { payload }: MyContext) { + async me(@Ctx() { payload }: ContextInterface) { const id = payload!.userId const user = await User.findOne({ where: { id } }) diff --git a/src/server/connection.ts b/src/server/connection.ts new file mode 100644 index 0000000..201931f --- /dev/null +++ b/src/server/connection.ts @@ -0,0 +1,15 @@ +import { ConnectionOptions } from "typeorm" +import { User } from "./userResolver/User" + +export const connectionOptions = (): ConnectionOptions => ({ + type: "postgres", + host: process.env.DB_HOST as string, + port: parseInt(process.env.DB_PORT as string), + database: process.env.DB_NAME as string, + username: process.env.DB_USER as string, + password: process.env.DB_PASS as string, + entities: [User], + dropSchema: false, + synchronize: false, + logging: false, +}) diff --git a/src/app/schema.ts b/src/server/schema.ts similarity index 55% rename from src/app/schema.ts rename to src/server/schema.ts index 2f28e84..74bb84e 100644 --- a/src/app/schema.ts +++ b/src/server/schema.ts @@ -1,14 +1,13 @@ require("dotenv").config() import { DocumentNode, graphql, GraphQLSchema } from "graphql" import { buildSchema } from "type-graphql" -import { ConnectionOptions } from "typeorm" import { UserResolver } from "./UserResolver" import { customAuthChecker } from "./userResolver/auth" -import { User } from "./userResolver/User" +import { ContextInterface } from "./userResolver/ContextInterface" let schema: GraphQLSchema -export const callSchema = async (document: DocumentNode, context?: any) => { +export const callSchema = async (document: DocumentNode, context?: ContextInterface) => { if (!schema) { schema = await createSchema() } @@ -25,19 +24,3 @@ export const createSchema = () => resolvers: [UserResolver], authChecker: customAuthChecker, }) - -export const connectionOptionsforTesting = () => { - let connectionOptions: ConnectionOptions = { - type: "postgres", - host: process.env.DB_HOST, - port: 5432, - database: "testing", - username: process.env.DB_USER, - password: process.env.DB_PASS, - entities: [User], - synchronize: true, - logging: false, - } - - return connectionOptions -} diff --git a/src/server/testing.ts b/src/server/testing.ts new file mode 100644 index 0000000..5a06444 --- /dev/null +++ b/src/server/testing.ts @@ -0,0 +1,40 @@ +import { ConnectionOptions } from "typeorm" +import { Propagation, Transactional } from "typeorm-transactional-cls-hooked" +import { connectionOptions } from "./connection" + +export const testingConnectionOptions = () => { + const database = process.env.DB_NAME_TESING as string + + return { ...connectionOptions(), database } as ConnectionOptions +} + +type RunFunction = () => Promise | void + +class RollbackError extends Error { + constructor(message: string) { + super(message) + + this.name = this.constructor.name + } +} + +class TransactionCreator { + @Transactional({ propagation: Propagation.REQUIRED }) + static async run(func: RunFunction) { + await func() + throw new RollbackError(`This is thrown to cause a rollback on the transaction.`) + } +} + +export function runInTransaction(func: RunFunction) { + return async () => { + try { + await TransactionCreator.run(func) + } catch (e) { + /* istanbul ignore next */ + if (!(e instanceof RollbackError)) { + throw e + } + } + } +} diff --git a/src/app/userResolver/LoginTokens.ts b/src/server/userResolver/LoginTokens.ts similarity index 100% rename from src/app/userResolver/LoginTokens.ts rename to src/server/userResolver/LoginTokens.ts diff --git a/src/app/userResolver/User.ts b/src/server/userResolver/User.ts similarity index 100% rename from src/app/userResolver/User.ts rename to src/server/userResolver/User.ts diff --git a/src/server/userResolver/auth.ts b/src/server/userResolver/auth.ts new file mode 100644 index 0000000..4a7ecaf --- /dev/null +++ b/src/server/userResolver/auth.ts @@ -0,0 +1,43 @@ +import { argon2id, hash, verify as argonVerify } from "argon2" +import { Request, Response } from "express" +import { sign, verify as jwtVerify } from "jsonwebtoken" +import { AuthChecker } from "type-graphql" + +export type Payload = { + userId: number +} + +export interface ContextInterface { + req: Request + res: Response + payload?: Payload +} + +export const hashPassword = async (password: string) => + await hash(password, { type: argon2id }) + +export const comparePassword = async (hash: string, plain: string) => + await argonVerify(hash, plain, { type: argon2id }) + +export const signAccessToken = (payload: Payload) => { + return sign(payload, process.env.ACCESS_SECRET!) +} + +export const verifyAccessToken = (token: string) => { + return jwtVerify(token, process.env.ACCESS_SECRET!) +} + +export const customAuthChecker: AuthChecker = ({ context }) => { + try { + const authHeader = context.req.headers["authorization"] + const accessToken = authHeader!.split(" ")[1] + const payload = verifyAccessToken(accessToken) + context.payload = payload as any + + return true + } catch (error) { + throw new Error("the valid authorization header is required: " + error) + } +} + +export const contextFunction = ({ req, res }: ContextInterface) => ({ req, res })