Merge pull request #2957 from gradido/2956-feature-x-com-4-introduce-public-community-info-handshake

feat(federation): x com 4 introduce public community info handshake
This commit is contained in:
clauspeterhuebner 2023-08-18 22:38:33 +02:00 committed by GitHub
commit 04a02d53fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 444 additions and 11 deletions

View File

@ -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<PublicCommunityInfo | undefined> => {
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)
}
}
}

View File

@ -0,0 +1,6 @@
export interface PublicCommunityInfo {
name: string
description: string
creationDate: Date
publicKey: string
}

View File

@ -0,0 +1,12 @@
import { gql } from 'graphql-request'
export const getPublicCommunityInfo = gql`
query {
getPublicCommunityInfo {
name
description
creationDate
publicKey
}
}
`

View File

@ -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<unknown>
})
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<unknown>
})
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', () => {

View File

@ -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<void> {
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<void> {
}
}
}
async function writeForeignCommunity(
dbCom: DbFederatedCommunity,
pubInfo: PublicCommunityInfo,
): Promise<void> {
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)
}
}

View File

@ -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(),
},
],
},
})
})
})
})
})

View File

@ -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) {

View File

@ -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;',
)
}
}

View File

@ -6,7 +6,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
coverageThreshold: {
global: {
lines: 72,
lines: 76,
},
},
setupFiles: ['<rootDir>/test/testSetup.ts'],

View File

@ -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
}

View File

@ -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'),
},
},
})
})
})
})

View File

@ -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<GetPublicCommunityInfoResult> {
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
}
}

View File

@ -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. */