diff --git a/backend/src/federation/client/1_0/FederationClient.ts b/backend/src/federation/client/1_0/FederationClient.ts index ff223b98a..8d915c751 100644 --- a/backend/src/federation/client/1_0/FederationClient.ts +++ b/backend/src/federation/client/1_0/FederationClient.ts @@ -1,9 +1,12 @@ import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' import { GraphQLClient } from 'graphql-request' +import { getPublicCommunityInfo } from '@/federation/client/1_0/query/getPublicCommunityInfo' import { getPublicKey } from '@/federation/client/1_0/query/getPublicKey' import { backendLogger as logger } from '@/server/logger' +import { PublicCommunityInfo } from './model/PublicCommunityInfo' + // eslint-disable-next-line camelcase export class FederationClient { dbCom: DbFederatedCommunity @@ -46,4 +49,27 @@ export class FederationClient { logger.warn('Federation: getPublicKey failed for endpoint', this.endpoint) } } + + getPublicCommunityInfo = async (): Promise => { + logger.debug(`Federation: getPublicCommunityInfo with endpoint='${this.endpoint}'...`) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { data } = await this.client.rawRequest(getPublicCommunityInfo, {}) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!data?.getPublicCommunityInfo?.name) { + logger.warn( + 'Federation: getPublicCommunityInfo without response data from endpoint', + this.endpoint, + ) + return + } + logger.debug(`Federation: getPublicCommunityInfo successful from endpoint=${this.endpoint}`) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + logger.debug(`publicCommunityInfo:`, data.getPublicCommunityInfo) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return data.getPublicCommunityInfo + } catch (err) { + logger.warn('Federation: getPublicCommunityInfo failed for endpoint', this.endpoint) + } + } } diff --git a/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts b/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts new file mode 100644 index 000000000..cad8176be --- /dev/null +++ b/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts @@ -0,0 +1,6 @@ +export interface PublicCommunityInfo { + name: string + description: string + creationDate: Date + publicKey: string +} diff --git a/backend/src/federation/client/1_0/query/getPublicCommunityInfo.ts b/backend/src/federation/client/1_0/query/getPublicCommunityInfo.ts new file mode 100644 index 000000000..f075b2aae --- /dev/null +++ b/backend/src/federation/client/1_0/query/getPublicCommunityInfo.ts @@ -0,0 +1,12 @@ +import { gql } from 'graphql-request' + +export const getPublicCommunityInfo = gql` + query { + getPublicCommunityInfo { + name + description + creationDate + publicKey + } + } +` diff --git a/backend/src/federation/validateCommunities.test.ts b/backend/src/federation/validateCommunities.test.ts index f6b3b3080..68d2433d8 100644 --- a/backend/src/federation/validateCommunities.test.ts +++ b/backend/src/federation/validateCommunities.test.ts @@ -59,6 +59,44 @@ describe('validate Communities', () => { expect(logger.debug).toBeCalledWith(`Federation: found 0 dbCommunities`) }) + describe('with one Community of api 1_0 but missing pubKey response', () => { + beforeEach(async () => { + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(GraphQLClient.prototype, 'rawRequest').mockImplementation(async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { data: {} } as Response + }) + const variables1 = { + publicKey: Buffer.from('11111111111111111111111111111111'), + apiVersion: '1_0', + endPoint: 'http//localhost:5001/api/', + lastAnnouncedAt: new Date(), + } + await DbFederatedCommunity.createQueryBuilder() + .insert() + .into(DbFederatedCommunity) + .values(variables1) + .orUpdate({ + // eslint-disable-next-line camelcase + conflict_target: ['id', 'publicKey', 'apiVersion'], + overwrite: ['end_point', 'last_announced_at'], + }) + .execute() + jest.clearAllMocks() + await validateCommunities() + }) + + it('logs one community found', () => { + expect(logger.debug).toBeCalledWith(`Federation: found 1 dbCommunities`) + }) + it('logs requestGetPublicKey missing response data ', () => { + expect(logger.warn).toBeCalledWith( + 'Federation: getPublicKey without response data from endpoint', + 'http//localhost:5001/api/1_0/', + ) + }) + }) + describe('with one Community of api 1_0 and not matching pubKey', () => { beforeEach(async () => { // eslint-disable-next-line @typescript-eslint/require-await @@ -88,7 +126,37 @@ describe('validate Communities', () => { overwrite: ['end_point', 'last_announced_at'], }) .execute() - + /* + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(GraphQLClient.prototype, 'rawRequest').mockImplementation(async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + data: { + getPublicCommunityInfo: { + name: 'Test-Community', + description: 'Description of Test-Community', + createdAt: 'someDate', + publicKey: 'somePubKey', + }, + }, + } as Response + }) + const variables2 = { + publicKey: Buffer.from('11111111111111111111111111111111'), + apiVersion: '1_0', + endPoint: 'http//localhost:5001/api/', + lastAnnouncedAt: new Date(), + } + await DbCommunity.createQueryBuilder() + .insert() + .into(DbFederatedCommunity) + .values(variables1) + .orUpdate({ + conflict_target: ['id', 'publicKey', 'apiVersion'], + overwrite: ['end_point', 'last_announced_at'], + }) + .execute() + */ jest.clearAllMocks() await validateCommunities() }) @@ -155,10 +223,26 @@ describe('validate Communities', () => { }) it('logs community pubKey verified', () => { expect(logger.debug).toHaveBeenNthCalledWith( - 6, - 'Federation: verified community with', - 'http//localhost:5001/api/', + 5, + 'Federation: getPublicKey successful from endpoint', + 'http//localhost:5001/api/1_0/', + '11111111111111111111111111111111', ) + /* + await expect(DbCommunity.find()).resolves.toContainEqual( + expect.objectContaining({ + foreign: false, + url: 'http://localhost/api', + publicKey: Buffer.from('11111111111111111111111111111111'), + privateKey: expect.any(Buffer), + communityUuid: expect.any(String), + authenticatedAt: expect.any(Date), + name: expect.any(String), + description: expect.any(String), + creationDate: expect.any(Date), + }), + ) + */ }) }) describe('with two Communities of api 1_0 and 1_1', () => { diff --git a/backend/src/federation/validateCommunities.ts b/backend/src/federation/validateCommunities.ts index f94774e53..b76e77bd7 100644 --- a/backend/src/federation/validateCommunities.ts +++ b/backend/src/federation/validateCommunities.ts @@ -1,10 +1,12 @@ -/** eslint-disable @typescript-eslint/no-unsafe-call */ /** eslint-disable @typescript-eslint/no-unsafe-assignment */ +/** eslint-disable @typescript-eslint/no-unsafe-call */ import { IsNull } from '@dbTools/typeorm' +import { Community as DbCommunity } from '@entity/Community' import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' // eslint-disable-next-line camelcase import { FederationClient as V1_0_FederationClient } from '@/federation/client/1_0/FederationClient' +import { PublicCommunityInfo } from '@/federation/client/1_0/model/PublicCommunityInfo' import { FederationClientFactory } from '@/federation/client/FederationClientFactory' import { backendLogger as logger } from '@/server/logger' @@ -48,7 +50,14 @@ export async function validateCommunities(): Promise { const pubKey = await client.getPublicKey() if (pubKey && pubKey === dbCom.publicKey.toString()) { await DbFederatedCommunity.update({ id: dbCom.id }, { verifiedAt: new Date() }) - logger.debug('Federation: verified community with', dbCom.endPoint) + logger.info(`Federation: verified community with:`, dbCom.endPoint) + const pubComInfo = await client.getPublicCommunityInfo() + if (pubComInfo) { + await writeForeignCommunity(dbCom, pubComInfo) + logger.info(`Federation: write publicInfo of community: name=${pubComInfo.name}`) + } else { + logger.warn('Federation: missing result of getPublicCommunityInfo') + } } else { logger.warn( 'Federation: received not matching publicKey:', @@ -62,3 +71,28 @@ export async function validateCommunities(): Promise { } } } + +async function writeForeignCommunity( + dbCom: DbFederatedCommunity, + pubInfo: PublicCommunityInfo, +): Promise { + if (!dbCom || !pubInfo || !(dbCom.publicKey.toString() === pubInfo.publicKey)) { + logger.error( + `Error in writeForeignCommunity: missmatching parameters or publicKey. pubInfo:${JSON.stringify( + pubInfo, + )}`, + ) + } else { + let com = await DbCommunity.findOneBy({ publicKey: dbCom.publicKey }) + if (!com) { + com = DbCommunity.create() + } + com.creationDate = pubInfo.creationDate + com.description = pubInfo.description + com.foreign = true + com.name = pubInfo.name + com.publicKey = dbCom.publicKey + com.url = dbCom.endPoint + await DbCommunity.save(com) + } +} diff --git a/backend/src/graphql/resolver/CommunityResolver.test.ts b/backend/src/graphql/resolver/CommunityResolver.test.ts index 840544e51..4b4101e66 100644 --- a/backend/src/graphql/resolver/CommunityResolver.test.ts +++ b/backend/src/graphql/resolver/CommunityResolver.test.ts @@ -6,12 +6,13 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Connection } from '@dbTools/typeorm' +import { Community as DbCommunity } from '@entity/Community' import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' import { ApolloServerTestClient } from 'apollo-server-testing' -import { testEnvironment } from '@test/helpers' +import { cleanDB, testEnvironment } from '@test/helpers' -import { getCommunities } from '@/seeds/graphql/queries' +import { getCommunities, getCommunitySelections } from '@/seeds/graphql/queries' // to do: We need a setup for the tests that closes the connection let query: ApolloServerTestClient['query'], con: Connection @@ -29,6 +30,7 @@ beforeAll(async () => { }) afterAll(async () => { + await cleanDB() await con.close() }) @@ -55,6 +57,7 @@ describe('CommunityResolver', () => { describe('only home-communities entries', () => { beforeEach(async () => { + await cleanDB() jest.clearAllMocks() homeCom1 = DbFederatedCommunity.create() @@ -230,4 +233,147 @@ describe('CommunityResolver', () => { }) }) }) + + describe('getCommunitySelections', () => { + let homeCom1: DbCommunity + let foreignCom1: DbCommunity + let foreignCom2: DbCommunity + + describe('with empty list', () => { + beforeEach(async () => { + await cleanDB() + jest.clearAllMocks() + }) + + it('returns no community entry', async () => { + // const result: Community[] = await query({ query: getCommunities }) + // expect(result.length).toEqual(0) + await expect(query({ query: getCommunitySelections })).resolves.toMatchObject({ + data: { + getCommunitySelections: [], + }, + }) + }) + }) + + describe('with one home-community entry', () => { + beforeEach(async () => { + await cleanDB() + jest.clearAllMocks() + + homeCom1 = DbCommunity.create() + homeCom1.foreign = false + homeCom1.url = 'http://localhost/api' + homeCom1.publicKey = Buffer.from('publicKey-HomeCommunity') + homeCom1.privateKey = Buffer.from('privateKey-HomeCommunity') + homeCom1.communityUuid = 'HomeCom-UUID' + homeCom1.authenticatedAt = new Date() + homeCom1.name = 'HomeCommunity-name' + homeCom1.description = 'HomeCommunity-description' + homeCom1.creationDate = new Date() + await DbCommunity.insert(homeCom1) + }) + + it('returns 1 home-community entry', async () => { + await expect(query({ query: getCommunitySelections })).resolves.toMatchObject({ + data: { + getCommunitySelections: [ + { + id: expect.any(Number), + foreign: homeCom1.foreign, + name: homeCom1.name, + description: homeCom1.description, + url: homeCom1.url, + creationDate: homeCom1.creationDate?.toISOString(), + uuid: homeCom1.communityUuid, + authenticatedAt: homeCom1.authenticatedAt?.toISOString(), + }, + ], + }, + }) + }) + }) + + describe('with several community entries', () => { + beforeEach(async () => { + await cleanDB() + jest.clearAllMocks() + + homeCom1 = DbCommunity.create() + homeCom1.foreign = false + homeCom1.url = 'http://localhost/api' + homeCom1.publicKey = Buffer.from('publicKey-HomeCommunity') + homeCom1.privateKey = Buffer.from('privateKey-HomeCommunity') + homeCom1.communityUuid = 'HomeCom-UUID' + homeCom1.authenticatedAt = new Date() + homeCom1.name = 'HomeCommunity-name' + homeCom1.description = 'HomeCommunity-description' + homeCom1.creationDate = new Date() + await DbCommunity.insert(homeCom1) + + foreignCom1 = DbCommunity.create() + foreignCom1.foreign = true + foreignCom1.url = 'http://stage-2.gradido.net/api' + foreignCom1.publicKey = Buffer.from('publicKey-stage-2_Community') + foreignCom1.privateKey = Buffer.from('privateKey-stage-2_Community') + foreignCom1.communityUuid = 'Stage2-Com-UUID' + foreignCom1.authenticatedAt = new Date() + foreignCom1.name = 'Stage-2_Community-name' + foreignCom1.description = 'Stage-2_Community-description' + foreignCom1.creationDate = new Date() + await DbCommunity.insert(foreignCom1) + + foreignCom2 = DbCommunity.create() + foreignCom2.foreign = true + foreignCom2.url = 'http://stage-3.gradido.net/api' + foreignCom2.publicKey = Buffer.from('publicKey-stage-3_Community') + foreignCom2.privateKey = Buffer.from('privateKey-stage-3_Community') + foreignCom2.communityUuid = 'Stage3-Com-UUID' + foreignCom2.authenticatedAt = new Date() + foreignCom2.name = 'Stage-3_Community-name' + foreignCom2.description = 'Stage-3_Community-description' + foreignCom2.creationDate = new Date() + await DbCommunity.insert(foreignCom2) + }) + + it('returns 3 community entries', async () => { + await expect(query({ query: getCommunitySelections })).resolves.toMatchObject({ + data: { + getCommunitySelections: [ + { + id: expect.any(Number), + foreign: homeCom1.foreign, + name: homeCom1.name, + description: homeCom1.description, + url: homeCom1.url, + creationDate: homeCom1.creationDate?.toISOString(), + uuid: homeCom1.communityUuid, + authenticatedAt: homeCom1.authenticatedAt?.toISOString(), + }, + { + id: expect.any(Number), + foreign: foreignCom1.foreign, + name: foreignCom1.name, + description: foreignCom1.description, + url: foreignCom1.url, + creationDate: foreignCom1.creationDate?.toISOString(), + uuid: foreignCom1.communityUuid, + authenticatedAt: foreignCom1.authenticatedAt?.toISOString(), + }, + { + id: expect.any(Number), + foreign: foreignCom2.foreign, + name: foreignCom2.name, + description: foreignCom2.description, + url: foreignCom2.url, + creationDate: foreignCom2.creationDate?.toISOString(), + uuid: foreignCom2.communityUuid, + authenticatedAt: foreignCom2.authenticatedAt?.toISOString(), + }, + ], + }, + }) + }) + }) + }) }) diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index f016102a2..3dda2633c 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -157,6 +157,21 @@ export const getCommunities = gql` } ` +export const getCommunitySelections = gql` + query { + getCommunitySelections { + id + foreign + name + description + url + creationDate + uuid + authenticatedAt + } + } +` + export const queryTransactionLink = gql` query ($code: String!) { queryTransactionLink(code: $code) { diff --git a/database/migrations/0068-community_tables_public_key_length.ts b/database/migrations/0068-community_tables_public_key_length.ts index 22c04e850..d5d047c26 100644 --- a/database/migrations/0068-community_tables_public_key_length.ts +++ b/database/migrations/0068-community_tables_public_key_length.ts @@ -39,4 +39,4 @@ export async function downgrade(queryFn: (query: string, values?: any[]) => Prom await queryFn( 'ALTER TABLE `communities` MODIFY COLUMN `public_key` binary(64) NULL DEFAULT NULL;', ) -} \ No newline at end of file +} diff --git a/federation/jest.config.js b/federation/jest.config.js index f055d66e2..25ff58fb3 100644 --- a/federation/jest.config.js +++ b/federation/jest.config.js @@ -6,7 +6,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 72, + lines: 76, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts b/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts new file mode 100644 index 000000000..86ea480df --- /dev/null +++ b/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts @@ -0,0 +1,27 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Community as DbCommunity } from '@entity/Community' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Field, ObjectType } from 'type-graphql' + +@ObjectType() +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class GetPublicCommunityInfoResult { + constructor(dbCom: DbCommunity) { + this.publicKey = dbCom.publicKey.toString() + this.name = dbCom.name + this.description = dbCom.description + this.creationDate = dbCom.creationDate + } + + @Field(() => String) + name: string | null + + @Field(() => String) + description: string | null + + @Field(() => Date) + creationDate: Date | null + + @Field(() => String) + publicKey: string +} diff --git a/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.test.ts b/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.test.ts new file mode 100644 index 000000000..d18a30a7c --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { createTestClient } from 'apollo-server-testing' +import createServer from '@/server/createServer' +import { Community as DbCommunity } from '@entity/Community' +import CONFIG from '@/config' + +let query: any + +// to do: We need a setup for the tests that closes the connection +let con: any + +CONFIG.FEDERATION_API = '1_0' + +beforeAll(async () => { + const server = await createServer() + con = server.con + query = createTestClient(server.apollo).query + DbCommunity.clear() +}) + +afterAll(async () => { + await con.close() +}) + +describe('PublicCommunityInfoResolver', () => { + const getPublicCommunityInfoQuery = ` + query { + getPublicCommunityInfo + { + name + description + creationDate + publicKey + } + } + ` + + describe('getPublicCommunityInfo', () => { + let homeCom: DbCommunity + beforeEach(async () => { + homeCom = new DbCommunity() + homeCom.foreign = false + homeCom.url = 'homeCommunity-url' + homeCom.name = 'Community-Name' + homeCom.description = 'Community-Description' + homeCom.creationDate = new Date() + homeCom.publicKey = Buffer.from('homeCommunity-publicKey') + await DbCommunity.insert(homeCom) + }) + + it('returns public CommunityInfo', async () => { + await expect(query({ query: getPublicCommunityInfoQuery })).resolves.toMatchObject({ + data: { + getPublicCommunityInfo: { + name: 'Community-Name', + description: 'Community-Description', + creationDate: homeCom.creationDate?.toISOString(), + publicKey: expect.stringMatching('homeCommunity-publicKey'), + }, + }, + }) + }) + }) +}) diff --git a/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.ts b/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.ts new file mode 100644 index 000000000..3076edd41 --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/PublicCommunityInfoResolver.ts @@ -0,0 +1,18 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Query, Resolver } from 'type-graphql' +import { federationLogger as logger } from '@/server/logger' +import { Community as DbCommunity } from '@entity/Community' +import { GetPublicCommunityInfoResult } from '../model/GetPublicCommunityInfoResult' + +@Resolver() +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class PublicCommunityInfoResolver { + @Query(() => GetPublicCommunityInfoResult) + async getPublicCommunityInfo(): Promise { + logger.debug(`getPublicCommunityInfo() via apiVersion=1_0 ...`) + const homeCom = await DbCommunity.findOneByOrFail({ foreign: false }) + const result = new GetPublicCommunityInfoResult(homeCom) + logger.info(`getPublicCommunityInfo()-1_0... return publicInfo=${JSON.stringify(result)}`) + return result + } +} diff --git a/federation/tsconfig.json b/federation/tsconfig.json index b38c43ba1..2326786ac 100644 --- a/federation/tsconfig.json +++ b/federation/tsconfig.json @@ -4,7 +4,7 @@ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ + "target": "esNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */