diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae4570622..15a736630 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -509,7 +509,7 @@ jobs: - name: backend | docker-compose run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb database - name: backend Unit tests | test - run: cd database && yarn && cd ../backend && yarn && yarn test + run: cd database && yarn && yarn build && cd ../backend && yarn && yarn CI_worklfow_test # run: docker-compose -f docker-compose.yml -f docker-compose.test.yml exec -T backend yarn test ########################################################################## # COVERAGE CHECK BACKEND ################################################# @@ -520,7 +520,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 37 + min_coverage: 40 token: ${{ github.token }} ############################################################################## diff --git a/backend/jest.config.js b/backend/jest.config.js index 3cee980c5..9d99c68f6 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -5,6 +5,11 @@ module.exports = { collectCoverage: true, collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], moduleNameMapper: { - '@entity/(.*)': '/../database/entity/$1', + '@entity/(.*)': '/../database/build/entity/$1', + // This is hack to fix a problem with the library `ts-mysql-migrate` which does differentiate between its ts/js state + '@dbTools/(.*)': + process.env.NODE_ENV === 'development' + ? '/../database/src/$1' + : '/../database/build/src/$1', }, } diff --git a/backend/package.json b/backend/package.json index e573a2704..c9314f0fd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,8 @@ "start": "node build/index.js", "dev": "nodemon -w src --ext ts --exec ts-node src/index.ts", "lint": "eslint . --ext .js,.ts", - "test": "jest --runInBand --coverage " + "CI_worklfow_test": "jest --runInBand --coverage ", + "test": "NODE_ENV=development jest --runInBand --coverage " }, "dependencies": { "@types/jest": "^27.0.2", @@ -59,6 +60,7 @@ "typescript": "^4.3.4" }, "_moduleAliases": { - "@entity": "../database/build/entity" + "@entity": "../database/build/entity", + "@dbTools": "../database/build/src" } } diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 3ccb0fa63..85cb3a0fe 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -321,7 +321,5 @@ function isCreationValid(creations: number[], amount: number, creationDate: Date async function hasActivatedEmail(email: string): Promise { const repository = getCustomRepository(LoginUserRepository) const user = await repository.findByEmail(email) - let emailActivate = false - if (user) emailActivate = user.emailChecked return user ? user.emailChecked : false } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts new file mode 100644 index 000000000..d0c144e22 --- /dev/null +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -0,0 +1,232 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { createTestClient } from 'apollo-server-testing' +import gql from 'graphql-tag' +import { GraphQLError } from 'graphql' +import createServer from '../../server/createServer' +import { resetDB, initialize } from '@dbTools/helpers' +import { getRepository } from 'typeorm' +import { LoginUser } from '@entity/LoginUser' +import { LoginUserBackup } from '@entity/LoginUserBackup' +import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' +import { User } from '@entity/User' +import CONFIG from '../../config' +import { sendEMail } from '../../util/sendEMail' + +jest.mock('../../util/sendEMail', () => { + return { + __esModule: true, + sendEMail: jest.fn(), + } +}) + +let mutate: any +let con: any + +beforeAll(async () => { + const server = await createServer({}) + con = server.con + mutate = createTestClient(server.apollo).mutate + await initialize() + await resetDB() +}) + +describe('UserResolver', () => { + describe('createUser', () => { + const variables = { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + } + + const mutation = gql` + mutation ( + $email: String! + $firstName: String! + $lastName: String! + $language: String! + $publisherId: Int + ) { + createUser( + email: $email + firstName: $firstName + lastName: $lastName + language: $language + publisherId: $publisherId + ) + } + ` + + let result: any + let emailOptIn: string + let newUser: User + + beforeAll(async () => { + result = await mutate({ mutation, variables }) + }) + + afterAll(async () => { + await resetDB() + }) + + it('returns success', () => { + expect(result).toEqual(expect.objectContaining({ data: { createUser: 'success' } })) + }) + + describe('valid input data', () => { + let loginUser: LoginUser[] + let user: User[] + let loginUserBackup: LoginUserBackup[] + let loginEmailOptIn: LoginEmailOptIn[] + beforeAll(async () => { + loginUser = await getRepository(LoginUser).createQueryBuilder('login_user').getMany() + user = await getRepository(User).createQueryBuilder('state_user').getMany() + loginUserBackup = await getRepository(LoginUserBackup) + .createQueryBuilder('login_user_backup') + .getMany() + loginEmailOptIn = await getRepository(LoginEmailOptIn) + .createQueryBuilder('login_email_optin') + .getMany() + newUser = user[0] + emailOptIn = loginEmailOptIn[0].verificationCode.toString() + }) + + describe('filling all tables', () => { + it('saves the user in login_user table', () => { + expect(loginUser).toEqual([ + { + id: expect.any(Number), + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + username: '', + description: '', + password: '0', + pubKey: null, + privKey: null, + emailHash: expect.any(Buffer), + createdAt: expect.any(Date), + emailChecked: false, + passphraseShown: false, + language: 'de', + disabled: false, + groupId: 1, + publisherId: 1234, + }, + ]) + }) + + it('saves the user in state_user table', () => { + expect(user).toEqual([ + { + id: expect.any(Number), + indexId: 0, + groupId: 0, + pubkey: expect.any(Buffer), + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + username: '', + disabled: false, + }, + ]) + }) + + it('saves the user in login_user_backup table', () => { + expect(loginUserBackup).toEqual([ + { + id: expect.any(Number), + passphrase: expect.any(String), + userId: loginUser[0].id, + mnemonicType: 2, + }, + ]) + }) + + it('creates an email optin', () => { + expect(loginEmailOptIn).toEqual([ + { + id: expect.any(Number), + userId: loginUser[0].id, + verificationCode: expect.any(String), + emailOptInTypeId: 1, + createdAt: expect.any(Date), + resendCount: 0, + updatedAt: expect.any(Date), + }, + ]) + }) + }) + }) + + describe('account activation email', () => { + it('sends an account activation email', () => { + const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(/\$1/g, emailOptIn) + expect(sendEMail).toBeCalledWith({ + from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, + to: `${newUser.firstName} ${newUser.lastName} <${newUser.email}>`, + subject: 'Gradido: E-Mail Überprüfung', + text: + expect.stringContaining(`Hallo ${newUser.firstName} ${newUser.lastName},`) && + expect.stringContaining(activationLink), + }) + }) + }) + + describe('email already exists', () => { + it('throws an error', async () => { + await expect(mutate({ mutation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User already exists.')], + }), + ) + }) + }) + + describe('unknown language', () => { + it('sets "de" as default language', async () => { + await mutate({ + mutation, + variables: { ...variables, email: 'bibi@bloxberg.de', language: 'es' }, + }) + await expect( + getRepository(LoginUser).createQueryBuilder('login_user').getMany(), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'bibi@bloxberg.de', + language: 'de', + }), + ]), + ) + }) + }) + + describe('no publisher id', () => { + it('sets publisher id to null', async () => { + await mutate({ + mutation, + variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined }, + }) + await expect( + getRepository(LoginUser).createQueryBuilder('login_user').getMany(), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + publisherId: null, + }), + ]), + ) + }) + }) + }) +}) + +afterAll(async () => { + await resetDB(true) + await con.close() +}) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 20bd01cec..2f98a2f6f 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -26,6 +26,7 @@ import { signIn } from '../../apis/KlicktippController' import { RIGHTS } from '../../auth/RIGHTS' import { ServerUserRepository } from '../../typeorm/repository/ServerUser' import { ROLE_ADMIN } from '../../auth/ROLES' +import { randomBytes } from 'crypto' const EMAIL_OPT_IN_RESET_PASSWORD = 2 const EMAIL_OPT_IN_REGISTER = 1 @@ -432,7 +433,7 @@ export class UserResolver { dbUser.lastName = lastName dbUser.username = username // TODO this field has no null allowed unlike the loginServer table - dbUser.pubkey = Buffer.alloc(32, 0) // default to 0000... + dbUser.pubkey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000... // dbUser.pubkey = keyPair[0] await queryRunner.manager.save(dbUser).catch((er) => { @@ -471,13 +472,13 @@ export class UserResolver { return 'success' } - private async sendAccountActivationEmail( + private sendAccountActivationEmail( activationLink: string, firstName: string, lastName: string, email: string, - ) { - const emailSent = await sendEMail({ + ): Promise { + return sendEMail({ from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, to: `${firstName} ${lastName} <${email}>`, subject: 'Gradido: E-Mail Überprüfung', @@ -492,7 +493,6 @@ export class UserResolver { Mit freundlichen Grüßen, dein Gradido-Team`, }) - return emailSent } @Mutation(() => Boolean) diff --git a/backend/src/middleware/userResolverMiddleware.ts b/backend/src/middleware/userResolverMiddleware.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 73e00a1a0..eabdc75b0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -47,7 +47,8 @@ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - "@entity/*": ["../database/entity/*"] + "@entity/*": ["../database/entity/*"], + "@dbTools/*": ["../database/src/*"] }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ diff --git a/database/.env.dist b/database/.env.dist index 644dcaaf4..689e4f509 100644 --- a/database/.env.dist +++ b/database/.env.dist @@ -4,6 +4,5 @@ DB_USER=root DB_PASSWORD= DB_DATABASE=gradido_community MIGRATIONS_TABLE=migrations -MIGRATIONS_DIRECTORY=./migrations/ TYPEORM_SEEDING_FACTORIES=src/factories/**/*{.ts,.js} diff --git a/database/build/.env.dist b/database/build/.env.dist deleted file mode 100644 index 505546e1b..000000000 --- a/database/build/.env.dist +++ /dev/null @@ -1,2 +0,0 @@ -// For production you need to put your env file in here. -// Please copy the dist file from the root folder in here and rename it to .env \ No newline at end of file diff --git a/database/package.json b/database/package.json index 7ff5c60d3..515fbcd74 100644 --- a/database/package.json +++ b/database/package.json @@ -10,9 +10,9 @@ "scripts": { "build": "tsc --build", "clean": "tsc --build --clean", - "up": "cd build && node src/index.js up", - "down": "cd build && node src/index.js down", - "reset": "cd build && node src/index.js reset", + "up": "node build/src/index.js up", + "down": "node build/src/index.js down", + "reset": "node build/src/index.js reset", "dev_up": "ts-node src/index.ts up", "dev_down": "ts-node src/index.ts down", "dev_reset": "ts-node src/index.ts reset", diff --git a/database/src/config/index.ts b/database/src/config/index.ts index 908d40311..2dde06c96 100644 --- a/database/src/config/index.ts +++ b/database/src/config/index.ts @@ -13,7 +13,6 @@ const database = { const migrations = { MIGRATIONS_TABLE: process.env.MIGRATIONS_TABLE || 'migrations', - MIGRATIONS_DIRECTORY: process.env.MIGRATIONS_DIRECTORY || './migrations/', } const CONFIG = { ...database, ...migrations } diff --git a/database/src/helpers.ts b/database/src/helpers.ts new file mode 100644 index 000000000..710094548 --- /dev/null +++ b/database/src/helpers.ts @@ -0,0 +1,34 @@ +import CONFIG from './config' +import { createPool, PoolConfig } from 'mysql' +import { Migration } from 'ts-mysql-migrate' +import path from 'path' + +const poolConfig: PoolConfig = { + host: CONFIG.DB_HOST, + port: CONFIG.DB_PORT, + user: CONFIG.DB_USER, + password: CONFIG.DB_PASSWORD, + database: CONFIG.DB_DATABASE, +} + +// Pool? +const pool = createPool(poolConfig) + +// Create & Initialize Migrations +const migration = new Migration({ + conn: pool, + tableName: CONFIG.MIGRATIONS_TABLE, + silent: true, + dir: path.join(__dirname, '..', 'migrations'), +}) + +const initialize = async (): Promise => { + await migration.initialize() +} + +const resetDB = async (closePool = false): Promise => { + await migration.reset() // use for resetting database + if (closePool) pool.end() +} + +export { resetDB, pool, migration, initialize } diff --git a/database/src/index.ts b/database/src/index.ts index 94566c9f5..488d098d3 100644 --- a/database/src/index.ts +++ b/database/src/index.ts @@ -1,7 +1,4 @@ import 'reflect-metadata' -import { createPool, PoolConfig } from 'mysql' -import { Migration } from 'ts-mysql-migrate' -import CONFIG from './config' import prepare from './prepare' import connection from './typeorm/connection' import { useSeeding, runSeeder } from 'typeorm-seeding' @@ -10,30 +7,12 @@ import { CreateBibiBloxbergSeed } from './seeds/users/bibi-bloxberg.seed' import { CreateRaeuberHotzenplotzSeed } from './seeds/users/raeuber-hotzenplotz.seed' import { CreateBobBaumeisterSeed } from './seeds/users/bob-baumeister.seed' import { DecayStartBlockSeed } from './seeds/decay-start-block.seed' +import { resetDB, pool, migration } from './helpers' const run = async (command: string) => { // Database actions not supported by our migration library await prepare() - // Database connection for Migrations - const poolConfig: PoolConfig = { - host: CONFIG.DB_HOST, - port: CONFIG.DB_PORT, - user: CONFIG.DB_USER, - password: CONFIG.DB_PASSWORD, - database: CONFIG.DB_DATABASE, - } - - // Pool? - const pool = createPool(poolConfig) - - // Create & Initialize Migrations - const migration = new Migration({ - conn: pool, - tableName: CONFIG.MIGRATIONS_TABLE, - dir: CONFIG.MIGRATIONS_DIRECTORY, - }) - // Database connection for TypeORM const con = await connection() if (!con || !con.isConnected) { @@ -52,7 +31,7 @@ const run = async (command: string) => { break case 'reset': // TODO protect from production - await migration.reset() // use for resetting database + await resetDB() // use for resetting database break case 'seed': // TODO protect from production