diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3dbb018c..c136ca4b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -527,7 +527,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 74 + min_coverage: 76 token: ${{ github.token }} ########################################################################## diff --git a/backend/.env.dist b/backend/.env.dist index 861e5ebb3..b238388f6 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -1,4 +1,4 @@ -CONFIG_VERSION=v13.2022-12-20 +CONFIG_VERSION=v14.2022-12-22 # Server PORT=4000 @@ -67,3 +67,4 @@ EVENT_PROTOCOL_DISABLED=false # on an hash created from this topic # FEDERATION_DHT_TOPIC=GRADIDO_HUB # FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f +# FEDERATION_COMMUNITY_URL=http://localhost:4000/api diff --git a/backend/.env.template b/backend/.env.template index 9d8696c6a..f73b87353 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -60,3 +60,4 @@ EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED # Federation FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED +FEDERATION_COMMUNITY_URL=$FEDERATION_COMMUNITY_URL diff --git a/backend/package.json b/backend/package.json index 4e34ca566..69a436563 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "dependencies": { "@hyperswarm/dht": "^6.2.0", "apollo-server-express": "^2.25.2", + "await-semaphore": "^0.1.3", "axios": "^0.21.1", "class-validator": "^0.13.1", "cors": "^2.8.5", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 4d605857f..698b17e67 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,14 +10,14 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0057-clear_old_password_junk', + DB_VERSION: '0058-add_communities_table', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info LOG_LEVEL: process.env.LOG_LEVEL || 'info', CONFIG_VERSION: { DEFAULT: 'DEFAULT', - EXPECTED: 'v13.2022-12-20', + EXPECTED: 'v14.2022-11-22', CURRENT: '', }, } @@ -120,6 +120,12 @@ if ( const federation = { FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || null, FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null, + FEDERATION_COMMUNITY_URL: + process.env.FEDERATION_COMMUNITY_URL === undefined + ? null + : process.env.FEDERATION_COMMUNITY_URL.endsWith('/') + ? process.env.FEDERATION_COMMUNITY_URL + : process.env.FEDERATION_COMMUNITY_URL + '/', } const CONFIG = { diff --git a/backend/src/federation/index.test.ts b/backend/src/federation/index.test.ts new file mode 100644 index 000000000..235206cf8 --- /dev/null +++ b/backend/src/federation/index.test.ts @@ -0,0 +1,798 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { startDHT } from './index' +import DHT from '@hyperswarm/dht' +import CONFIG from '@/config' +import { logger } from '@test/testSetup' +import { Community as DbCommunity } from '@entity/Community' +import { testEnvironment, cleanDB } from '@test/helpers' + +CONFIG.FEDERATION_DHT_SEED = '64ebcb0e3ad547848fef4197c6e2332f' + +jest.mock('@hyperswarm/dht') + +const TEST_TOPIC = 'gradido_test_topic' + +const keyPairMock = { + publicKey: Buffer.from('publicKey'), + secretKey: Buffer.from('secretKey'), +} + +const serverListenSpy = jest.fn() + +const serverEventMocks: { [key: string]: any } = {} + +const serverOnMock = jest.fn().mockImplementation((key: string, callback) => { + serverEventMocks[key] = callback +}) + +const nodeCreateServerMock = jest.fn().mockImplementation(() => { + return { + on: serverOnMock, + listen: serverListenSpy, + } +}) + +const nodeAnnounceMock = jest.fn().mockImplementation(() => { + return { + finished: jest.fn(), + } +}) + +const lookupResultMock = { + token: Buffer.from(TEST_TOPIC), + from: { + id: Buffer.from('somone'), + host: '188.95.53.5', + port: 63561, + }, + to: { id: null, host: '83.53.31.27', port: 55723 }, + peers: [ + { + publicKey: Buffer.from('some-public-key'), + relayAddresses: [], + }, + ], +} + +const nodeLookupMock = jest.fn().mockResolvedValue([lookupResultMock]) + +const socketEventMocks: { [key: string]: any } = {} + +const socketOnMock = jest.fn().mockImplementation((key: string, callback) => { + socketEventMocks[key] = callback +}) + +const socketWriteMock = jest.fn() + +const nodeConnectMock = jest.fn().mockImplementation(() => { + return { + on: socketOnMock, + once: socketOnMock, + write: socketWriteMock, + } +}) + +DHT.hash.mockImplementation(() => { + return Buffer.from(TEST_TOPIC) +}) + +DHT.keyPair.mockImplementation(() => { + return keyPairMock +}) + +DHT.mockImplementation(() => { + return { + createServer: nodeCreateServerMock, + announce: nodeAnnounceMock, + lookup: nodeLookupMock, + connect: nodeConnectMock, + } +}) + +let con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment(logger) + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('federation', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + describe('call startDHT', () => { + const hashSpy = jest.spyOn(DHT, 'hash') + const keyPairSpy = jest.spyOn(DHT, 'keyPair') + beforeEach(async () => { + DHT.mockClear() + jest.clearAllMocks() + await startDHT(TEST_TOPIC) + }) + + it('calls DHT.hash', () => { + expect(hashSpy).toBeCalledWith(Buffer.from(TEST_TOPIC)) + }) + + it('creates a key pair', () => { + expect(keyPairSpy).toBeCalledWith(expect.any(Buffer)) + }) + + it('initializes a new DHT object', () => { + expect(DHT).toBeCalledWith({ keyPair: keyPairMock }) + }) + + describe('DHT node', () => { + it('creates a server', () => { + expect(nodeCreateServerMock).toBeCalled() + }) + + it('listens on the server', () => { + expect(serverListenSpy).toBeCalled() + }) + + describe('timers', () => { + beforeEach(() => { + jest.runOnlyPendingTimers() + }) + + it('announces on topic', () => { + expect(nodeAnnounceMock).toBeCalledWith(Buffer.from(TEST_TOPIC), keyPairMock) + }) + + it('looks up on topic', () => { + expect(nodeLookupMock).toBeCalledWith(Buffer.from(TEST_TOPIC)) + }) + }) + + describe('server connection event', () => { + beforeEach(() => { + serverEventMocks.connection({ + remotePublicKey: Buffer.from('another-public-key'), + on: socketOnMock, + }) + }) + + it('can be triggered', () => { + expect(socketOnMock).toBeCalled() + }) + + describe('socket events', () => { + describe('on data', () => { + describe('with receiving simply a string', () => { + beforeEach(() => { + jest.clearAllMocks() + socketEventMocks.data(Buffer.from('no-json string')) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith('data: no-json string') + }) + + it('logs an error of unexpected data format and structure', () => { + expect(logger.error).toBeCalledWith( + 'Error on receiving data from socket:', + new SyntaxError('Unexpected token o in JSON at position 1'), + ) + }) + }) + + describe('with receiving array of strings', () => { + beforeEach(() => { + jest.clearAllMocks() + const strArray: string[] = ['invalid type test', 'api', 'url'] + socketEventMocks.data(Buffer.from(strArray.toString())) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith('data: invalid type test,api,url') + }) + + it('logs an error of unexpected data format and structure', () => { + expect(logger.error).toBeCalledWith( + 'Error on receiving data from socket:', + new SyntaxError('Unexpected token i in JSON at position 0'), + ) + }) + }) + + describe('with receiving array of string-arrays', () => { + beforeEach(async () => { + jest.clearAllMocks() + const strArray: string[][] = [ + [`api`, `url`, `invalid type in array test`], + [`wrong`, `api`, `url`], + ] + await socketEventMocks.data(Buffer.from(strArray.toString())) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith( + 'data: api,url,invalid type in array test,wrong,api,url', + ) + }) + + it('logs an error of unexpected data format and structure', () => { + expect(logger.error).toBeCalledWith( + 'Error on receiving data from socket:', + new SyntaxError('Unexpected token a in JSON at position 0'), + ) + }) + }) + + describe('with receiving JSON-Array with too much entries', () => { + let jsonArray: { api: string; url: string }[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 'v1_0', url: 'too much versions at the same time test' }, + { api: 'v1_0', url: 'url2' }, + { api: 'v1_0', url: 'url3' }, + { api: 'v1_0', url: 'url4' }, + { api: 'v1_0', url: 'url5' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith( + 'data: [{"api":"v1_0","url":"too much versions at the same time test"},{"api":"v1_0","url":"url2"},{"api":"v1_0","url":"url3"},{"api":"v1_0","url":"url4"},{"api":"v1_0","url":"url5"}]', + ) + }) + + it('logs a warning of too much apiVersion-Definitions', () => { + expect(logger.warn).toBeCalledWith( + `received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify( + jsonArray, + )}`, + ) + }) + }) + + describe('with receiving wrong but tolerated property data', () => { + let jsonArray: any[] + let result: DbCommunity[] = [] + beforeAll(async () => { + jest.clearAllMocks() + jsonArray = [ + { + wrong: 'wrong but tolerated property test', + api: 'v1_0', + url: 'url1', + }, + { + api: 'v2_0', + url: 'url2', + wrong: 'wrong but tolerated property test', + }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + result = await DbCommunity.find() + }) + + afterAll(async () => { + await cleanDB() + }) + + it('has two Communty entries in database', () => { + expect(result).toHaveLength(2) + }) + + it('has an entry for api version v1_0', () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'v1_0', + endPoint: 'url1', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + + it('has an entry for api version v2_0', () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'v2_0', + endPoint: 'url2', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + }) + + describe('with receiving data but missing api property', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { test1: 'missing api proterty test', url: 'any url definition as string' }, + { api: 'some api', test2: 'missing url property test' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`, + ) + }) + }) + + describe('with receiving data but missing url property', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 'some api', test2: 'missing url property test' }, + { test1: 'missing api proterty test', url: 'any url definition as string' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`, + ) + }) + }) + + describe('with receiving data but wrong type of api property', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 1, url: 'wrong property type tests' }, + { api: 'urltyptest', url: 2 }, + { api: 1, url: 2 }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`, + ) + }) + }) + + describe('with receiving data but wrong type of url property', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 'urltyptest', url: 2 }, + { api: 1, url: 'wrong property type tests' }, + { api: 1, url: 2 }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`, + ) + }) + }) + + describe('with receiving data but wrong type of both properties', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 1, url: 2 }, + { api: 'urltyptest', url: 2 }, + { api: 1, url: 'wrong property type tests' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`, + ) + }) + }) + + describe('with receiving data but too long api string', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { api: 'toolong api', url: 'some valid url' }, + { + api: 'valid api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + { + api: 'toolong api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received apiVersion with content longer than max length: ${JSON.stringify( + jsonArray[0], + )}`, + ) + }) + }) + + describe('with receiving data but too long url string', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { + api: 'api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + { api: 'toolong api', url: 'some valid url' }, + { + api: 'toolong api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + + it('logs a warning of invalid apiVersion-Definition', () => { + expect(logger.warn).toBeCalledWith( + `received apiVersion with content longer than max length: ${JSON.stringify( + jsonArray[0], + )}`, + ) + }) + }) + + describe('with receiving data but both properties with too long strings', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { + api: 'toolong api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + { + api: 'api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + { api: 'toolong api', url: 'some valid url' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`) + }) + }) + + describe('with receiving data of exact max allowed properties length', () => { + let jsonArray: any[] + let result: DbCommunity[] = [] + beforeAll(async () => { + jest.clearAllMocks() + jsonArray = [ + { + api: 'valid api', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'api', + url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic', + }, + { api: 'toolong api', url: 'some valid url' }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + result = await DbCommunity.find() + }) + + afterAll(async () => { + await cleanDB() + }) + + it('has one Communty entry in database', () => { + expect(result).toHaveLength(1) + }) + + it(`has an entry with max content length for api and url`, () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'valid api', + endPoint: + 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + }) + + describe('with receiving data of exact max allowed buffer length', () => { + let jsonArray: any[] + let result: DbCommunity[] = [] + beforeAll(async () => { + jest.clearAllMocks() + jsonArray = [ + { + api: 'valid api1', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api2', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api3', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api4', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + result = await DbCommunity.find() + }) + + afterAll(async () => { + await cleanDB() + }) + + it('has five Communty entries in database', () => { + expect(result).toHaveLength(4) + }) + + it(`has an entry 'valid api1' with max content length for api and url`, () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'valid api1', + endPoint: + 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + + it(`has an entry 'valid api2' with max content length for api and url`, () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'valid api2', + endPoint: + 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + + it(`has an entry 'valid api3' with max content length for api and url`, () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'valid api3', + endPoint: + 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + + it(`has an entry 'valid api4' with max content length for api and url`, () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'valid api4', + endPoint: + 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + }) + + describe('with receiving data longer than max allowed buffer length', () => { + let jsonArray: any[] + beforeEach(async () => { + jest.clearAllMocks() + jsonArray = [ + { + api: 'Xvalid api1', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api2', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api3', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + { + api: 'valid api4', + url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich', + }, + ] + await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray))) + }) + + it('logs the received data', () => { + expect(logger.warn).toBeCalledWith( + `received more than max allowed length of data buffer: ${ + JSON.stringify(jsonArray).length + } against 1141 max allowed`, + ) + }) + }) + + describe('with proper data', () => { + let result: DbCommunity[] = [] + beforeAll(async () => { + jest.clearAllMocks() + await socketEventMocks.data( + Buffer.from( + JSON.stringify([ + { + api: 'v1_0', + url: 'http://localhost:4000/api/v1_0', + }, + { + api: 'v2_0', + url: 'http://localhost:4000/api/v2_0', + }, + ]), + ), + ) + result = await DbCommunity.find() + }) + + afterAll(async () => { + await cleanDB() + }) + + it('has two Communty entries in database', () => { + expect(result).toHaveLength(2) + }) + + it('has an entry for api version v1_0', () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'v1_0', + endPoint: 'http://localhost:4000/api/v1_0', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + + it('has an entry for api version v2_0', () => { + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + publicKey: expect.any(Buffer), + apiVersion: 'v2_0', + endPoint: 'http://localhost:4000/api/v2_0', + lastAnnouncedAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]), + ) + }) + }) + }) + + describe('on open', () => { + beforeEach(() => { + socketEventMocks.open() + }) + + it.skip('calls socket write with own api versions', () => { + expect(socketWriteMock).toBeCalledWith( + Buffer.from( + JSON.stringify([ + { + api: 'v1_0', + url: 'http://localhost:4000/api/v1_0', + }, + { + api: 'v1_1', + url: 'http://localhost:4000/api/v1_1', + }, + { + api: 'v2_0', + url: 'http://localhost:4000/api/v2_0', + }, + ]), + ), + ) + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/federation/index.ts b/backend/src/federation/index.ts index 82b961c63..ebaaed5e2 100644 --- a/backend/src/federation/index.ts +++ b/backend/src/federation/index.ts @@ -1,14 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - import DHT from '@hyperswarm/dht' -// import { Connection } from '@dbTools/typeorm' import { backendLogger as logger } from '@/server/logger' import CONFIG from '@/config' - -function between(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1) + min) -} +import { Community as DbCommunity } from '@entity/Community' const KEY_SECRET_SEEDBYTES = 32 const getSeed = (): Buffer | null => @@ -18,37 +13,107 @@ const POLLTIME = 20000 const SUCCESSTIME = 120000 const ERRORTIME = 240000 const ANNOUNCETIME = 30000 -const nodeRand = between(1, 99) -const nodeURL = `https://test${nodeRand}.org` -const nodeAPI = { - API_1_00: `${nodeURL}/api/1_00/`, - API_1_01: `${nodeURL}/api/1_01/`, - API_2_00: `${nodeURL}/graphql/2_00/`, + +enum ApiVersionType { + V1_0 = 'v1_0', + V1_1 = 'v1_1', + V2_0 = 'v2_0', +} +type CommunityApi = { + api: string + url: string } -export const startDHT = async ( - // connection: Connection, - topic: string, -): Promise => { +export const startDHT = async (topic: string): Promise => { try { const TOPIC = DHT.hash(Buffer.from(topic)) const keyPair = DHT.keyPair(getSeed()) logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`) logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`) + const ownApiVersions = Object.values(ApiVersionType).map(function (apiEnum) { + const comApi: CommunityApi = { + api: apiEnum, + url: CONFIG.FEDERATION_COMMUNITY_URL + apiEnum, + } + return comApi + }) + logger.debug(`ApiList: ${JSON.stringify(ownApiVersions)}`) + const node = new DHT({ keyPair }) const server = node.createServer() server.on('connection', function (socket: any) { - // noiseSocket is E2E between you and the other peer - // pipe it somewhere like any duplex stream - logger.info(`Remote public key: ${socket.remotePublicKey.toString('hex')}`) - // console.log("Local public key", noiseSocket.publicKey.toString("hex")); // same as keyPair.publicKey + logger.info(`server on... with Remote public key: ${socket.remotePublicKey.toString('hex')}`) - socket.on('data', (data: Buffer) => logger.info(`data: ${data.toString('ascii')}`)) + socket.on('data', async (data: Buffer) => { + try { + if (data.length > 1141) { + logger.warn( + `received more than max allowed length of data buffer: ${data.length} against 1141 max allowed`, + ) + return + } + logger.info(`data: ${data.toString('ascii')}`) + const recApiVersions: CommunityApi[] = JSON.parse(data.toString('ascii')) - // process.stdin.pipe(noiseSocket).pipe(process.stdout); + // TODO better to introduce the validation by https://github.com/typestack/class-validato + if (recApiVersions && Array.isArray(recApiVersions) && recApiVersions.length < 5) { + for (const recApiVersion of recApiVersions) { + if ( + !recApiVersion.api || + typeof recApiVersion.api !== 'string' || + !recApiVersion.url || + typeof recApiVersion.url !== 'string' + ) { + logger.warn( + `received invalid apiVersion-Definition: ${JSON.stringify(recApiVersion)}`, + ) + // in a forEach-loop use return instead of continue + return + } + // TODO better to introduce the validation on entity-Level by https://github.com/typestack/class-validator + if (recApiVersion.api.length > 10 || recApiVersion.url.length > 255) { + logger.warn( + `received apiVersion with content longer than max length: ${JSON.stringify( + recApiVersion, + )}`, + ) + // in a forEach-loop use return instead of continue + return + } + + const variables = { + apiVersion: recApiVersion.api, + endPoint: recApiVersion.url, + publicKey: socket.remotePublicKey.toString('hex'), + lastAnnouncedAt: new Date(), + } + logger.debug(`upsert with variables=${JSON.stringify(variables)}`) + // this will NOT update the updatedAt column, to distingue between a normal update and the last announcement + await DbCommunity.createQueryBuilder() + .insert() + .into(DbCommunity) + .values(variables) + .orUpdate({ + conflict_target: ['id', 'publicKey', 'apiVersion'], + overwrite: ['end_point', 'last_announced_at'], + }) + .execute() + logger.info(`federation community upserted successfully...`) + } + } else { + logger.warn( + `received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify( + recApiVersions, + )}`, + ) + } + } catch (e) { + logger.error('Error on receiving data from socket:', e) + } + }) }) await server.listen() @@ -93,7 +158,6 @@ export const startDHT = async ( logger.info(`Found new peers: ${collectedPubKeys}`) collectedPubKeys.forEach((remotePubKey) => { - // publicKey here is keyPair.publicKey from above const socket = node.connect(Buffer.from(remotePubKey, 'hex')) // socket.once("connect", function () { @@ -110,17 +174,12 @@ export const startDHT = async ( }) socket.on('open', function () { - // noiseSocket fully open with the other peer - // console.log("writing to socket"); - socket.write(Buffer.from(`${nodeRand}`)) - socket.write(Buffer.from(JSON.stringify(nodeAPI))) + socket.write(Buffer.from(JSON.stringify(ownApiVersions))) successfulRequests.push(remotePubKey) }) - // pipe it somewhere like any duplex stream - // process.stdin.pipe(noiseSocket).pipe(process.stdout) }) }, POLLTIME) } catch (err) { - logger.error(err) + logger.error('DHT unexpected error:', err) } } diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 387018624..cf2d55d94 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -1961,8 +1961,7 @@ describe('ContributionResolver', () => { }) }) - // In the futrue this should not throw anymore - it('throws an error for the second confirmation', async () => { + it('throws no error for the second confirmation', async () => { const r1 = mutate({ mutation: confirmContribution, variables: { @@ -1982,8 +1981,7 @@ describe('ContributionResolver', () => { ) await expect(r2).resolves.toEqual( expect.objectContaining({ - // data: { confirmContribution: true }, - errors: [new GraphQLError('Creation was not successful.')], + data: { confirmContribution: true }, }), ) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 32c72b9b1..2587aab61 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -50,6 +50,7 @@ import { sendContributionConfirmedEmail, sendContributionRejectedEmail, } from '@/emails/sendEmailVariants' +import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' @Resolver() export class ContributionResolver { @@ -579,8 +580,10 @@ export class ContributionResolver { clientTimezoneOffset, ) - const receivedCallDate = new Date() + // acquire lock + const releaseLock = await TRANSACTIONS_LOCK.acquire() + const receivedCallDate = new Date() const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') @@ -590,7 +593,7 @@ export class ContributionResolver { .select('transaction') .from(DbTransaction, 'transaction') .where('transaction.userId = :id', { id: contribution.userId }) - .orderBy('transaction.balanceDate', 'DESC') + .orderBy('transaction.id', 'DESC') .getOne() logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined') @@ -639,10 +642,11 @@ export class ContributionResolver { }) } catch (e) { await queryRunner.rollbackTransaction() - logger.error(`Creation was not successful: ${e}`) - throw new Error(`Creation was not successful.`) + logger.error('Creation was not successful', e) + throw new Error('Creation was not successful.') } finally { await queryRunner.release() + releaseLock() } const event = new Event() diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 28422af26..9f7d30244 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -23,6 +23,11 @@ import { User } from '@entity/User' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' +import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' + +// mock semaphore to allow use fake timers +jest.mock('@/util/TRANSACTIONS_LOCK') +TRANSACTIONS_LOCK.acquire = jest.fn().mockResolvedValue(jest.fn()) let mutate: any, query: any, con: any let testEnv: any @@ -185,8 +190,7 @@ describe('TransactionLinkResolver', () => { describe('after one day', () => { beforeAll(async () => { jest.useFakeTimers() - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - setTimeout(() => {}, 1000 * 60 * 60 * 24) + setTimeout(jest.fn(), 1000 * 60 * 60 * 24) jest.runAllTimers() await mutate({ mutation: login, diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 9041aae67..897cf9252 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -31,6 +31,7 @@ import { calculateDecay } from '@/util/decay' import { getUserCreation, validateContribution } from './util/creations' import { executeTransaction } from './TransactionResolver' import QueryLinkResult from '@union/QueryLinkResult' +import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' // TODO: do not export, test it inside the resolver export const transactionLinkCode = (date: Date): string => { @@ -165,10 +166,12 @@ export class TransactionLinkResolver { ): Promise { const clientTimezoneOffset = getClientTimezoneOffset(context) const user = getUser(context) - const now = new Date() if (code.match(/^CL-/)) { + // acquire lock + const releaseLock = await TRANSACTIONS_LOCK.acquire() logger.info('redeem contribution link...') + const now = new Date() const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('REPEATABLE READ') @@ -273,7 +276,7 @@ export class TransactionLinkResolver { .select('transaction') .from(DbTransaction, 'transaction') .where('transaction.userId = :id', { id: user.id }) - .orderBy('transaction.balanceDate', 'DESC') + .orderBy('transaction.id', 'DESC') .getOne() let newBalance = new Decimal(0) @@ -309,9 +312,11 @@ export class TransactionLinkResolver { throw new Error(`Creation from contribution link was not successful. ${e}`) } finally { await queryRunner.release() + releaseLock() } return true } else { + const now = new Date() const transactionLink = await DbTransactionLink.findOneOrFail({ code }) const linkedUser = await DbUser.findOneOrFail( { id: transactionLink.userId }, @@ -322,6 +327,9 @@ export class TransactionLinkResolver { throw new Error('Cannot redeem own transaction link.') } + // TODO: The now check should be done within the semaphore lock, + // since the program might wait a while till it is ready to proceed + // writing the transaction. if (transactionLink.validUntil.getTime() < now.getTime()) { throw new Error('Transaction Link is not valid anymore.') } diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts index 1d4fe5708..6115ef846 100644 --- a/backend/src/graphql/resolver/TransactionResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -368,5 +368,74 @@ describe('send coins', () => { ) }) }) + + describe('more transactions to test semaphore', () => { + it('sends the coins four times in a row', async () => { + await expect( + mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 10, + memo: 'first transaction', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + await expect( + mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 20, + memo: 'second transaction', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + await expect( + mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 30, + memo: 'third transaction', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + await expect( + mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 40, + memo: 'fourth transaction', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + }) + }) }) }) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 4df7af601..0ac5b382e 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -36,6 +36,8 @@ import { BalanceResolver } from './BalanceResolver' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { findUserByEmail } from './UserResolver' +import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK' + export const executeTransaction = async ( amount: Decimal, memo: string, @@ -62,124 +64,133 @@ export const executeTransaction = async ( throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } - // validate amount - const receivedCallDate = new Date() - const sendBalance = await calculateBalance( - sender.id, - amount.mul(-1), - receivedCallDate, - transactionLink, - ) - logger.debug(`calculated Balance=${sendBalance}`) - if (!sendBalance) { - logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`) - throw new Error("user hasn't enough GDD or amount is < 0") - } + // acquire lock + const releaseLock = await TRANSACTIONS_LOCK.acquire() - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') - logger.debug(`open Transaction to write...`) try { - // transaction - const transactionSend = new dbTransaction() - transactionSend.typeId = TransactionTypeId.SEND - transactionSend.memo = memo - transactionSend.userId = sender.id - transactionSend.linkedUserId = recipient.id - transactionSend.amount = amount.mul(-1) - transactionSend.balance = sendBalance.balance - transactionSend.balanceDate = receivedCallDate - transactionSend.decay = sendBalance.decay.decay - transactionSend.decayStart = sendBalance.decay.start - transactionSend.previous = sendBalance.lastTransactionId - transactionSend.transactionLinkId = transactionLink ? transactionLink.id : null - await queryRunner.manager.insert(dbTransaction, transactionSend) - - logger.debug(`sendTransaction inserted: ${dbTransaction}`) - - const transactionReceive = new dbTransaction() - transactionReceive.typeId = TransactionTypeId.RECEIVE - transactionReceive.memo = memo - transactionReceive.userId = recipient.id - transactionReceive.linkedUserId = sender.id - transactionReceive.amount = amount - const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) - transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount - transactionReceive.balanceDate = receivedCallDate - transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) - transactionReceive.decayStart = receiveBalance ? receiveBalance.decay.start : null - transactionReceive.previous = receiveBalance ? receiveBalance.lastTransactionId : null - transactionReceive.linkedTransactionId = transactionSend.id - transactionReceive.transactionLinkId = transactionLink ? transactionLink.id : null - await queryRunner.manager.insert(dbTransaction, transactionReceive) - logger.debug(`receive Transaction inserted: ${dbTransaction}`) - - // Save linked transaction id for send - transactionSend.linkedTransactionId = transactionReceive.id - await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) - logger.debug(`send Transaction updated: ${transactionSend}`) - - if (transactionLink) { - logger.info(`transactionLink: ${transactionLink}`) - transactionLink.redeemedAt = receivedCallDate - transactionLink.redeemedBy = recipient.id - await queryRunner.manager.update( - dbTransactionLink, - { id: transactionLink.id }, - transactionLink, - ) + // validate amount + const receivedCallDate = new Date() + const sendBalance = await calculateBalance( + sender.id, + amount.mul(-1), + receivedCallDate, + transactionLink, + ) + logger.debug(`calculated Balance=${sendBalance}`) + if (!sendBalance) { + logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`) + throw new Error("user hasn't enough GDD or amount is < 0") } - await queryRunner.commitTransaction() - logger.info(`commit Transaction successful...`) + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') + logger.debug(`open Transaction to write...`) + try { + // transaction + const transactionSend = new dbTransaction() + transactionSend.typeId = TransactionTypeId.SEND + transactionSend.memo = memo + transactionSend.userId = sender.id + transactionSend.linkedUserId = recipient.id + transactionSend.amount = amount.mul(-1) + transactionSend.balance = sendBalance.balance + transactionSend.balanceDate = receivedCallDate + transactionSend.decay = sendBalance.decay.decay + transactionSend.decayStart = sendBalance.decay.start + transactionSend.previous = sendBalance.lastTransactionId + transactionSend.transactionLinkId = transactionLink ? transactionLink.id : null + await queryRunner.manager.insert(dbTransaction, transactionSend) - const eventTransactionSend = new EventTransactionSend() - eventTransactionSend.userId = transactionSend.userId - eventTransactionSend.xUserId = transactionSend.linkedUserId - eventTransactionSend.transactionId = transactionSend.id - eventTransactionSend.amount = transactionSend.amount.mul(-1) - await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend)) + logger.debug(`sendTransaction inserted: ${dbTransaction}`) - const eventTransactionReceive = new EventTransactionReceive() - eventTransactionReceive.userId = transactionReceive.userId - eventTransactionReceive.xUserId = transactionReceive.linkedUserId - eventTransactionReceive.transactionId = transactionReceive.id - eventTransactionReceive.amount = transactionReceive.amount - await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive)) - } catch (e) { - await queryRunner.rollbackTransaction() - logger.error(`Transaction was not successful: ${e}`) - throw new Error(`Transaction was not successful: ${e}`) - } finally { - await queryRunner.release() - } - logger.debug(`prepare Email for transaction received...`) - await sendTransactionReceivedEmail({ - firstName: recipient.firstName, - lastName: recipient.lastName, - email: recipient.emailContact.email, - language: recipient.language, - senderFirstName: sender.firstName, - senderLastName: sender.lastName, - senderEmail: sender.emailContact.email, - transactionAmount: amount, - }) - if (transactionLink) { - await sendTransactionLinkRedeemedEmail({ - firstName: sender.firstName, - lastName: sender.lastName, - email: sender.emailContact.email, - language: sender.language, - senderFirstName: recipient.firstName, - senderLastName: recipient.lastName, - senderEmail: recipient.emailContact.email, + const transactionReceive = new dbTransaction() + transactionReceive.typeId = TransactionTypeId.RECEIVE + transactionReceive.memo = memo + transactionReceive.userId = recipient.id + transactionReceive.linkedUserId = sender.id + transactionReceive.amount = amount + const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount + transactionReceive.balanceDate = receivedCallDate + transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) + transactionReceive.decayStart = receiveBalance ? receiveBalance.decay.start : null + transactionReceive.previous = receiveBalance ? receiveBalance.lastTransactionId : null + transactionReceive.linkedTransactionId = transactionSend.id + transactionReceive.transactionLinkId = transactionLink ? transactionLink.id : null + await queryRunner.manager.insert(dbTransaction, transactionReceive) + logger.debug(`receive Transaction inserted: ${dbTransaction}`) + + // Save linked transaction id for send + transactionSend.linkedTransactionId = transactionReceive.id + await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) + logger.debug(`send Transaction updated: ${transactionSend}`) + + if (transactionLink) { + logger.info(`transactionLink: ${transactionLink}`) + transactionLink.redeemedAt = receivedCallDate + transactionLink.redeemedBy = recipient.id + await queryRunner.manager.update( + dbTransactionLink, + { id: transactionLink.id }, + transactionLink, + ) + } + + await queryRunner.commitTransaction() + logger.info(`commit Transaction successful...`) + + const eventTransactionSend = new EventTransactionSend() + eventTransactionSend.userId = transactionSend.userId + eventTransactionSend.xUserId = transactionSend.linkedUserId + eventTransactionSend.transactionId = transactionSend.id + eventTransactionSend.amount = transactionSend.amount.mul(-1) + await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend)) + + const eventTransactionReceive = new EventTransactionReceive() + eventTransactionReceive.userId = transactionReceive.userId + eventTransactionReceive.xUserId = transactionReceive.linkedUserId + eventTransactionReceive.transactionId = transactionReceive.id + eventTransactionReceive.amount = transactionReceive.amount + await eventProtocol.writeEvent( + new Event().setEventTransactionReceive(eventTransactionReceive), + ) + } catch (e) { + await queryRunner.rollbackTransaction() + logger.error(`Transaction was not successful: ${e}`) + throw new Error(`Transaction was not successful: ${e}`) + } finally { + await queryRunner.release() + } + logger.debug(`prepare Email for transaction received...`) + await sendTransactionReceivedEmail({ + firstName: recipient.firstName, + lastName: recipient.lastName, + email: recipient.emailContact.email, + language: recipient.language, + senderFirstName: sender.firstName, + senderLastName: sender.lastName, + senderEmail: sender.emailContact.email, transactionAmount: amount, - transactionMemo: memo, }) + if (transactionLink) { + await sendTransactionLinkRedeemedEmail({ + firstName: sender.firstName, + lastName: sender.lastName, + email: sender.emailContact.email, + language: sender.language, + senderFirstName: recipient.firstName, + senderLastName: recipient.lastName, + senderEmail: recipient.emailContact.email, + transactionAmount: amount, + transactionMemo: memo, + }) + } + logger.info(`finished executeTransaction successfully`) + return true + } finally { + releaseLock() } - logger.info(`finished executeTransaction successfully`) - return true } @Resolver() diff --git a/backend/src/graphql/resolver/semaphore.test.ts b/backend/src/graphql/resolver/semaphore.test.ts new file mode 100644 index 000000000..e334910f1 --- /dev/null +++ b/backend/src/graphql/resolver/semaphore.test.ts @@ -0,0 +1,190 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import Decimal from 'decimal.js-light' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { logger } from '@test/testSetup' +import { userFactory } from '@/seeds/factory/user' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { creationFactory, nMonthsBefore } from '@/seeds/factory/creation' +import { cleanDB, testEnvironment, contributionDateFormatter } from '@test/helpers' +import { + confirmContribution, + createContribution, + createTransactionLink, + redeemTransactionLink, + login, + createContributionLink, + sendCoins, +} from '@/seeds/graphql/mutations' + +let mutate: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('semaphore', () => { + let contributionLinkCode = '' + let bobsTransactionLinkCode = '' + let bibisTransactionLinkCode = '' + let bibisOpenContributionId = -1 + let bobsOpenContributionId = -1 + + beforeAll(async () => { + const now = new Date() + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) + await userFactory(testEnv, bobBaumeister) + await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 1000, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: nMonthsBefore(new Date()), + confirmed: true, + }) + await creationFactory(testEnv, { + email: 'bob@baumeister.de', + amount: 1000, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: nMonthsBefore(new Date()), + confirmed: true, + }) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + const { + data: { createContributionLink: contributionLink }, + } = await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(200), + name: 'Test Contribution Link', + memo: 'Danke für deine Teilnahme an dem Test der Contribution Links', + cycle: 'ONCE', + validFrom: new Date(2022, 5, 18).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, + }) + contributionLinkCode = 'CL-' + contributionLink.code + await mutate({ + mutation: login, + variables: { email: 'bob@baumeister.de', password: 'Aa12345_' }, + }) + const { + data: { createTransactionLink: bobsLink }, + } = await mutate({ + mutation: createTransactionLink, + variables: { + email: 'bob@baumeister.de', + amount: 20, + memo: 'Bobs Link', + }, + }) + const { + data: { createContribution: bobsContribution }, + } = await mutate({ + mutation: createContribution, + variables: { + creationDate: contributionDateFormatter(new Date()), + amount: 200, + memo: 'Bobs Contribution', + }, + }) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const { + data: { createTransactionLink: bibisLink }, + } = await mutate({ + mutation: createTransactionLink, + variables: { + amount: 20, + memo: 'Bibis Link', + }, + }) + const { + data: { createContribution: bibisContribution }, + } = await mutate({ + mutation: createContribution, + variables: { + creationDate: contributionDateFormatter(new Date()), + amount: 200, + memo: 'Bibis Contribution', + }, + }) + bobsTransactionLinkCode = bobsLink.code + bibisTransactionLinkCode = bibisLink.code + bibisOpenContributionId = bibisContribution.id + bobsOpenContributionId = bobsContribution.id + }) + + it('creates a lot of transactions without errors', async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const bibiRedeemContributionLink = mutate({ + mutation: redeemTransactionLink, + variables: { code: contributionLinkCode }, + }) + const redeemBobsLink = mutate({ + mutation: redeemTransactionLink, + variables: { code: bobsTransactionLinkCode }, + }) + const bibisTransaction = mutate({ + mutation: sendCoins, + variables: { email: 'bob@baumeister.de', amount: '50', memo: 'Das ist für dich, Bob' }, + }) + await mutate({ + mutation: login, + variables: { email: 'bob@baumeister.de', password: 'Aa12345_' }, + }) + const bobRedeemContributionLink = mutate({ + mutation: redeemTransactionLink, + variables: { code: contributionLinkCode }, + }) + const redeemBibisLink = mutate({ + mutation: redeemTransactionLink, + variables: { code: bibisTransactionLinkCode }, + }) + const bobsTransaction = mutate({ + mutation: sendCoins, + variables: { email: 'bibi@bloxberg.de', amount: '50', memo: 'Das ist für dich, Bibi' }, + }) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + const confirmBibisContribution = mutate({ + mutation: confirmContribution, + variables: { id: bibisOpenContributionId }, + }) + const confirmBobsContribution = mutate({ + mutation: confirmContribution, + variables: { id: bobsOpenContributionId }, + }) + await expect(bibiRedeemContributionLink).resolves.toMatchObject({ errors: undefined }) + await expect(redeemBobsLink).resolves.toMatchObject({ errors: undefined }) + await expect(bibisTransaction).resolves.toMatchObject({ errors: undefined }) + await expect(bobRedeemContributionLink).resolves.toMatchObject({ errors: undefined }) + await expect(redeemBibisLink).resolves.toMatchObject({ errors: undefined }) + await expect(bobsTransaction).resolves.toMatchObject({ errors: undefined }) + await expect(confirmBibisContribution).resolves.toMatchObject({ errors: undefined }) + await expect(confirmBobsContribution).resolves.toMatchObject({ errors: undefined }) + }) +}) diff --git a/backend/src/index.ts b/backend/src/index.ts index e63f80827..329e63f87 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,9 @@ async function main() { // start DHT hyperswarm when DHT_TOPIC is set in .env if (CONFIG.FEDERATION_DHT_TOPIC) { + if (CONFIG.FEDERATION_COMMUNITY_URL === null) { + throw Error(`Config-Error: missing configuration of property FEDERATION_COMMUNITY_URL`) + } // eslint-disable-next-line no-console console.log( `starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${ diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index 3675d381d..9e1939db8 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -75,10 +75,7 @@ const run = async () => { // create GDD for (let i = 0; i < creations.length; i++) { - const now = new Date().getTime() // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) await creationFactory(seedClient, creations[i]) - // eslint-disable-next-line no-empty - while (new Date().getTime() < now + 1000) {} // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) } logger.info('##seed## seeding all creations successful...') diff --git a/backend/src/util/TRANSACTIONS_LOCK.ts b/backend/src/util/TRANSACTIONS_LOCK.ts new file mode 100644 index 000000000..847386e4d --- /dev/null +++ b/backend/src/util/TRANSACTIONS_LOCK.ts @@ -0,0 +1,4 @@ +import { Semaphore } from 'await-semaphore' + +const CONCURRENT_TRANSACTIONS = 1 +export const TRANSACTIONS_LOCK = new Semaphore(CONCURRENT_TRANSACTIONS) diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 437e04189..397a38730 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -20,7 +20,7 @@ async function calculateBalance( time: Date, transactionLink?: dbTransactionLink | null, ): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { - const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } }) + const lastTransaction = await Transaction.findOne({ userId }, { order: { id: 'DESC' } }) if (!lastTransaction) return null const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) diff --git a/backend/yarn.lock b/backend/yarn.lock index 940906cfa..82bcd6b1f 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1643,6 +1643,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +await-semaphore@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/await-semaphore/-/await-semaphore-0.1.3.tgz#2b88018cc8c28e06167ae1cdff02504f1f9688d3" + integrity sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q== + axios@^0.21.1: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" diff --git a/database/entity/0058-add_communities_table/Community.ts b/database/entity/0058-add_communities_table/Community.ts new file mode 100644 index 000000000..f2d071ce4 --- /dev/null +++ b/database/entity/0058-add_communities_table/Community.ts @@ -0,0 +1,42 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm' + +@Entity('communities') +export class Community extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'public_key', type: 'binary', length: 64, default: null, nullable: true }) + publicKey: Buffer + + @Column({ name: 'api_version', length: 10, nullable: false }) + apiVersion: string + + @Column({ name: 'end_point', length: 255, nullable: false }) + endPoint: string + + @Column({ name: 'last_announced_at', type: 'datetime', nullable: false }) + lastAnnouncedAt: Date + + @CreateDateColumn({ + name: 'created_at', + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP(3)', + nullable: false, + }) + createdAt: Date + + @UpdateDateColumn({ + name: 'updated_at', + type: 'datetime', + onUpdate: 'CURRENT_TIMESTAMP(3)', + nullable: true, + }) + updatedAt: Date | null +} diff --git a/database/entity/Community.ts b/database/entity/Community.ts new file mode 100644 index 000000000..457d03eae --- /dev/null +++ b/database/entity/Community.ts @@ -0,0 +1 @@ +export { Community } from './0058-add_communities_table/Community' diff --git a/database/entity/index.ts b/database/entity/index.ts index a82ef561c..a58afb816 100644 --- a/database/entity/index.ts +++ b/database/entity/index.ts @@ -9,6 +9,7 @@ import { UserContact } from './UserContact' import { Contribution } from './Contribution' import { EventProtocol } from './EventProtocol' import { ContributionMessage } from './ContributionMessage' +import { Community } from './Community' export const entities = [ Contribution, @@ -22,4 +23,5 @@ export const entities = [ EventProtocol, ContributionMessage, UserContact, + Community, ] diff --git a/database/migrations/0058-add_communities_table.ts b/database/migrations/0058-add_communities_table.ts new file mode 100644 index 000000000..1e5bb5084 --- /dev/null +++ b/database/migrations/0058-add_communities_table.ts @@ -0,0 +1,28 @@ +/* MIGRATION TO CREATE THE FEDERATION COMMUNITY TABLES + * + * This migration creates the `community` and 'communityfederation' tables in the `apollo` database (`gradido_community`). + */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + CREATE TABLE communities ( + id int unsigned NOT NULL AUTO_INCREMENT, + public_key binary(64), + api_version varchar(10) NOT NULL, + end_point varchar(255) NOT NULL, + last_announced_at datetime(3) NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3), + PRIMARY KEY (id), + UNIQUE KEY public_api_key (public_key, api_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + // write downgrade logic as parameter of queryFn + await queryFn(`DROP TABLE communities;`) +}