diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 798bef1e6..78ac9e41e 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -27,7 +27,8 @@ module.exports = { }, }, rules: { - 'no-console': ['error'], + 'no-console': 'error', + camelcase: ['error', { allow: ['FederationClient_*'] }], 'no-debugger': 'error', 'prettier/prettier': [ 'error', @@ -184,6 +185,7 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig.json', '**/tsconfig.json'], // this is to properly reference the referenced project database without requirement of compiling it + // eslint-disable-next-line camelcase EXPERIMENTAL_useSourceOfProjectReferenceRedirect: true, }, }, diff --git a/backend/src/federation/client/FederationClient.ts b/backend/src/federation/client/FederationClient.ts new file mode 100644 index 000000000..db1e5e3b2 --- /dev/null +++ b/backend/src/federation/client/FederationClient.ts @@ -0,0 +1,55 @@ +import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' + +import { ApiVersionType } from '@/federation/enum/apiVersionType' + +import { FederationClient_1_0 } from './FederationClient_1_0' +import { FederationClient_1_1 } from './FederationClient_1_1' + +type FederationClientType = FederationClient_1_0 | FederationClient_1_1 + +interface ClientInstance { + id: number + // eslint-disable-next-line no-use-before-define + client: FederationClientType +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class FederationClient { + private static instanceArray: ClientInstance[] = [] + + /** + * The Singleton's constructor should always be private to prevent direct + * construction calls with the `new` operator. + */ + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} + + private static createFederationClient = (dbCom: DbFederatedCommunity) => { + switch (dbCom.apiVersion) { + case ApiVersionType.V1_0: + return new FederationClient_1_0(dbCom) + case ApiVersionType.V1_1: + return new FederationClient_1_1(dbCom) + default: + return null + } + } + + /** + * The static method that controls the access to the singleton instance. + * + * This implementation let you subclass the Singleton class while keeping + * just one instance of each subclass around. + */ + public static getInstance(dbCom: DbFederatedCommunity): FederationClientType | null { + const instance = FederationClient.instanceArray.find((instance) => instance.id === dbCom.id) + if (instance) { + return instance.client + } + const client = FederationClient.createFederationClient(dbCom) + if (client) { + FederationClient.instanceArray.push({ id: dbCom.id, client } as ClientInstance) + } + return client + } +} diff --git a/backend/src/federation/client/FederationClient_1_0.ts b/backend/src/federation/client/FederationClient_1_0.ts new file mode 100644 index 000000000..c8e878ded --- /dev/null +++ b/backend/src/federation/client/FederationClient_1_0.ts @@ -0,0 +1,48 @@ +import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' +import { GraphQLClient } from 'graphql-request' + +import { getPublicKey } from '@/federation/query/getPublicKey' +import { backendLogger as logger } from '@/server/logger' + +export class FederationClient_1_0 { + dbCom: DbFederatedCommunity + endpoint: string + client: GraphQLClient + + constructor(dbCom: DbFederatedCommunity) { + this.dbCom = dbCom + this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${ + dbCom.apiVersion + }/` + this.client = new GraphQLClient(this.endpoint, { + method: 'GET', + jsonSerializer: { + parse: JSON.parse, + stringify: JSON.stringify, + }, + }) + } + + getPublicKey = async (): Promise => { + logger.info('Federation: getPublicKey from endpoint', this.endpoint) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { data } = await this.client.rawRequest(getPublicKey, {}) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!data?.getPublicKey?.publicKey) { + logger.warn('Federation: getPublicKey without response data from endpoint', this.endpoint) + return + } + logger.info( + 'Federation: getPublicKey successful from endpoint', + this.endpoint, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + data.getPublicKey.publicKey, + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return data.getPublicKey.publicKey + } catch (err) { + logger.warn('Federation: getPublicKey failed for endpoint', this.endpoint) + } + } +} diff --git a/backend/src/federation/client/FederationClient_1_1.ts b/backend/src/federation/client/FederationClient_1_1.ts new file mode 100644 index 000000000..27679b423 --- /dev/null +++ b/backend/src/federation/client/FederationClient_1_1.ts @@ -0,0 +1,3 @@ +import { FederationClient_1_0 } from './FederationClient_1_0' + +export class FederationClient_1_1 extends FederationClient_1_0 {} diff --git a/backend/src/federation/validateCommunities.test.ts b/backend/src/federation/validateCommunities.test.ts index b1f8d0abe..c489798bb 100644 --- a/backend/src/federation/validateCommunities.test.ts +++ b/backend/src/federation/validateCommunities.test.ts @@ -83,6 +83,7 @@ describe('validate Communities', () => { .into(DbFederatedCommunity) .values(variables1) .orUpdate({ + // eslint-disable-next-line camelcase conflict_target: ['id', 'publicKey', 'apiVersion'], overwrite: ['end_point', 'last_announced_at'], }) @@ -160,6 +161,7 @@ describe('validate Communities', () => { .into(DbFederatedCommunity) .values(variables2) .orUpdate({ + // eslint-disable-next-line camelcase conflict_target: ['id', 'publicKey', 'apiVersion'], overwrite: ['end_point', 'last_announced_at'], }) @@ -198,6 +200,7 @@ describe('validate Communities', () => { .into(DbFederatedCommunity) .values(variables3) .orUpdate({ + // eslint-disable-next-line camelcase conflict_target: ['id', 'publicKey', 'apiVersion'], overwrite: ['end_point', 'last_announced_at'], }) diff --git a/dht-node/src/dht_node/index.test.ts b/dht-node/src/dht_node/index.test.ts index 04a45c51a..ec172c4f8 100644 --- a/dht-node/src/dht_node/index.test.ts +++ b/dht-node/src/dht_node/index.test.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { - CommunityApi, - startDHT, - writeFederatedHomeCommunityEntries, - writeHomeCommunityEntry, -} from './index' +import { startDHT } from './index' import DHT from '@hyperswarm/dht' import CONFIG from '@/config' import { logger } from '@test/testSetup' @@ -121,6 +116,9 @@ describe('federation', () => { const hashSpy = jest.spyOn(DHT, 'hash') const keyPairSpy = jest.spyOn(DHT, 'keyPair') beforeEach(async () => { + CONFIG.FEDERATION_COMMUNITY_URL = 'https://test.gradido.net' + CONFIG.COMMUNITY_NAME = 'Gradido Test Community' + CONFIG.COMMUNITY_DESCRIPTION = 'Community to test the federation' DHT.mockClear() jest.clearAllMocks() await cleanDB() @@ -139,6 +137,64 @@ describe('federation', () => { expect(DHT).toBeCalledWith({ keyPair: keyPairMock }) }) + it('stores the home community in community table ', async () => { + const result = await DbCommunity.find() + expect(result).toEqual([ + expect.objectContaining({ + id: expect.any(Number), + foreign: false, + url: 'https://test.gradido.net/api/', + publicKey: expect.any(Buffer), + communityUuid: expect.any(String), + authenticatedAt: null, + name: 'Gradido Test Community', + description: 'Community to test the federation', + creationDate: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: null, + }), + ]) + expect(validateUUID(result[0].communityUuid ? result[0].communityUuid : '')).toEqual(true) + expect(versionUUID(result[0].communityUuid ? result[0].communityUuid : '')).toEqual(4) + }) + + it('creates 3 entries in table federated_communities', async () => { + const result = await DbFederatedCommunity.find({ order: { id: 'ASC' } }) + await expect(result).toHaveLength(3) + await expect(result).toEqual([ + expect.objectContaining({ + id: expect.any(Number), + foreign: false, + publicKey: expect.any(Buffer), + apiVersion: '1_0', + endPoint: 'https://test.gradido.net/api/', + lastAnnouncedAt: null, + createdAt: expect.any(Date), + updatedAt: null, + }), + expect.objectContaining({ + id: expect.any(Number), + foreign: false, + publicKey: expect.any(Buffer), + apiVersion: '1_1', + endPoint: 'https://test.gradido.net/api/', + lastAnnouncedAt: null, + createdAt: expect.any(Date), + updatedAt: null, + }), + expect.objectContaining({ + id: expect.any(Number), + foreign: false, + publicKey: expect.any(Buffer), + apiVersion: '2_0', + endPoint: 'https://test.gradido.net/api/', + lastAnnouncedAt: null, + createdAt: expect.any(Date), + updatedAt: null, + }), + ]) + }) + describe('DHT node', () => { it('creates a server', () => { expect(nodeCreateServerMock).toBeCalled() @@ -162,131 +218,6 @@ describe('federation', () => { }) }) - describe('home community', () => { - it('one in table communities', async () => { - const result = await DbCommunity.find({ foreign: false }) - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(Number), - foreign: false, - url: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - publicKey: expect.any(Buffer), - communityUuid: expect.any(String), - authenticatedAt: null, - name: CONFIG.COMMUNITY_NAME, - description: CONFIG.COMMUNITY_DESCRIPTION, - creationDate: expect.any(Date), - createdAt: expect.any(Date), - updatedAt: null, - }), - ]), - ) - const valUUID = validateUUID( - result[0].communityUuid != null ? result[0].communityUuid : '', - ) - const verUUID = versionUUID( - result[0].communityUuid != null ? result[0].communityUuid : '', - ) - expect(valUUID).toEqual(true) - expect(verUUID).toEqual(4) - }) - it('update the one in table communities', async () => { - const resultBefore = await DbCommunity.find({ foreign: false }) - expect(resultBefore).toHaveLength(1) - const modifiedCom = DbCommunity.create() - modifiedCom.description = 'updated description' - modifiedCom.name = 'update name' - modifiedCom.publicKey = Buffer.from( - '1234567891abcdef7892abcdef7893abcdef7894abcdef7895abcdef7896abcd', - ) - modifiedCom.url = 'updated url' - await DbCommunity.update(modifiedCom, { id: resultBefore[0].id }) - - await writeHomeCommunityEntry(modifiedCom.publicKey.toString()) - const resultAfter = await DbCommunity.find({ foreign: false }) - expect(resultAfter).toHaveLength(1) - expect(resultAfter).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: resultBefore[0].id, - foreign: false, - url: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - publicKey: modifiedCom.publicKey, - communityUuid: resultBefore[0].communityUuid, - authenticatedAt: null, - name: CONFIG.COMMUNITY_NAME, - description: CONFIG.COMMUNITY_DESCRIPTION, - creationDate: expect.any(Date), - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - }), - ]), - ) - }) - }) - - // skipped because ot timing problems in testframework - describe.skip('federated home community', () => { - it('three in table federated_communities', async () => { - const homeApiVersions: CommunityApi[] = await writeFederatedHomeCommunityEntries( - keyPairMock.publicKey.toString('hex'), - ) - expect(homeApiVersions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - api: '1_0', - url: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - }), - expect.objectContaining({ - api: '1_1', - url: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - }), - expect.objectContaining({ - api: '2_0', - url: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - }), - ]), - ) - const result = await DbFederatedCommunity.find({ foreign: false }) - expect(result).toHaveLength(3) - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(Number), - foreign: false, - publicKey: expect.any(Buffer), - apiVersion: '1_0', - endPoint: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - lastAnnouncedAt: null, - createdAt: expect.any(Date), - updatedAt: null, - }), - expect.objectContaining({ - id: expect.any(Number), - foreign: false, - publicKey: expect.any(Buffer), - apiVersion: '1_1', - endPoint: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - lastAnnouncedAt: null, - createdAt: expect.any(Date), - updatedAt: null, - }), - expect.objectContaining({ - id: expect.any(Number), - foreign: false, - publicKey: expect.any(Buffer), - apiVersion: '2_0', - endPoint: CONFIG.FEDERATION_COMMUNITY_URL + '/api/', - lastAnnouncedAt: null, - createdAt: expect.any(Date), - updatedAt: null, - }), - ]), - ) - }) - }) - describe('server connection event', () => { beforeEach(() => { serverEventMocks.connection({ @@ -918,15 +849,15 @@ describe('federation', () => { JSON.stringify([ { api: '1_0', - url: 'http://localhost/api/', + url: 'https://test.gradido.net/api/', }, { api: '1_1', - url: 'http://localhost/api/', + url: 'https://test.gradido.net/api/', }, { api: '2_0', - url: 'http://localhost/api/', + url: 'https://test.gradido.net/api/', }, ]), ), @@ -936,5 +867,101 @@ describe('federation', () => { }) }) }) + + describe('restart DHT', () => { + let homeCommunity: DbCommunity + let federatedCommunities: DbFederatedCommunity[] + + describe('without changes', () => { + beforeEach(async () => { + DHT.mockClear() + jest.clearAllMocks() + homeCommunity = (await DbCommunity.find())[0] + federatedCommunities = await DbFederatedCommunity.find({ order: { id: 'ASC' } }) + await startDHT(TEST_TOPIC) + }) + + it('does not change home community in community table except updated at column ', async () => { + await expect(DbCommunity.find()).resolves.toEqual([ + { + ...homeCommunity, + updatedAt: expect.any(Date), + }, + ]) + }) + + it('rewrites the 3 entries in table federated_communities', async () => { + const result = await DbFederatedCommunity.find() + await expect(result).toHaveLength(3) + await expect(result).toEqual([ + { + ...federatedCommunities[0], + id: expect.any(Number), + createdAt: expect.any(Date), + }, + { + ...federatedCommunities[1], + id: expect.any(Number), + createdAt: expect.any(Date), + }, + { + ...federatedCommunities[2], + id: expect.any(Number), + createdAt: expect.any(Date), + }, + ]) + }) + }) + + describe('changeing URL, name and description', () => { + beforeEach(async () => { + CONFIG.FEDERATION_COMMUNITY_URL = 'https://test2.gradido.net' + CONFIG.COMMUNITY_NAME = 'Second Gradido Test Community' + CONFIG.COMMUNITY_DESCRIPTION = 'Another Community to test the federation' + DHT.mockClear() + jest.clearAllMocks() + homeCommunity = (await DbCommunity.find())[0] + federatedCommunities = await DbFederatedCommunity.find({ order: { id: 'ASC' } }) + await startDHT(TEST_TOPIC) + }) + + it('updates URL, name, description and updated at columns ', async () => { + await expect(DbCommunity.find()).resolves.toEqual([ + { + ...homeCommunity, + url: 'https://test2.gradido.net/api/', + name: 'Second Gradido Test Community', + description: 'Another Community to test the federation', + updatedAt: expect.any(Date), + }, + ]) + }) + + it('rewrites the 3 entries in table federated_communities with new endpoint', async () => { + const result = await DbFederatedCommunity.find() + await expect(result).toHaveLength(3) + await expect(result).toEqual([ + { + ...federatedCommunities[0], + id: expect.any(Number), + createdAt: expect.any(Date), + endPoint: 'https://test2.gradido.net/api/', + }, + { + ...federatedCommunities[1], + id: expect.any(Number), + createdAt: expect.any(Date), + endPoint: 'https://test2.gradido.net/api/', + }, + { + ...federatedCommunities[2], + id: expect.any(Number), + createdAt: expect.any(Date), + endPoint: 'https://test2.gradido.net/api/', + }, + ]) + }) + }) + }) }) }) diff --git a/dht-node/src/dht_node/index.ts b/dht-node/src/dht_node/index.ts index bd9c95a7e..4fd0bd733 100644 --- a/dht-node/src/dht_node/index.ts +++ b/dht-node/src/dht_node/index.ts @@ -33,13 +33,12 @@ 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')}`) + const pubKeyString = keyPair.publicKey.toString('hex') + logger.info(`keyPairDHT: publicKey=${pubKeyString}`) logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`) - await writeHomeCommunityEntry(keyPair.publicKey.toString('hex')) + await writeHomeCommunityEntry(pubKeyString) - const ownApiVersions = await writeFederatedHomeCommunityEntries( - keyPair.publicKey.toString('hex'), - ) + const ownApiVersions = await writeFederatedHomeCommunityEntries(pubKeyString) logger.info(`ApiList: ${JSON.stringify(ownApiVersions)}`) const node = new DHT({ keyPair }) @@ -146,7 +145,7 @@ export const startDHT = async (topic: string): Promise => { data.peers.forEach((peer: any) => { const pubKey = peer.publicKey.toString('hex') if ( - pubKey !== keyPair.publicKey.toString('hex') && + pubKey !== pubKeyString && !successfulRequests.includes(pubKey) && !errorfulRequests.includes(pubKey) && !collectedPubKeys.includes(pubKey) @@ -197,17 +196,15 @@ export async function writeFederatedHomeCommunityEntries(pubKey: string): Promis }) try { // first remove privious existing homeCommunity entries - DbFederatedCommunity.createQueryBuilder().delete().where({ foreign: false }).execute() - for (let i = 0; i < homeApiVersions.length; i++) { + await DbFederatedCommunity.createQueryBuilder().delete().where({ foreign: false }).execute() + for (const homeApiVersion of homeApiVersions) { const homeCom = DbFederatedCommunity.create() homeCom.foreign = false - homeCom.apiVersion = homeApiVersions[i].api - homeCom.endPoint = homeApiVersions[i].url + homeCom.apiVersion = homeApiVersion.api + homeCom.endPoint = homeApiVersion.url homeCom.publicKey = Buffer.from(pubKey) await DbFederatedCommunity.insert(homeCom) - logger.info( - `federation home-community inserted successfully: ${JSON.stringify(homeApiVersions[i])}`, - ) + logger.info(`federation home-community inserted successfully:`, homeApiVersion) } } catch (err) { throw new Error(`Federation: Error writing federated HomeCommunity-Entries: ${err}`) @@ -233,7 +230,7 @@ export async function writeHomeCommunityEntry(pubKey: string): Promise { homeCom.name = CONFIG.COMMUNITY_NAME homeCom.description = CONFIG.COMMUNITY_DESCRIPTION await DbCommunity.save(homeCom) - logger.info(`home-community updated successfully: ${JSON.stringify(homeCom)}`) + logger.info(`home-community updated successfully:`, homeCom) } else { // insert a new homecommunity entry including a new ID and a new but ensured unique UUID homeCom = new DbCommunity() @@ -245,7 +242,7 @@ export async function writeHomeCommunityEntry(pubKey: string): Promise { homeCom.description = CONFIG.COMMUNITY_DESCRIPTION homeCom.creationDate = new Date() await DbCommunity.insert(homeCom) - logger.info(`home-community inserted successfully: ${JSON.stringify(homeCom)}`) + logger.info(`home-community inserted successfully:`, homeCom) } } catch (err) { throw new Error(`Federation: Error writing HomeCommunity-Entry: ${err}`)