parent
81595c8261
commit
69233bb7e1
@ -1,4 +1,5 @@ |
||||
module.exports = { |
||||
preset: 'ts-jest', |
||||
testEnvironment: 'node', |
||||
}; |
||||
preset: "ts-jest", |
||||
testEnvironment: "node", |
||||
globalSetup: "./src/jestGlobalSetup.ts", |
||||
} |
||||
|
@ -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() |
||||
}) |
||||
}) |
@ -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 }, |
||||
}) |
||||
}) |
||||
}) |
||||
}) |
@ -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<MyContext> = ({ 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)) |
@ -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) |
||||
})() |
||||
|
@ -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() |
||||
} |
@ -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() |
||||
}) |
||||
}) |
@ -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, |
@ -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, |
||||
}) |
@ -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, |
||||
}) |
@ -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> | 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 |
||||
} |
||||
} |
||||
} |
||||
} |
@ -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<ContextInterface> = ({ 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 }) |
Loading…
Reference in new issue