diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9fda7b2a..e9762b4bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -509,16 +509,13 @@ jobs: ########################################################################## # UNIT TESTS BACKEND ##################################################### ########################################################################## - - name: backend | docker-compose + - name: backend | docker-compose mariadb run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb - name: Sleep for 30 seconds run: sleep 30s shell: bash - name: backend | docker-compose database run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database - - name: Sleep for 30 seconds - run: sleep 30s - shell: bash - name: backend Unit tests | test run: cd database && yarn && yarn build && cd ../backend && yarn && yarn test # run: docker-compose -f docker-compose.yml -f docker-compose.test.yml exec -T backend yarn test @@ -531,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 48 + min_coverage: 54 token: ${{ github.token }} ########################################################################## diff --git a/backend/package.json b/backend/package.json index 710d73a8c..79e5fd130 100644 --- a/backend/package.json +++ b/backend/package.json @@ -60,12 +60,12 @@ "typescript": "^4.3.4" }, "_moduleAliases": { - "@": "./src", - "@arg": "./src/graphql/arg", + "@": "./build/src", + "@arg": "./build/src/graphql/arg", "@dbTools": "../database/build/src", "@entity": "../database/build/entity", - "@enum": "./src/graphql/enum", - "@model": "./src/graphql/model", - "@repository": "./src/typeorm/repository" + "@enum": "./build/src/graphql/enum", + "@model": "./build/src/graphql/model", + "@repository": "./build/src/typeorm/repository" } } diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index aa407c95f..159a1614c 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -13,35 +13,33 @@ import { ServerUser } from '@entity/ServerUser' const isAuthorized: AuthChecker = async ({ context }, rights) => { context.role = ROLE_UNAUTHORIZED // unauthorized user + // is rights an inalienable right? + if ((rights).reduce((acc, right) => acc && INALIENABLE_RIGHTS.includes(right), true)) + return true + // Do we have a token? - if (context.token) { - // Decode the token - const decoded = decode(context.token) - if (!decoded) { - // Are all rights requested public? - const isInalienable = (rights).reduce( - (acc, right) => acc && INALIENABLE_RIGHTS.includes(right), - true, - ) - if (isInalienable) { - // If public dont throw and permit access - return true - } else { - // Throw on a protected route - throw new Error('403.13 - Client certificate revoked') - } - } - // Set context pubKey - context.pubKey = Buffer.from(decoded.pubKey).toString('hex') - // set new header token - // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests - // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey - const userRepository = await getCustomRepository(UserRepository) + if (!context.token) { + throw new Error('401 Unauthorized') + } + + // Decode the token + const decoded = decode(context.token) + if (!decoded) { + throw new Error('403.13 - Client certificate revoked') + } + // Set context pubKey + context.pubKey = Buffer.from(decoded.pubKey).toString('hex') + + // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests + // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey + const userRepository = await getCustomRepository(UserRepository) + try { const user = await userRepository.findByPubkeyHex(context.pubKey) const countServerUsers = await ServerUser.count({ email: user.email }) context.role = countServerUsers > 0 ? ROLE_ADMIN : ROLE_USER - - context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) + } catch { + // in case the database query fails (user deleted) + throw new Error('401 Unauthorized') } // check for correct rights @@ -50,6 +48,8 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { throw new Error('401 Unauthorized') } + // set new header token + context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) return true } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 05ff2b302..947636aa4 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { testEnvironment, resetEntities, createUser } from '@test/helpers' +import { testEnvironment, createUser, headerPushMock, cleanDB, resetToken } from '@test/helpers' import { createUserMutation, setPasswordMutation } from '@test/graphql' import gql from 'graphql-tag' import { GraphQLError } from 'graphql' -import { resetDB } from '@dbTools/helpers' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import CONFIG from '@/config' @@ -30,29 +29,36 @@ jest.mock('@/apis/KlicktippController', () => { }) */ -let token: string - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const headerPushMock = jest.fn((t) => (token = t.value)) - -const context = { - setHeaders: { - push: headerPushMock, - forEach: jest.fn(), - }, -} - let mutate: any, query: any, con: any +const loginQuery = gql` + query ($email: String!, $password: String!, $publisherId: Int) { + login(email: $email, password: $password, publisherId: $publisherId) { + email + firstName + lastName + language + coinanimation + klickTipp { + newsletterState + } + hasElopage + publisherId + isAdmin + } + } +` + beforeAll(async () => { - const testEnv = await testEnvironment(context) + const testEnv = await testEnvironment() mutate = testEnv.mutate query = testEnv.query con = testEnv.con + await cleanDB() }) afterAll(async () => { - await resetDB(true) + await cleanDB() await con.close() }) @@ -75,7 +81,7 @@ describe('UserResolver', () => { }) afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) it('returns success', () => { @@ -213,7 +219,7 @@ describe('UserResolver', () => { }) afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) it('sets email checked to true', () => { @@ -256,7 +262,7 @@ describe('UserResolver', () => { }) afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) it('throws an error', () => { @@ -282,7 +288,7 @@ describe('UserResolver', () => { }) afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) it('throws an error', () => { @@ -296,24 +302,6 @@ describe('UserResolver', () => { }) describe('login', () => { - const loginQuery = gql` - query ($email: String!, $password: String!, $publisherId: Int) { - login(email: $email, password: $password, publisherId: $publisherId) { - email - firstName - lastName - language - coinanimation - klickTipp { - newsletterState - } - hasElopage - publisherId - isAdmin - } - } - ` - const variables = { email: 'peter@lustig.de', password: 'Aa12345_', @@ -323,7 +311,7 @@ describe('UserResolver', () => { let result: User afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) describe('no users in database', () => { @@ -340,7 +328,7 @@ describe('UserResolver', () => { }) }) - describe('user is in database', () => { + describe('user is in database and correct login data', () => { beforeAll(async () => { await createUser(mutate, { email: 'peter@lustig.de', @@ -353,7 +341,7 @@ describe('UserResolver', () => { }) afterAll(async () => { - await resetEntities([User, LoginEmailOptIn]) + await cleanDB() }) it('returns the user object', () => { @@ -382,5 +370,81 @@ describe('UserResolver', () => { expect(headerPushMock).toBeCalledWith({ key: 'token', value: expect.any(String) }) }) }) + + describe('user is in database and wrong password', () => { + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns an error', () => { + expect( + query({ query: loginQuery, variables: { ...variables, password: 'wrong' } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No user with this credentials')], + }), + ) + }) + }) + }) + + describe('logout', () => { + const logoutQuery = gql` + query { + logout + } + ` + + describe('unauthenticated', () => { + it('throws an error', async () => { + resetToken() + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + const variables = { + email: 'peter@lustig.de', + password: 'Aa12345_', + } + + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + await query({ query: loginQuery, variables }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns true', async () => { + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + data: { logout: 'true' }, + errors: undefined, + }), + ) + }) + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 4d1454e86..9896ddc97 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -373,6 +373,8 @@ export class UserResolver { /{code}/g, emailOptIn.verificationCode.toString(), ) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName, @@ -380,11 +382,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Account confirmation link: ${activationLink}`) } + */ await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() @@ -414,6 +418,7 @@ export class UserResolver { emailOptIn.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName: user.firstName, @@ -421,11 +426,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Account confirmation link: ${activationLink}`) } + */ await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() @@ -450,6 +457,7 @@ export class UserResolver { optInCode.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmail({ link, firstName: user.firstName, @@ -457,11 +465,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Reset password link: ${link}`) } + */ return true } @@ -551,7 +561,9 @@ export class UserResolver { } catch { // TODO is this a problem? // eslint-disable-next-line no-console + /* uncomment this, when you need the activation link on the console console.log('Could not subscribe to klicktipp') + */ } } diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index f3588cd43..edb4eb3e4 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -3,44 +3,62 @@ import { createTestClient } from 'apollo-server-testing' import createServer from '../src/server/createServer' -import { resetDB, initialize } from '@dbTools/helpers' +import { initialize } from '@dbTools/helpers' import { createUserMutation, setPasswordMutation } from './graphql' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' +import { entities } from '@entity/index' -export const testEnvironment = async (context: any) => { +export const headerPushMock = jest.fn((t) => { + context.token = t.value +}) + +const context = { + token: '', + setHeaders: { + push: headerPushMock, + forEach: jest.fn(), + }, +} + +export const cleanDB = async () => { + // this only works as lond we do not have foreign key constraints + for (let i = 0; i < entities.length; i++) { + await resetEntity(entities[i]) + } +} + +export const testEnvironment = async () => { const server = await createServer(context) const con = server.con const testClient = createTestClient(server.apollo) const mutate = testClient.mutate const query = testClient.query await initialize() - await resetDB() return { mutate, query, con } } export const resetEntity = async (entity: any) => { - const items = await entity.find() + const items = await entity.find({ withDeleted: true }) if (items.length > 0) { const ids = items.map((i: any) => i.id) await entity.delete(ids) } } -export const resetEntities = async (entities: any[]) => { - for (let i = 0; i < entities.length; i++) { - await resetEntity(entities[i]) - } -} - export const createUser = async (mutate: any, user: any) => { + // resetToken() await mutate({ mutation: createUserMutation, variables: user }) const dbUser = await User.findOne({ where: { email: user.email } }) if (!dbUser) throw new Error('Ups, no user found') - const optin = await LoginEmailOptIn.findOne(dbUser.id) + const optin = await LoginEmailOptIn.findOne({ where: { userId: dbUser.id } }) if (!optin) throw new Error('Ups, no optin found') await mutate({ mutation: setPasswordMutation, variables: { password: 'Aa12345_', code: optin.verificationCode }, }) } + +export const resetToken = () => { + context.token = '' +} diff --git a/deployment/bare_metal/logrotate/gradido.conf.template b/deployment/bare_metal/logrotate/gradido.conf.template index c038f8e75..c543b54c2 100644 --- a/deployment/bare_metal/logrotate/gradido.conf.template +++ b/deployment/bare_metal/logrotate/gradido.conf.template @@ -1,4 +1,4 @@ -$GRADIDO_LOG_PATH/* { +$GRADIDO_LOG_PATH/*.log { weekly rotate 26 size 10M diff --git a/deployment/bare_metal/nginx/update-page/updating.html.template b/deployment/bare_metal/nginx/update-page/updating.html.template index a88a40b0f..cc6d7debb 100644 --- a/deployment/bare_metal/nginx/update-page/updating.html.template +++ b/deployment/bare_metal/nginx/update-page/updating.html.template @@ -1,3 +1,4 @@ -Gradido is currently updating...
-please stand by and try again in some minutes
-
\ No newline at end of file +
+Gradido is currently updating...
+please stand by and try again in some minutes
+
diff --git a/deployment/bare_metal/start.sh b/deployment/bare_metal/start.sh
index 616e4b8ab..250971419 100755
--- a/deployment/bare_metal/start.sh
+++ b/deployment/bare_metal/start.sh
@@ -42,30 +42,38 @@ if [ -f $LOCK_FILE ] ; then
 fi
 touch $LOCK_FILE
 
+# find today string
+TODAY=$(date +"%Y-%m-%d")
+
 # Create a new updating.html from the template
 \cp $SCRIPT_DIR/nginx/update-page/updating.html.template $UPDATE_HTML
 
+# redirect all output of the script to the UPDATE_HTML and also have things on console
+# TODO: this might pose a security risk
+exec > >(tee -a $UPDATE_HTML) 2>&1
+
 # configure nginx for the update-page
-echo 'Configuring nginx to serve the update-page
' >> $UPDATE_HTML +echo 'Configuring nginx to serve the update-page' >> $UPDATE_HTML rm /etc/nginx/sites-enabled/gradido.conf ln -s /etc/nginx/sites-available/update-page.conf /etc/nginx/sites-enabled/ sudo /etc/init.d/nginx restart # stop all services -echo 'Stopping all Gradido services
' >> $UPDATE_HTML +echo 'Stopping all Gradido services' >> $UPDATE_HTML pm2 stop all # git BRANCH=${1:-master} -echo "Starting with git pull - branch:$BRANCH
" >> $UPDATE_HTML +echo "Starting with git pull - branch:$BRANCH" >> $UPDATE_HTML cd $PROJECT_ROOT -git fetch origin $BRANCH +# TODO: this overfetches alot, but ensures we can use start.sh with tags +git fetch origin --all git checkout $BRANCH git pull export BUILD_COMMIT="$(git rev-parse HEAD)" # Generate gradido.conf from template -echo 'Generate new gradido nginx config
' >> $UPDATE_HTML +echo 'Generate new gradido nginx config' >> $UPDATE_HTML case "$NGINX_SSL" in true) TEMPLATE_FILE="gradido.conf.ssl.template" ;; *) TEMPLATE_FILE="gradido.conf.template" ;; @@ -73,7 +81,7 @@ esac envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $NGINX_CONFIG_DIR/$TEMPLATE_FILE > $NGINX_CONFIG_DIR/gradido.conf # Generate update-page.conf from template -echo 'Generate new update-page nginx config
' >> $UPDATE_HTML +echo 'Generate new update-page nginx config' >> $UPDATE_HTML case "$NGINX_SSL" in true) TEMPLATE_FILE="update-page.conf.ssl.template" ;; *) TEMPLATE_FILE="update-page.conf.template" ;; @@ -91,7 +99,7 @@ envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/frontend/.env envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/admin/.env.template > $PROJECT_ROOT/admin/.env # Install & build database -echo 'Updating database
' >> $UPDATE_HTML +echo 'Updating database' >> $UPDATE_HTML cd $PROJECT_ROOT/database yarn install yarn build @@ -104,7 +112,7 @@ else fi # Install & build backend -echo 'Updating backend
' >> $UPDATE_HTML +echo 'Updating backend' >> $UPDATE_HTML cd $PROJECT_ROOT/backend # TODO maybe handle this differently? unset NODE_ENV @@ -113,11 +121,11 @@ yarn build # TODO maybe handle this differently? export NODE_ENV=production pm2 delete gradido-backend -pm2 start --name gradido-backend "yarn --cwd $PROJECT_ROOT/backend start" -l $GRADIDO_LOG_PATH/pm2.backend.log --log-date-format 'DD-MM HH:mm:ss.SSS' +pm2 start --name gradido-backend "yarn --cwd $PROJECT_ROOT/backend start" -l $GRADIDO_LOG_PATH/pm2.backend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' pm2 save # Install & build frontend -echo 'Updating frontend
' >> $UPDATE_HTML +echo 'Updating frontend' >> $UPDATE_HTML cd $PROJECT_ROOT/frontend # TODO maybe handle this differently? unset NODE_ENV @@ -126,11 +134,11 @@ yarn build # TODO maybe handle this differently? export NODE_ENV=production pm2 delete gradido-frontend -pm2 start --name gradido-frontend "yarn --cwd $PROJECT_ROOT/frontend start" -l $GRADIDO_LOG_PATH/pm2.frontend.log --log-date-format 'DD-MM HH:mm:ss.SSS' +pm2 start --name gradido-frontend "yarn --cwd $PROJECT_ROOT/frontend start" -l $GRADIDO_LOG_PATH/pm2.frontend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' pm2 save # Install & build admin -echo 'Updating admin
' >> $UPDATE_HTML +echo 'Updating admin' >> $UPDATE_HTML cd $PROJECT_ROOT/admin # TODO maybe handle this differently? unset NODE_ENV @@ -139,14 +147,17 @@ yarn build # TODO maybe handle this differently? export NODE_ENV=production pm2 delete gradido-admin -pm2 start --name gradido-admin "yarn --cwd $PROJECT_ROOT/admin start" -l $GRADIDO_LOG_PATH/pm2.admin.log --log-date-format 'DD-MM HH:mm:ss.SSS' +pm2 start --name gradido-admin "yarn --cwd $PROJECT_ROOT/admin start" -l $GRADIDO_LOG_PATH/pm2.admin.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' pm2 save # let nginx showing gradido -echo 'Configuring nginx to serve gradido again
' >> $UPDATE_HTML +echo 'Configuring nginx to serve gradido again' >> $UPDATE_HTML ln -s /etc/nginx/sites-available/gradido.conf /etc/nginx/sites-enabled/ rm /etc/nginx/sites-enabled/update-page.conf sudo /etc/init.d/nginx restart +# keep the update log +cat $UPDATE_HTML >> $GRADIDO_LOG_PATH/update.$TODAY.log + # release lock rm $LOCK_FILE \ No newline at end of file