diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 00000000..715a9a2e --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,9 @@ +### Testing structure + +The test suite is designed to ensure that some of the modules are fully covered by tests. +Therefore, there're 2 runs of tests: one validates that overall code coverage is on appropriate level, another validates that the code coverage for +a subset of modules is maximal. + +The coverage report will therefore contain two tables. + +See corresponding configs in `./vitest.config.ts` and `./vitest.full-coverage.config.ts` diff --git a/package.json b/package.json index 7b05f348..fd092bc4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "postinstall": "prisma generate", "lint": "eslint \"./**/*.{ts,tsx}\" --max-warnings=0", "typecheck": "tsc --noEmit", - "test": "vitest run --coverage" + "test": "vitest run && vitest run --config ./vitest.full-coverage.config.ts", + "watch": "vitest watch" }, "dependencies": { "@prisma/client": "^4.11.0", diff --git a/src/context.ts b/src/context.ts index bc4154fd..593764ae 100644 --- a/src/context.ts +++ b/src/context.ts @@ -36,9 +36,6 @@ export function createContext(params: CreateContextParams): Context { const authorizationHeader = req.get('Authorization'); const token = authorizationHeader?.replace('Bearer ', ''); - if (!JWT_SECRET) { - throw new Error('Missing JWT_SECRET environment variable'); - } return { request: params, prisma, diff --git a/src/env.ts b/src/env.ts deleted file mode 100644 index 6f9c62ef..00000000 --- a/src/env.ts +++ /dev/null @@ -1,14 +0,0 @@ -import dotenv from 'dotenv'; - -dotenv.config(); - -if (!process.env.JWT_SECRET) { - if (process.env.NODE_ENV === 'production') { - throw new Error('JWT_SECRET is not defined'); - } -} -export const JWT_SECRET = process.env.JWT_SECRET || 'dev'; -export const PORT = Number(process.env.PORT ?? '3000'); -export const isDevelopment = process.env.NODE_ENV === 'development'; -export const AUTH_SIGNUP_ENABLED = Boolean(process.env.AUTH_SIGNUP_ENABLED); -export const JWT_EXPIRATION_PERIOD = process.env.JWT_EXPIRATION_PERIOD_SECONDS ? Number(process.env.JWT_EXPIRATION_PERIOD_SECONDS) : '7d'; diff --git a/src/env/getters.ts b/src/env/getters.ts new file mode 100644 index 00000000..15a5cee8 --- /dev/null +++ b/src/env/getters.ts @@ -0,0 +1,8 @@ +export const getJwtSecret = (): string => { + if (!process.env.JWT_SECRET) { + if (process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET is not defined'); + } + } + return process.env.JWT_SECRET || 'dev'; +}; diff --git a/src/env/index.ts b/src/env/index.ts new file mode 100644 index 00000000..515a1bdb --- /dev/null +++ b/src/env/index.ts @@ -0,0 +1,11 @@ +import dotenv from 'dotenv'; +import { getJwtSecret } from './getters'; + +dotenv.config(); + +export const JWT_SECRET = getJwtSecret(); +export const PORT = Number(process.env.PORT ?? '3000'); +export const isDevelopment = process.env.NODE_ENV === 'development'; +export const AUTH_SIGNUP_ENABLED = Boolean(process.env.AUTH_SIGNUP_ENABLED); +// https://www.npmjs.com/package/jsonwebtoken for `expiresIn` format +export const JWT_EXPIRATION_PERIOD: number | string = process.env.JWT_EXPIRATION_PERIOD_SECONDS ? Number(process.env.JWT_EXPIRATION_PERIOD_SECONDS) : '7d'; diff --git a/src/index.ts b/src/index.ts index 3fd03f87..a339329c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { createApp } from './app'; import { startServer } from './server'; const application = createApp(); +/* istanbul ignore next @preserve */ startServer(application) .then(() => { // This should never happen, is only here until we add the real API which of course runs forever diff --git a/src/modules/User/model.ts b/src/modules/User/model.ts index 91cce5a3..d9a1113a 100644 --- a/src/modules/User/model.ts +++ b/src/modules/User/model.ts @@ -46,7 +46,7 @@ export function getUserCrud(prisma: PrismaClient) { if (!user) { throw new ApolloError('User not found', 'USER_NOT_FOUND'); } - const passwordValid = (await compare(password, user.password || '')) || false; + const passwordValid = await compare(password, user.password); if (!passwordValid) { throw new ApolloError('Invalid password', 'INVALID_PASSWORD'); } @@ -75,7 +75,8 @@ export function getUserCrud(prisma: PrismaClient) { if ('code' in e && e.code === 'P2002') { throw new ApolloError('Username already taken', 'USERNAME_TAKEN'); } - throw new ApolloError('Failed to create user', 'USER_CREATE_FAILED'); + /* istanbul ignore next @preserve */ + throw e; } return { token: sign({ userId: created.id }, JWT_SECRET), diff --git a/src/schema.ts b/src/schema.ts index e13aa0f2..56dcc3c4 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -4,6 +4,7 @@ import { validationPlugin } from 'nexus-validation-plugin'; import { applyMiddleware } from 'graphql-middleware'; import * as types from './modules'; +/* istanbul ignore next @preserve */ export const schema = makeSchema({ types, plugins: [ diff --git a/src/server.ts b/src/server.ts index 03dcc3cc..133496ec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,9 +21,5 @@ export const startServer = async ( await apollo.start(); apollo.applyMiddleware({ app }); const usedPort = port || PORT; - return httpServer.listen({ port: usedPort }, () => { - process.stdout.write( - `🚀 Server ready at port ${usedPort}`, - ); - }); + return httpServer.listen({ port: usedPort }, () => {}); }; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 7affa591..26793bbc 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,7 +1,9 @@ -import { test, expect } from 'vitest'; +import { test, expect, vi } from 'vitest'; import builder from 'gql-query-builder'; import { cleanDatabase as cleanDatabaseBeforeAfterEachTest } from './helpers/database'; import { ctx, executeGraphQlQuery } from './helpers/server'; +import { restoreEnvAfterEach } from './helpers/env'; +import * as env from '../src/env'; const signUpMutation = builder.mutation({ operation: 'signUp', @@ -39,6 +41,7 @@ const meQuery = builder.query({ }); cleanDatabaseBeforeAfterEachTest(); +restoreEnvAfterEach(); test('Authentication: sign up, sign in, request protected enpoint', async () => { const signUpResponse = (await executeGraphQlQuery(signUpMutation)) as Record< @@ -110,3 +113,36 @@ test('Authentication: access protected endpoint without valid token', async () = const response = (await executeGraphQlQuery(meQuery)) as any; expect(response.errors[0].message).toBe('Invalid authentication token'); }); + +test('Authentication: token expiration error', async () => { + vi.spyOn(env, 'JWT_EXPIRATION_PERIOD', 'get').mockReturnValue('1ms'); + const signUpResponse = (await executeGraphQlQuery(signUpMutation)) as Record< + string, + any + >; + expect(signUpResponse?.signUp?.user?.username).toBe('asdf'); + + const signInResponse = (await executeGraphQlQuery(singInMutation)) as Record< + string, + any + >; + expect(signInResponse?.signIn?.user?.username).toBe('asdf'); + expect(signInResponse?.signIn?.token).toBeTruthy(); + + const token = signInResponse?.signIn?.token; + ctx.client.setHeader('Authorization', `Bearer ${token}`); + + const meResponse = (await executeGraphQlQuery(meQuery)) as Record< + string, + any + >; + // wait until token expires + await new Promise((resolve) => { setTimeout(resolve, 20); resolve(null); }); + expect(meResponse?.errors[0].message).toBe('Token expired'); +}); + +test('Authentication: sign up disabled', async () => { + vi.spyOn(env, 'AUTH_SIGNUP_ENABLED', 'get').mockReturnValue(false); + const response = (await executeGraphQlQuery(signUpMutation)) as any; + expect(response.errors[0].message).toBe('Sign up is disabled'); +}); diff --git a/tests/auxilary.test.ts b/tests/auxilary.test.ts new file mode 100644 index 00000000..e5267df9 --- /dev/null +++ b/tests/auxilary.test.ts @@ -0,0 +1,16 @@ +import { test, expect } from 'vitest'; +import { getJwtSecret } from '../src/env/getters'; +import { restoreEnvAfterEach } from './helpers/env'; + +restoreEnvAfterEach(); + +test('Env: production has jwt secret defined', async () => { + process.env.JWT_SECRET = ''; + process.env.NODE_ENV = 'production'; + expect(getJwtSecret).toThrowError('JWT_SECRET is not defined'); +}); + +test('Env: dev environment has jwt secret automatically set', async () => { + process.env.JWT_SECRET = ''; + expect(getJwtSecret()).toBe('dev'); +}); diff --git a/tests/coreunit.test.ts b/tests/coreunit.test.ts new file mode 100644 index 00000000..c009886e --- /dev/null +++ b/tests/coreunit.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from 'vitest'; +import builder from 'gql-query-builder'; +import { CoreUnit } from '@prisma/client'; +import { cleanDatabase as cleanDatabaseBeforeAfterEachTest } from './helpers/database'; +import { executeGraphQlQuery } from './helpers/server'; +import { getPrisma } from '../src/database'; + +cleanDatabaseBeforeAfterEachTest(); + +test('Core Unit: get all', async () => { + const prisma = getPrisma(); + await prisma.coreUnit.create({ + data: { + code: 'asdf', + shortCode: 'a', + name: 'name', + imageSource: '', + descriptionSentence: '', + descriptionParagraph: '', + descriptionParagraphImageSource: '', + }, + }); + const query = builder.query({ + operation: 'coreUnits', + fields: ['code', 'shortCode', 'name'], + }); + const response = await executeGraphQlQuery(query) as { coreUnits: CoreUnit[] }; + expect(response.coreUnits).toHaveLength(1); + expect(response.coreUnits[0].code).toBe('asdf'); + expect(response.coreUnits[0].shortCode).toBe('a'); + expect(response.coreUnits[0].name).toBe('name'); +}); + +test('Core Unit: get by id', async () => { + const prisma = getPrisma(); + const created = await prisma.coreUnit.create({ + data: { + code: 'asdf', + shortCode: 'a', + name: 'name', + imageSource: '', + descriptionSentence: '', + descriptionParagraph: '', + descriptionParagraphImageSource: '', + }, + }); + const query = builder.query({ + operation: 'coreUnit', + variables: { + id: created.id, + }, + fields: ['id'], + }); + const response = await executeGraphQlQuery(query) as { coreUnit: CoreUnit }; + expect(response.coreUnit.id).toBe(created.id); +}); + +test('Core Unit: get by id without id field throws', async () => { + const query = builder.query({ + operation: 'coreUnit', + fields: ['id'], + }); + const response = await executeGraphQlQuery(query) as { errors: Record[] }; + expect(response.errors[0].message).toBe('please provide id'); +}); diff --git a/tests/helpers/env.ts b/tests/helpers/env.ts new file mode 100644 index 00000000..4969828e --- /dev/null +++ b/tests/helpers/env.ts @@ -0,0 +1,8 @@ +import { afterEach } from 'vitest'; + +const originalEnv = { ...process.env }; +export function restoreEnvAfterEach() { + afterEach(() => { + process.env = { ...originalEnv }; + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 8c6742b6..0ff078db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,21 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, UserConfigExport } from 'vitest/config'; -export default defineConfig({ - test: { - coverage: { - provider: 'istanbul', - lines: 0, - functions: 0, - statements: 0, - all: true, +export const getVitestConfig = (fullyCoveredModulePaths?: string[]): UserConfigExport => { + const enableFullCoverage = !!fullyCoveredModulePaths && fullyCoveredModulePaths.length !== 0; + const coverage = enableFullCoverage ? 100 : 90; + return { + test: { + coverage: { + enabled: true, + provider: 'istanbul', + lines: coverage, + functions: coverage, + statements: coverage, + include: fullyCoveredModulePaths || undefined, + }, + singleThread: true, }, - singleThread: true, - }, -}); + }; +}; + +export default defineConfig(getVitestConfig()); diff --git a/vitest.full-coverage.config.ts b/vitest.full-coverage.config.ts new file mode 100644 index 00000000..3f0e3140 --- /dev/null +++ b/vitest.full-coverage.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +import { getVitestConfig } from './vitest.config'; + +const fullyCoveredModulePaths = [ + 'src/modules/**', +]; + +export default defineConfig(getVitestConfig(fullyCoveredModulePaths));