From bf33cf9341fb3a3fa2bcb1de959d5baff4b2d7db Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 30 Sep 2025 13:39:35 +0200 Subject: [PATCH 01/13] refactor community, rights and allow input community/user in frontend gdd send form --- admin/src/graphql/updateHomeCommunity.js | 2 +- backend/src/auth/ADMIN_RIGHTS.ts | 4 +- backend/src/auth/DLT_CONNECTOR_RIGHTS.ts | 2 +- backend/src/auth/RIGHTS.ts | 5 +- backend/src/auth/USER_RIGHTS.ts | 1 + .../client/FederationClientFactory.ts | 2 +- backend/src/graphql/arg/UserArgs.ts | 4 +- .../src/graphql/model/AdminCommunityView.ts | 1 - backend/src/graphql/model/Community.ts | 7 +- .../resolver/CommunityResolver.test.ts | 617 +++++++----------- .../src/graphql/resolver/CommunityResolver.ts | 63 +- .../resolver/TransactionLinkResolver.ts | 3 +- .../graphql/resolver/TransactionResolver.ts | 4 +- backend/src/graphql/resolver/UserResolver.ts | 6 + backend/src/seeds/graphql/mutations.ts | 1 - backend/src/seeds/graphql/queries.ts | 33 +- backend/test/helpers.ts | 6 +- database/src/queries/communities.test.ts | 33 +- database/src/queries/communities.ts | 41 ++ .../src/queries/pendingTransactions.test.ts | 2 +- database/src/queries/user.test.ts | 2 +- database/src/queries/user.ts | 15 +- database/src/seeds/community.ts | 34 + database/src/seeds/homeCommunity.ts | 26 - frontend/src/components/CommunitySwitch.vue | 11 +- .../GddSend/TransactionForm.spec.js | 7 + .../components/GddSend/TransactionForm.vue | 41 +- frontend/src/graphql/communities.graphql | 9 + frontend/src/graphql/queries.js | 13 - frontend/src/graphql/user.graphql | 8 +- frontend/src/locales/de.json | 1 + frontend/src/locales/en.json | 1 + frontend/src/locales/es.json | 5 + frontend/src/locales/fr.json | 7 + frontend/src/locales/nl.json | 5 + frontend/src/validationSchemas.js | 17 +- 36 files changed, 492 insertions(+), 547 deletions(-) create mode 100644 database/src/seeds/community.ts delete mode 100644 database/src/seeds/homeCommunity.ts create mode 100644 frontend/src/graphql/communities.graphql diff --git a/admin/src/graphql/updateHomeCommunity.js b/admin/src/graphql/updateHomeCommunity.js index 19bfb7396..036db91e5 100644 --- a/admin/src/graphql/updateHomeCommunity.js +++ b/admin/src/graphql/updateHomeCommunity.js @@ -8,7 +8,7 @@ export const updateHomeCommunity = gql` location: $location hieroTopicId: $hieroTopicId ) { - id + uuid } } ` diff --git a/backend/src/auth/ADMIN_RIGHTS.ts b/backend/src/auth/ADMIN_RIGHTS.ts index 9ba3e7ccd..69100d7d2 100644 --- a/backend/src/auth/ADMIN_RIGHTS.ts +++ b/backend/src/auth/ADMIN_RIGHTS.ts @@ -5,8 +5,6 @@ export const ADMIN_RIGHTS = [ RIGHTS.DELETE_USER, RIGHTS.UNDELETE_USER, RIGHTS.COMMUNITY_UPDATE, - RIGHTS.COMMUNITY_BY_UUID, - RIGHTS.COMMUNITY_BY_IDENTIFIER, - RIGHTS.HOME_COMMUNITY, + RIGHTS.COMMUNITY_WITH_API_KEYS, RIGHTS.PROJECT_BRANDING_MUTATE, ] diff --git a/backend/src/auth/DLT_CONNECTOR_RIGHTS.ts b/backend/src/auth/DLT_CONNECTOR_RIGHTS.ts index 399b7c2d4..9b4c56eaa 100644 --- a/backend/src/auth/DLT_CONNECTOR_RIGHTS.ts +++ b/backend/src/auth/DLT_CONNECTOR_RIGHTS.ts @@ -1,3 +1,3 @@ import { RIGHTS } from './RIGHTS' -export const DLT_CONNECTOR_RIGHTS = [RIGHTS.COMMUNITY_BY_IDENTIFIER, RIGHTS.HOME_COMMUNITY] +export const DLT_CONNECTOR_RIGHTS = [RIGHTS.COMMUNITIES, RIGHTS.COMMUNITY_UPDATE] diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 012a4e627..30027086f 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -35,6 +35,7 @@ export enum RIGHTS { UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION', LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS', COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS', + COMMUNITY_STATUS = 'COMMUNITY_STATUS', SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS', CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE', LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES', @@ -69,9 +70,7 @@ export enum RIGHTS { SET_USER_ROLE = 'SET_USER_ROLE', DELETE_USER = 'DELETE_USER', UNDELETE_USER = 'UNDELETE_USER', - COMMUNITY_BY_UUID = 'COMMUNITY_BY_UUID', - COMMUNITY_BY_IDENTIFIER = 'COMMUNITY_BY_IDENTIFIER', - HOME_COMMUNITY = 'HOME_COMMUNITY', COMMUNITY_UPDATE = 'COMMUNITY_UPDATE', + COMMUNITY_WITH_API_KEYS = 'COMMUNITY_WITH_API_KEYS', PROJECT_BRANDING_MUTATE = 'PROJECT_BRANDING_MUTATE', } diff --git a/backend/src/auth/USER_RIGHTS.ts b/backend/src/auth/USER_RIGHTS.ts index 3f08d1160..83ce9d871 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -26,6 +26,7 @@ export const USER_RIGHTS = [ RIGHTS.SEARCH_ADMIN_USERS, RIGHTS.LIST_CONTRIBUTION_LINKS, RIGHTS.COMMUNITY_STATISTICS, + RIGHTS.COMMUNITY_STATUS, RIGHTS.CREATE_CONTRIBUTION_MESSAGE, RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES, RIGHTS.OPEN_CREATIONS, diff --git a/backend/src/federation/client/FederationClientFactory.ts b/backend/src/federation/client/FederationClientFactory.ts index 926c3c180..87794882d 100644 --- a/backend/src/federation/client/FederationClientFactory.ts +++ b/backend/src/federation/client/FederationClientFactory.ts @@ -4,7 +4,7 @@ import { FederationClient as V1_0_FederationClient } from '@/federation/client/1 import { FederationClient as V1_1_FederationClient } from '@/federation/client/1_1/FederationClient' import { ApiVersionType, ensureUrlEndsWithSlash } from 'core' -type FederationClient = V1_0_FederationClient | V1_1_FederationClient +export type FederationClient = V1_0_FederationClient | V1_1_FederationClient interface FederationClientInstance { id: number diff --git a/backend/src/graphql/arg/UserArgs.ts b/backend/src/graphql/arg/UserArgs.ts index 406be14cb..681d3ce51 100644 --- a/backend/src/graphql/arg/UserArgs.ts +++ b/backend/src/graphql/arg/UserArgs.ts @@ -7,7 +7,7 @@ export class UserArgs { @IsString() identifier: string - @Field() + @Field({ nullable: true }) @IsString() - communityIdentifier: string + communityIdentifier?: string } diff --git a/backend/src/graphql/model/AdminCommunityView.ts b/backend/src/graphql/model/AdminCommunityView.ts index 50cee146a..8a685fa86 100644 --- a/backend/src/graphql/model/AdminCommunityView.ts +++ b/backend/src/graphql/model/AdminCommunityView.ts @@ -38,7 +38,6 @@ export class AdminCommunityView { this.updatedAt = dbCom.updatedAt this.uuid = dbCom.communityUuid this.authenticatedAt = dbCom.authenticatedAt - this.gmsApiKey = dbCom.gmsApiKey this.hieroTopicId = dbCom.hieroTopicId if (dbCom.location) { this.location = Point2Location(dbCom.location as Point) diff --git a/backend/src/graphql/model/Community.ts b/backend/src/graphql/model/Community.ts index 62cec9cf7..3163b886d 100644 --- a/backend/src/graphql/model/Community.ts +++ b/backend/src/graphql/model/Community.ts @@ -12,7 +12,7 @@ export class Community { this.creationDate = dbCom.creationDate this.uuid = dbCom.communityUuid this.authenticatedAt = dbCom.authenticatedAt - this.gmsApiKey = dbCom.gmsApiKey + // this.gmsApiKey = dbCom.gmsApiKey // this.hieroTopicId = dbCom.hieroTopicId } @@ -40,8 +40,9 @@ export class Community { @Field(() => Date, { nullable: true }) authenticatedAt: Date | null - @Field(() => String, { nullable: true }) - gmsApiKey: string | null + // gms api key should only seen by admins, they can use AdminCommunityView + // @Field(() => String, { nullable: true }) + // gmsApiKey: string | null @Field(() => String, { nullable: true }) hieroTopicId: string | null diff --git a/backend/src/graphql/resolver/CommunityResolver.test.ts b/backend/src/graphql/resolver/CommunityResolver.test.ts index c9c925a2e..b65cb65c0 100644 --- a/backend/src/graphql/resolver/CommunityResolver.test.ts +++ b/backend/src/graphql/resolver/CommunityResolver.test.ts @@ -1,5 +1,5 @@ import { ApolloServerTestClient } from 'apollo-server-testing' -import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' +import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity } from 'database' import { GraphQLError } from 'graphql/error/GraphQLError' import { DataSource } from 'typeorm' import { v4 as uuidv4 } from 'uuid' @@ -10,19 +10,21 @@ import { i18n as localization } from '@test/testSetup' import { userFactory } from '@/seeds/factory/user' import { login, updateHomeCommunityQuery } from '@/seeds/graphql/mutations' import { - allCommunities, - communitiesQuery, - getCommunities, + allCommunities, getCommunityByIdentifierQuery, getHomeCommunityQuery, + reachableCommunities, } from '@/seeds/graphql/queries' import { peterLustig } from '@/seeds/users/peter-lustig' +import { createCommunity, createAuthenticatedForeignCommunity } from 'database/src/seeds/community' import { getLogger } from 'config-schema/test/testSetup' import { getCommunityByUuid } from './util/communities' +import { CONFIG } from '@/config' jest.mock('@/password/EncryptorUtils') +CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER = 1000 // to do: We need a setup for the tests that closes the connection let mutate: ApolloServerTestClient['mutate'] @@ -46,11 +48,10 @@ beforeAll(async () => { mutate = testEnv.mutate query = testEnv.query con = testEnv.con - await DbFederatedCommunity.clear() + await cleanDB() }) afterAll(async () => { - await cleanDB() await con.destroy() }) @@ -109,31 +110,38 @@ const ed25519KeyPairStaticHex = [ ] describe('CommunityResolver', () => { - describe('getCommunities', () => { + describe('allCommunities for admin', () => { let homeCom1: DbFederatedCommunity let homeCom2: DbFederatedCommunity let homeCom3: DbFederatedCommunity let foreignCom1: DbFederatedCommunity let foreignCom2: DbFederatedCommunity - let foreignCom3: DbFederatedCommunity + let foreignCom3: DbFederatedCommunity + + beforeAll(async () => { + // create admin and login as admin + await userFactory(testEnv, peterLustig) + await mutate({ mutation: login, variables: peterLoginData }) + }) + + afterAll(async () => { + await cleanDB() + }) describe('with empty list', () => { it('returns no community entry', async () => { // const result: Community[] = await query({ query: getCommunities }) // expect(result.length).toEqual(0) - await expect(query({ query: getCommunities })).resolves.toMatchObject({ + await expect(query({ query: allCommunities })).resolves.toMatchObject({ data: { - getCommunities: [], + allCommunities: [], }, }) }) }) - describe('only home-communities entries', () => { + describe('only home-community entries (different apis)', () => { beforeEach(async () => { - await cleanDB() - jest.clearAllMocks() - homeCom1 = DbFederatedCommunity.create() homeCom1.foreign = false homeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex') @@ -144,7 +152,7 @@ describe('CommunityResolver', () => { homeCom2 = DbFederatedCommunity.create() homeCom2.foreign = false - homeCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[1].public, 'hex') + homeCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex') homeCom2.apiVersion = '1_1' homeCom2.endPoint = 'http://localhost/api' homeCom2.createdAt = new Date() @@ -152,170 +160,67 @@ describe('CommunityResolver', () => { homeCom3 = DbFederatedCommunity.create() homeCom3.foreign = false - homeCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[2].public, 'hex') + homeCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex') homeCom3.apiVersion = '2_0' homeCom3.endPoint = 'http://localhost/api' homeCom3.createdAt = new Date() await DbFederatedCommunity.insert(homeCom3) }) - it('returns 3 home-community entries', async () => { - await expect(query({ query: getCommunities })).resolves.toMatchObject({ + it('returns only home-community entries', async () => { + await expect(query({ query: allCommunities })).resolves.toMatchObject({ data: { - getCommunities: [ + allCommunities: [ { - id: 3, - foreign: homeCom3.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '2_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom3.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 2, - foreign: homeCom2.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '1_1', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom2.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 1, - foreign: homeCom1.foreign, + foreign: false, + url: 'http://localhost', publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '1_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom1.createdAt.toISOString(), + authenticatedAt: null, + createdAt: null, + creationDate: null, + description: null, + gmsApiKey: null, + name: null, updatedAt: null, + uuid: null, + federatedCommunities: [ + { + id: 3, + apiVersion: '2_0', + endPoint: 'http://localhost/api/', + createdAt: homeCom3.createdAt.toISOString(), + lastAnnouncedAt: null, + lastErrorAt: null, + updatedAt: null, + verifiedAt: null, + }, + { + id: 2, + apiVersion: '1_1', + endPoint: 'http://localhost/api/', + createdAt: homeCom2.createdAt.toISOString(), + lastAnnouncedAt: null, + lastErrorAt: null, + updatedAt: null, + verifiedAt: null, + }, + { + id: 1, + apiVersion: '1_0', + endPoint: 'http://localhost/api/', + createdAt: homeCom1.createdAt.toISOString(), + lastAnnouncedAt: null, + lastErrorAt: null, + updatedAt: null, + verifiedAt: null, + }, + ], }, ], }, }) }) }) - - describe('plus foreign-communities entries', () => { - beforeEach(async () => { - jest.clearAllMocks() - - foreignCom1 = DbFederatedCommunity.create() - foreignCom1.foreign = true - foreignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex') - foreignCom1.apiVersion = '1_0' - foreignCom1.endPoint = 'http://remotehost/api' - foreignCom1.createdAt = new Date() - await DbFederatedCommunity.insert(foreignCom1) - - foreignCom2 = DbFederatedCommunity.create() - foreignCom2.foreign = true - foreignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex') - foreignCom2.apiVersion = '1_1' - foreignCom2.endPoint = 'http://remotehost/api' - foreignCom2.createdAt = new Date() - await DbFederatedCommunity.insert(foreignCom2) - - foreignCom3 = DbFederatedCommunity.create() - foreignCom3.foreign = true - foreignCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex') - foreignCom3.apiVersion = '2_0' - foreignCom3.endPoint = 'http://remotehost/api' - foreignCom3.createdAt = new Date() - await DbFederatedCommunity.insert(foreignCom3) - }) - - it('returns 3 home community and 3 foreign community entries', async () => { - await expect(query({ query: getCommunities })).resolves.toMatchObject({ - data: { - getCommunities: [ - { - id: 3, - foreign: homeCom3.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '2_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom3.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 2, - foreign: homeCom2.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '1_1', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom2.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 1, - foreign: homeCom1.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public), - endPoint: expect.stringMatching('http://localhost/api/'), - apiVersion: '1_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: homeCom1.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 6, - foreign: foreignCom3.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[5].public), - endPoint: expect.stringMatching('http://remotehost/api/'), - apiVersion: '2_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: foreignCom3.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 5, - foreign: foreignCom2.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[4].public), - endPoint: expect.stringMatching('http://remotehost/api/'), - apiVersion: '1_1', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: foreignCom2.createdAt.toISOString(), - updatedAt: null, - }, - { - id: 4, - foreign: foreignCom1.foreign, - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[3].public), - endPoint: expect.stringMatching('http://remotehost/api/'), - apiVersion: '1_0', - lastAnnouncedAt: null, - verifiedAt: null, - lastErrorAt: null, - createdAt: foreignCom1.createdAt.toISOString(), - updatedAt: null, - }, - ], - }, - }) - }) - }) - describe('with 6 federated community entries', () => { let comHomeCom1: DbCommunity let comForeignCom1: DbCommunity @@ -323,7 +228,6 @@ describe('CommunityResolver', () => { let foreignCom4: DbFederatedCommunity beforeEach(async () => { - jest.clearAllMocks() comHomeCom1 = DbCommunity.create() comHomeCom1.foreign = false comHomeCom1.url = 'http://localhost' @@ -360,6 +264,30 @@ describe('CommunityResolver', () => { comForeignCom2.creationDate = new Date() await DbCommunity.insert(comForeignCom2) + foreignCom1 = DbFederatedCommunity.create() + foreignCom1.foreign = true + foreignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex') + foreignCom1.apiVersion = '1_0' + foreignCom1.endPoint = 'http://remotehost/api' + foreignCom1.createdAt = new Date() + await DbFederatedCommunity.insert(foreignCom1) + + foreignCom2 = DbFederatedCommunity.create() + foreignCom2.foreign = true + foreignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex') + foreignCom2.apiVersion = '1_1' + foreignCom2.endPoint = 'http://remotehost/api' + foreignCom2.createdAt = new Date() + await DbFederatedCommunity.insert(foreignCom2) + + foreignCom3 = DbFederatedCommunity.create() + foreignCom3.foreign = true + foreignCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex') + foreignCom3.apiVersion = '2_0' + foreignCom3.endPoint = 'http://remotehost/api' + foreignCom3.createdAt = new Date() + await DbFederatedCommunity.insert(foreignCom3) + foreignCom4 = DbFederatedCommunity.create() foreignCom4.foreign = true foreignCom4.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex') @@ -376,15 +304,15 @@ describe('CommunityResolver', () => { { foreign: false, url: 'http://localhost', - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public), - authenticatedAt: null, - createdAt: null, - creationDate: null, - description: null, + publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public), + authenticatedAt: comHomeCom1.authenticatedAt?.toISOString(), + createdAt: comHomeCom1.createdAt.toISOString(), + creationDate: comHomeCom1.creationDate?.toISOString(), + description: comHomeCom1.description, gmsApiKey: null, - name: null, + name: comHomeCom1.name, updatedAt: null, - uuid: null, + uuid: comHomeCom1.communityUuid, federatedCommunities: [ { id: 3, @@ -396,21 +324,6 @@ describe('CommunityResolver', () => { updatedAt: null, verifiedAt: null, }, - ], - }, - { - foreign: false, - url: 'http://localhost', - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public), - authenticatedAt: null, - createdAt: null, - creationDate: null, - description: null, - gmsApiKey: null, - name: null, - updatedAt: null, - uuid: null, - federatedCommunities: [ { id: 2, apiVersion: '1_1', @@ -421,21 +334,6 @@ describe('CommunityResolver', () => { updatedAt: null, verifiedAt: null, }, - ], - }, - { - foreign: false, - url: 'http://localhost', - publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public), - authenticatedAt: comHomeCom1.authenticatedAt?.toISOString(), - createdAt: comHomeCom1.createdAt.toISOString(), - creationDate: comHomeCom1.creationDate?.toISOString(), - description: comHomeCom1.description, - gmsApiKey: null, - name: comHomeCom1.name, - updatedAt: null, - uuid: comHomeCom1.communityUuid, - federatedCommunities: [ { id: 1, apiVersion: '1_0', @@ -540,23 +438,20 @@ describe('CommunityResolver', () => { }) }) - describe('communities', () => { + describe('reachableCommunities', () => { let homeCom1: DbCommunity let foreignCom1: DbCommunity let foreignCom2: DbCommunity - describe('with empty list', () => { - beforeEach(async () => { - await cleanDB() - jest.clearAllMocks() - }) + afterAll(async () => { + await DbCommunity.clear() + }) + describe('with empty list', () => { it('returns no community entry', async () => { - // const result: Community[] = await query({ query: getCommunities }) - // expect(result.length).toEqual(0) - await expect(query({ query: communitiesQuery })).resolves.toMatchObject({ + await expect(query({ query: reachableCommunities })).resolves.toMatchObject({ data: { - communities: [], + reachableCommunities: [], }, }) }) @@ -564,35 +459,20 @@ describe('CommunityResolver', () => { 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(ed25519KeyPairStaticHex[0].public, 'hex') - homeCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[0].private, 'hex') - homeCom1.communityUuid = 'HomeCom-UUID' - homeCom1.authenticatedAt = new Date() - homeCom1.name = 'HomeCommunity-name' - homeCom1.description = 'HomeCommunity-description' - homeCom1.creationDate = new Date() + homeCom1 = await createCommunity(false, false) await DbCommunity.insert(homeCom1) }) it('returns 1 home-community entry', async () => { - await expect(query({ query: communitiesQuery })).resolves.toMatchObject({ + await expect(query({ query: reachableCommunities })).resolves.toMatchObject({ data: { - communities: [ + reachableCommunities: [ { - 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(), }, ], }, @@ -602,81 +482,30 @@ describe('CommunityResolver', () => { describe('returns 2 filtered communities even with 3 existing entries', () => { beforeEach(async () => { - await cleanDB() - jest.clearAllMocks() - - homeCom1 = DbCommunity.create() - homeCom1.foreign = false - homeCom1.url = 'http://localhost/api' - homeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex') - homeCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[0].private, 'hex') - 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(ed25519KeyPairStaticHex[3].public, 'hex') - foreignCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[3].private, 'hex') - // 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(ed25519KeyPairStaticHex[4].public, 'hex') - foreignCom2.privateKey = Buffer.from(ed25519KeyPairStaticHex[4].private, 'hex') - 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) + foreignCom1 = await createCommunity(true, false) + foreignCom2 = await createAuthenticatedForeignCommunity(100, false) + await Promise.all([ + DbCommunity.insert(foreignCom1), + DbCommunity.insert(foreignCom2) + ]) }) it('returns 2 community entries', async () => { - await expect(query({ query: communitiesQuery })).resolves.toMatchObject({ + await expect(query({ query: reachableCommunities })).resolves.toMatchObject({ data: { - communities: [ + reachableCommunities: [ { - 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(), }, ], }, @@ -686,51 +515,18 @@ describe('CommunityResolver', () => { describe('search community by uuid', () => { let homeCom: DbCommunity | null - beforeEach(async () => { - await cleanDB() - jest.clearAllMocks() - const admin = await userFactory(testEnv, peterLustig) - // login as admin - await mutate({ mutation: login, variables: peterLoginData }) + beforeAll(async () => { + await DbCommunity.clear() - // HomeCommunity is still created in userFactory - homeCom = await getCommunityByUuid(admin.communityUuid) + homeCom = await createCommunity(false, false) + foreignCom1 = await createCommunity(true, false) + foreignCom2 = await createCommunity(true, false) - foreignCom1 = DbCommunity.create() - foreignCom1.foreign = true - foreignCom1.url = 'http://stage-2.gradido.net/api' - foreignCom1.publicKey = Buffer.from( - '8a1f9374b99c30d827b85dcd23f7e50328430d64ef65ef35bf375ea8eb9a2e1d', - 'hex', - ) - foreignCom1.privateKey = Buffer.from( - 'f6c2a9d78e20a3c910f35b8ffcf824aa7b37f0d3d81bfc4c0e65e17a194b3a4a', - 'hex', - ) - // 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( - 'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f', - 'hex', - ) - foreignCom2.privateKey = Buffer.from( - 'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f', - 'hex', - ) - foreignCom2.communityUuid = uuidv4() - foreignCom2.authenticatedAt = new Date() - foreignCom2.name = 'Stage-3_Community-name' - foreignCom2.description = 'Stage-3_Community-description' - foreignCom2.creationDate = new Date() - await DbCommunity.insert(foreignCom2) + await Promise.all([ + DbCommunity.insert(homeCom), + DbCommunity.insert(foreignCom1), + DbCommunity.insert(foreignCom2), + ]) }) it('finds the home-community by uuid', async () => { @@ -749,7 +545,6 @@ describe('CommunityResolver', () => { url: homeCom?.url, creationDate: homeCom?.creationDate?.toISOString(), uuid: homeCom?.communityUuid, - authenticatedAt: homeCom?.authenticatedAt, }, }, }) @@ -769,76 +564,100 @@ describe('CommunityResolver', () => { description: homeCom?.description, url: homeCom?.url, creationDate: homeCom?.creationDate?.toISOString(), - uuid: homeCom?.communityUuid, - authenticatedAt: homeCom?.authenticatedAt, + uuid: homeCom?.communityUuid }, }, }) }) - - it('updates the home-community gmsApiKey', async () => { - await expect( - mutate({ - mutation: updateHomeCommunityQuery, - variables: { uuid: homeCom?.communityUuid, gmsApiKey: 'gmsApiKey' }, - }), - ).resolves.toMatchObject({ - data: { - updateHomeCommunity: { - id: expect.any(Number), - foreign: homeCom?.foreign, - name: homeCom?.name, - description: homeCom?.description, - url: homeCom?.url, - creationDate: homeCom?.creationDate?.toISOString(), - uuid: homeCom?.communityUuid, - authenticatedAt: homeCom?.authenticatedAt, - gmsApiKey: 'gmsApiKey', - }, - }, - }) - }) - - it('throws error on updating a foreign-community', async () => { - expect( - await mutate({ - mutation: updateHomeCommunityQuery, - variables: { uuid: foreignCom2.communityUuid, gmsApiKey: 'gmsApiKey' }, - }), - ).toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Error: Only the HomeCommunity could be modified!')], - }), - ) - }) - - it('throws error on updating a community without uuid', async () => { - expect( - await mutate({ - mutation: updateHomeCommunityQuery, - variables: { uuid: null, gmsApiKey: 'gmsApiKey' }, - }), - ).toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError(`Variable "$uuid" of non-null type "String!" must not be null.`), - ], - }), - ) - }) - - it('throws error on updating a community with not existing uuid', async () => { - expect( - await mutate({ - mutation: updateHomeCommunityQuery, - variables: { uuid: uuidv4(), gmsApiKey: 'gmsApiKey' }, - }), - ).toEqual( - expect.objectContaining({ - errors: [new GraphQLError('HomeCommunity with uuid not found: ')], - }), - ) - }) + }) + }) + + describe('update community', () => { + let homeCom: DbCommunity + let foreignCom1: DbCommunity + let foreignCom2: DbCommunity + + beforeAll(async () => { + await DbCommunity.clear() + + // create admin and login as admin + await userFactory(testEnv, peterLustig) + homeCom = (await getHomeCommunity())! + foreignCom1 = await createCommunity(true, false) + foreignCom2 = await createCommunity(true, false) + + await Promise.all([ + DbCommunity.insert(foreignCom1), + DbCommunity.insert(foreignCom2), + mutate({ mutation: login, variables: peterLoginData }) + ]) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('updates the home-community gmsApiKey', async () => { + await expect( + mutate({ + mutation: updateHomeCommunityQuery, + variables: { uuid: homeCom?.communityUuid, gmsApiKey: 'gmsApiKey' }, + }), + ).resolves.toMatchObject({ + data: { + updateHomeCommunity: { + foreign: homeCom?.foreign, + name: homeCom?.name, + description: homeCom?.description, + url: homeCom?.url, + creationDate: homeCom?.creationDate?.toISOString(), + uuid: homeCom?.communityUuid, + authenticatedAt: homeCom?.authenticatedAt, + gmsApiKey: 'gmsApiKey', + }, + }, + }) + }) + + it('throws error on updating a foreign-community', async () => { + expect( + await mutate({ + mutation: updateHomeCommunityQuery, + variables: { uuid: foreignCom2.communityUuid, gmsApiKey: 'gmsApiKey' }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Error: Only the HomeCommunity could be modified!')], + }), + ) + }) + + it('throws error on updating a community without uuid', async () => { + expect( + await mutate({ + mutation: updateHomeCommunityQuery, + variables: { uuid: null, gmsApiKey: 'gmsApiKey' }, + }), + ).toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError(`Variable "$uuid" of non-null type "String!" must not be null.`), + ], + }), + ) + }) + + it('throws error on updating a community with not existing uuid', async () => { + expect( + await mutate({ + mutation: updateHomeCommunityQuery, + variables: { uuid: uuidv4(), gmsApiKey: 'gmsApiKey' }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('HomeCommunity with uuid not found: ')], + }), + ) }) }) }) diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index a46e30144..88ca37dd9 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -1,12 +1,15 @@ -import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity } from 'database' +import { + Community as DbCommunity, + getReachableCommunities, + getCommunityWithFederatedCommunityByIdentifier, + getHomeCommunity +} from 'database' import { Arg, Args, Authorized, Mutation, Query, Resolver } from 'type-graphql' -import { IsNull, Not } from 'typeorm' import { Paginated } from '@arg/Paginated' import { EditCommunityInput } from '@input/EditCommunityInput' import { AdminCommunityView } from '@model/AdminCommunityView' import { Community } from '@model/Community' -import { FederatedCommunity } from '@model/FederatedCommunity' import { RIGHTS } from '@/auth/RIGHTS' import { LogError } from '@/server/LogError' @@ -18,24 +21,16 @@ import { getCommunityByUuid, } from './util/communities' +import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' +import { getLogger } from 'log4js' +import { communityIsReachable, CommunityIsReachableResult } from '../logic/communityIsReachable' +import { CONFIG } from '@/config' + +const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.CommunityResolver`) + @Resolver() export class CommunityResolver { - @Authorized([RIGHTS.COMMUNITIES]) - @Query(() => [FederatedCommunity]) - async getCommunities(): Promise { - const dbFederatedCommunities: DbFederatedCommunity[] = await DbFederatedCommunity.find({ - order: { - foreign: 'ASC', - createdAt: 'DESC', - lastAnnouncedAt: 'DESC', - }, - }) - return dbFederatedCommunities.map( - (dbCom: DbFederatedCommunity) => new FederatedCommunity(dbCom), - ) - } - - @Authorized([RIGHTS.COMMUNITIES]) + @Authorized([RIGHTS.COMMUNITY_WITH_API_KEYS]) @Query(() => [AdminCommunityView]) async allCommunities(@Args() paginated: Paginated): Promise { // communityUUID could be oneTimePassCode (uint32 number) @@ -44,17 +39,17 @@ export class CommunityResolver { @Authorized([RIGHTS.COMMUNITIES]) @Query(() => [Community]) - async communities(): Promise { - const dbCommunities: DbCommunity[] = await DbCommunity.find({ - where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) }, - order: { - name: 'ASC', - }, + async reachableCommunities(): Promise { + const dbCommunities: DbCommunity[] = await getReachableCommunities( + CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER * 2, { + // order by + foreign: 'ASC', // home community first + name: 'ASC', // sort foreign communities by name }) return dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom)) } - @Authorized([RIGHTS.COMMUNITY_BY_IDENTIFIER]) + @Authorized([RIGHTS.COMMUNITIES]) @Query(() => Community) async communityByIdentifier( @Arg('communityIdentifier') communityIdentifier: string, @@ -67,7 +62,7 @@ export class CommunityResolver { return new Community(community) } - @Authorized([RIGHTS.HOME_COMMUNITY]) + @Authorized([RIGHTS.COMMUNITIES]) @Query(() => Community) async homeCommunity(): Promise { const community = await getHomeCommunity() @@ -78,10 +73,10 @@ export class CommunityResolver { } @Authorized([RIGHTS.COMMUNITY_UPDATE]) - @Mutation(() => Community) + @Mutation(() => AdminCommunityView) async updateHomeCommunity( @Args() { uuid, gmsApiKey, location, hieroTopicId }: EditCommunityInput, - ): Promise { + ): Promise { const homeCom = await getCommunityByUuid(uuid) if (!homeCom) { throw new LogError('HomeCommunity with uuid not found: ', uuid) @@ -89,18 +84,24 @@ export class CommunityResolver { if (homeCom.foreign) { throw new LogError('Error: Only the HomeCommunity could be modified!') } + if ( homeCom.gmsApiKey !== gmsApiKey || homeCom.location !== location || homeCom.hieroTopicId !== hieroTopicId ) { + // TODO: think about this, it is really expected to delete gmsApiKey if no new one is given? homeCom.gmsApiKey = gmsApiKey ?? null if (location) { homeCom.location = Location2Point(location) } - homeCom.hieroTopicId = hieroTopicId ?? null + // update only with new value, don't overwrite existing value with null or undefined! + if (hieroTopicId) { + homeCom.hieroTopicId = hieroTopicId + } await DbCommunity.save(homeCom) } - return new Community(homeCom) + + return new AdminCommunityView(homeCom) } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 8acbd7b53..ada54b4b9 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -47,8 +47,7 @@ import { getLogger, Logger } from 'log4js' import { randombytes_random } from 'sodium-native' import { executeTransaction } from './TransactionResolver' import { - getAuthenticatedCommunities, - getCommunityByIdentifier, + getAuthenticatedCommunities, getCommunityByPublicKey, getCommunityByUuid, } from './util/communities' diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index c9d68de35..fa6170013 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -439,9 +439,7 @@ export class TransactionResolver { logger.debug( `sendCoins(recipientCommunityIdentifier=${recipientCommunityIdentifier}, recipientIdentifier=${recipientIdentifier}, amount=${amount}, memo=${memo})`, ) - const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } }) const senderUser = getUser(context) - if (!recipientCommunityIdentifier || (await isHomeCommunity(recipientCommunityIdentifier))) { // processing sendCoins within sender and recipient are both in home community const recipientUser = await findUserByIdentifier( @@ -449,7 +447,7 @@ export class TransactionResolver { recipientCommunityIdentifier, ) if (!recipientUser) { - throw new LogError('The recipient user was not found', recipientUser) + throw new LogError('The recipient user was not found', { recipientIdentifier, recipientCommunityIdentifier }) } logger.addContext('to', recipientUser?.id) if (recipientUser.foreign) { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 045ca7756..e13391a55 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1151,6 +1151,12 @@ export class UserResolver { @Args() { identifier, communityIdentifier }: UserArgs, ): Promise { + // check if identifier contain community and user identifier + if (identifier.includes('/')) { + const parts = identifier.split('/') + communityIdentifier = parts[0] + identifier = parts[1] + } const foundDbUser = await findUserByIdentifier(identifier, communityIdentifier) if (!foundDbUser) { createLogger().debug('User not found', identifier, communityIdentifier) diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index ec0a966a8..e42e738f2 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -375,7 +375,6 @@ export const logout = gql` export const updateHomeCommunityQuery = gql` mutation ($uuid: String!, $gmsApiKey: String!) { updateHomeCommunity(uuid: $uuid, gmsApiKey: $gmsApiKey) { - id foreign name description diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 5a8e06cc0..2eb56e6f6 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -135,18 +135,14 @@ export const listGDTEntriesQuery = gql` } ` -export const communitiesQuery = gql` - query { - communities { - id +export const reachableCommunities = gql` + query { + reachableCommunities { foreign + uuid name description url - creationDate - uuid - authenticatedAt - gmsApiKey } } ` @@ -162,7 +158,6 @@ export const getCommunityByIdentifierQuery = gql` creationDate uuid authenticatedAt - gmsApiKey } } ` @@ -178,24 +173,6 @@ export const getHomeCommunityQuery = gql` creationDate uuid authenticatedAt - gmsApiKey - } - } -` - -export const getCommunities = gql` - query { - getCommunities { - id - foreign - publicKey - endPoint - apiVersion - lastAnnouncedAt - verifiedAt - lastErrorAt - createdAt - updatedAt } } ` @@ -268,7 +245,7 @@ export const listContributions = gql` } ` -export const listAllContributions = ` +export const listAllContributions = gql` query ($pagination: Paginated!) { listAllContributions(pagination: $pagination) { contributionCount diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index c7f533931..ff0c513e7 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -39,11 +39,13 @@ export const testEnvironment = async (testLogger = getLogger('apollo'), testI18n } export const resetEntity = async (entity: any) => { - const items = await entity.find({ withDeleted: true }) + // delete data and reset autoincrement! + await entity.clear() + /*const items = await entity.find({ withDeleted: true }) if (items.length > 0) { const ids = items.map((e: any) => e.id) await entity.delete(ids) - } + }*/ } export const resetToken = () => { diff --git a/database/src/queries/communities.test.ts b/database/src/queries/communities.test.ts index 9cfb210db..62dedabf1 100644 --- a/database/src/queries/communities.test.ts +++ b/database/src/queries/communities.test.ts @@ -1,8 +1,8 @@ import { Community as DbCommunity } from '..' import { AppDatabase } from '../AppDatabase' -import { getHomeCommunity } from './communities' -import { describe, expect, it, beforeAll, afterAll } from 'vitest' -import { createCommunity } from '../seeds/homeCommunity' +import { getHomeCommunity, getReachableCommunities } from './communities' +import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest' +import { createCommunity, createAuthenticatedForeignCommunity } from '../seeds/community' const db = AppDatabase.getInstance() @@ -14,7 +14,8 @@ afterAll(async () => { }) describe('community.queries', () => { - beforeAll(async () => { + // clean db for every test case + beforeEach(async () => { await DbCommunity.clear() }) describe('getHomeCommunity', () => { @@ -37,4 +38,28 @@ describe('community.queries', () => { expect(community?.privateKey).toStrictEqual(homeCom.privateKey) }) }) + describe('getReachableCommunities', () => { + it('home community counts also to reachable communities', async () => { + await createCommunity(false) + expect(await getReachableCommunities(1000)).toHaveLength(1) + }) + it('foreign communities authenticated within chosen range', async () => { + await createAuthenticatedForeignCommunity(400) + await createAuthenticatedForeignCommunity(500) + await createAuthenticatedForeignCommunity(1200) + + const community = await getReachableCommunities(1000) + expect(community).toHaveLength(2) + }) + it('foreign and home community', async () => { + await createCommunity(false) + await createAuthenticatedForeignCommunity(400) + await createAuthenticatedForeignCommunity(1200) + expect(await getReachableCommunities(1000)).toHaveLength(2) + }) + it('not authenticated foreign community', async () => { + await createCommunity(true) + expect(await getReachableCommunities(1000)).toHaveLength(0) + }) + }) }) \ No newline at end of file diff --git a/database/src/queries/communities.ts b/database/src/queries/communities.ts index edc3bd7ed..5bf8798c8 100644 --- a/database/src/queries/communities.ts +++ b/database/src/queries/communities.ts @@ -1,10 +1,14 @@ +import { FindOptionsOrder, FindOptionsWhere, IsNull, MoreThanOrEqual, Not } from 'typeorm' import { Community as DbCommunity } from '../entity' +import { urlSchema, uuidv4Schema } from 'shared' /** * Retrieves the home community, i.e., a community that is not foreign. * @returns A promise that resolves to the home community, or null if no home community was found */ export async function getHomeCommunity(): Promise { + // TODO: Put in Cache, it is needed nearly always + // TODO: return only DbCommunity or throw to reduce unnecessary checks, because there should be always a home community return await DbCommunity.findOne({ where: { foreign: false }, }) @@ -15,3 +19,40 @@ export async function getCommunityByUuid(communityUuid: string): Promise { + const where: FindOptionsWhere = {} + // pre filter identifier type to reduce db query complexity + if (urlSchema.safeParse(communityIdentifier).success) { + where.url = communityIdentifier + } else if (uuidv4Schema.safeParse(communityIdentifier).success) { + where.communityUuid = communityIdentifier + } else { + where.name = communityIdentifier + } + return where +} + +export async function getCommunityWithFederatedCommunityByIdentifier( + communityIdentifier: string, +): Promise { + return await DbCommunity.findOne({ + where: { ...findWithCommunityIdentifier(communityIdentifier) }, + relations: ['federatedCommunities'], + }) +} + +// returns all reachable communities +// home community and all foreign communities which have been authenticated within the last authenticationTimeoutMs +export async function getReachableCommunities( + authenticationTimeoutMs: number, + order?: FindOptionsOrder +): Promise { + return await DbCommunity.find({ + where: [ + { communityUuid: Not(IsNull()), authenticatedAt: MoreThanOrEqual(new Date(Date.now() - authenticationTimeoutMs)) }, + { foreign: false }, + ], + order, + }) +} \ No newline at end of file diff --git a/database/src/queries/pendingTransactions.test.ts b/database/src/queries/pendingTransactions.test.ts index 72deaac71..c59c312e4 100644 --- a/database/src/queries/pendingTransactions.test.ts +++ b/database/src/queries/pendingTransactions.test.ts @@ -14,7 +14,7 @@ import { peterLustig } from '../seeds/users/peter-lustig' import { bobBaumeister } from '../seeds/users/bob-baumeister' import { garrickOllivander } from '../seeds/users/garrick-ollivander' import { describe, expect, it, beforeAll, afterAll } from 'vitest' -import { createCommunity } from '../seeds/homeCommunity' +import { createCommunity } from '../seeds/community' import { v4 as uuidv4 } from 'uuid' import Decimal from 'decimal.js-light' diff --git a/database/src/queries/user.test.ts b/database/src/queries/user.test.ts index 8cb95e0ac..873ca7a2b 100644 --- a/database/src/queries/user.test.ts +++ b/database/src/queries/user.test.ts @@ -4,7 +4,7 @@ import { aliasExists, findUserByIdentifier } from './user' import { userFactory } from '../seeds/factory/user' import { bibiBloxberg } from '../seeds/users/bibi-bloxberg' import { describe, expect, it, beforeAll, afterAll, beforeEach, } from 'vitest' -import { createCommunity } from '../seeds/homeCommunity' +import { createCommunity } from '../seeds/community' import { peterLustig } from '../seeds/users/peter-lustig' import { bobBaumeister } from '../seeds/users/bob-baumeister' import { getLogger, printLogs, clearLogs } from '../../../config-schema/test/testSetup.vitest' diff --git a/database/src/queries/user.ts b/database/src/queries/user.ts index 2cb0eaaee..d7083e8aa 100644 --- a/database/src/queries/user.ts +++ b/database/src/queries/user.ts @@ -1,9 +1,8 @@ import { Raw } from 'typeorm' -import { Community, User as DbUser, UserContact as DbUserContact } from '../entity' -import { FindOptionsWhere } from 'typeorm' -import { aliasSchema, emailSchema, uuidv4Schema, urlSchema } from 'shared' +import { User as DbUser, UserContact as DbUserContact } from '../entity' +import { aliasSchema, emailSchema, uuidv4Schema } from 'shared' import { getLogger } from 'log4js' -import { LOG4JS_QUERIES_CATEGORY_NAME } from './index' +import { findWithCommunityIdentifier, LOG4JS_QUERIES_CATEGORY_NAME } from './index' export async function aliasExists(alias: string): Promise { const user = await DbUser.findOne({ @@ -22,11 +21,9 @@ export const findUserByIdentifier = async ( identifier: string, communityIdentifier?: string, ): Promise => { - const communityWhere: FindOptionsWhere = urlSchema.safeParse(communityIdentifier).success - ? { url: communityIdentifier } - : uuidv4Schema.safeParse(communityIdentifier).success - ? { communityUuid: communityIdentifier } - : { name: communityIdentifier } + const communityWhere = communityIdentifier + ? findWithCommunityIdentifier(communityIdentifier) + : undefined if (uuidv4Schema.safeParse(identifier).success) { return DbUser.findOne({ diff --git a/database/src/seeds/community.ts b/database/src/seeds/community.ts new file mode 100644 index 000000000..b57e839e1 --- /dev/null +++ b/database/src/seeds/community.ts @@ -0,0 +1,34 @@ +import { Community } from '../entity' +import { randomBytes } from 'node:crypto' +import { v4 as uuidv4 } from 'uuid' + +export async function createCommunity(foreign: boolean, save: boolean = true): Promise { + const community = new Community() + community.publicKey = randomBytes(32) + community.communityUuid = uuidv4() + community.name = 'HomeCommunity-name' + community.creationDate = new Date() + + if(foreign) { + community.foreign = true + community.name = 'ForeignCommunity-name' + community.description = 'ForeignCommunity-description' + community.url = `http://foreign-${Math.random()}/api` + } else { + community.foreign = false + community.privateKey = randomBytes(64) + community.name = 'HomeCommunity-name' + community.description = 'HomeCommunity-description' + community.url = 'http://localhost/api' + } + return save ? await community.save() : community +} + +export async function createAuthenticatedForeignCommunity( + authenticatedBeforeMs: number, + save: boolean = true +): Promise { + const foreignCom = await createCommunity(true, false) + foreignCom.authenticatedAt = new Date(Date.now() - authenticatedBeforeMs) + return save ? await foreignCom.save() : foreignCom +} \ No newline at end of file diff --git a/database/src/seeds/homeCommunity.ts b/database/src/seeds/homeCommunity.ts deleted file mode 100644 index d76ee0b98..000000000 --- a/database/src/seeds/homeCommunity.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Community } from '../entity' -import { randomBytes } from 'node:crypto' -import { v4 as uuidv4 } from 'uuid' - -export async function createCommunity(foreign: boolean): Promise { - const homeCom = new Community() - homeCom.publicKey = randomBytes(32) - homeCom.communityUuid = uuidv4() - homeCom.authenticatedAt = new Date() - homeCom.name = 'HomeCommunity-name' - homeCom.creationDate = new Date() - - if(foreign) { - homeCom.foreign = true - homeCom.name = 'ForeignCommunity-name' - homeCom.description = 'ForeignCommunity-description' - homeCom.url = 'http://foreign/api' - } else { - homeCom.foreign = false - homeCom.privateKey = randomBytes(64) - homeCom.name = 'HomeCommunity-name' - homeCom.description = 'HomeCommunity-description' - homeCom.url = 'http://localhost/api' - } - return await homeCom.save() -} diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index b081f5d93..f460efa8e 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -25,7 +25,7 @@ import { ref, computed, onMounted } from 'vue' import { useQuery } from '@vue/apollo-composable' import { useRoute } from 'vue-router' -import { selectCommunities } from '@/graphql/queries' +import { reachableCommunities } from '@/graphql/communities.graphql' import { useAppToast } from '@/composables/useToast' const props = defineProps({ @@ -35,7 +35,7 @@ const props = defineProps({ }, }) -const emit = defineEmits(['update:modelValue']) +const emit = defineEmits(['update:modelValue', 'communitiesLoaded']) const route = useRoute() const { toastError } = useAppToast() @@ -43,16 +43,17 @@ const { toastError } = useAppToast() const communities = ref([]) const validCommunityIdentifier = ref(false) -const { onResult } = useQuery(selectCommunities) +const { onResult } = useQuery(reachableCommunities) onResult(({ data }) => { // console.log('CommunitySwitch.onResult...data=', data) if (data) { - communities.value = data.communities + communities.value = data.reachableCommunities setDefaultCommunity() - if (data.communities.length === 1) { + if (data.reachableCommunities.length === 1) { validCommunityIdentifier.value = true } + emit('communitiesLoaded', data.reachableCommunities) } }) diff --git a/frontend/src/components/GddSend/TransactionForm.spec.js b/frontend/src/components/GddSend/TransactionForm.spec.js index 5bd9e9d8e..afaf36b6f 100644 --- a/frontend/src/components/GddSend/TransactionForm.spec.js +++ b/frontend/src/components/GddSend/TransactionForm.spec.js @@ -26,6 +26,7 @@ vi.mock('vue-router', () => ({ })) const mockUseQuery = vi.fn() +const mockUseLazyQuery = vi.fn() vi.mock('@vue/apollo-composable', () => ({ useQuery: (...args) => { mockUseQuery(...args) @@ -35,6 +36,12 @@ vi.mock('@vue/apollo-composable', () => ({ error: ref(null), } }, + useLazyQuery: (...args) => { + mockUseLazyQuery(...args) + return { + refetch: vi.fn(() => true), + } + }, })) vi.mock('@/composables/useToast', () => ({ diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 8d977b835..b66b39ed1 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -49,6 +49,7 @@ :disabled="isBalanceEmpty" :model-value="form.targetCommunity" @update:model-value="updateField($event, 'targetCommunity')" + @communitiesLoaded="setCommunities" /> @@ -62,7 +63,7 @@ :label="$t('form.recipient')" :placeholder="$t('form.identifier')" :rules="validationSchema.fields.identifier" - :disabled="isBalanceEmpty" + :disabled="isBalanceEmpty || isCommunitiesEmpty" :disable-smart-valid-state="disableSmartValidState" @update:model-value="updateField" /> @@ -171,6 +172,7 @@ const props = defineProps({ const entityDataToForm = computed(() => ({ ...props })) const form = reactive({ ...entityDataToForm.value }) const disableSmartValidState = ref(false) +const communities = ref([]) const emit = defineEmits(['set-transaction']) @@ -191,6 +193,10 @@ const userIdentifier = computed(() => { return null }) +function setCommunities(returnedCommunities) { + communities.value = returnedCommunities +} + const validationSchema = computed(() => { const amountSchema = number() .required() @@ -214,7 +220,29 @@ const validationSchema = computed(() => { return object({ memo: memoSchema, amount: amountSchema, - identifier: identifierSchema, + identifier: identifierSchema.test( + 'community-is-reachable', + 'form.validation.identifier.communityIsReachable', + (value) => { + const parts = value.split('/') + // early exit if no community id is in identifier string + if (parts.length !== 2) { + return true + } + const com = communities.value.find((community) => { + return ( + community.uuid === parts[0] || + community.name === parts[0] || + community.url === parts[0] + ) + }) + if (com) { + form.targetCommunity = com + return true + } + return false + }, + ), }) } else { // don't need identifier schema if it is a transaction link or identifier was set via url @@ -224,7 +252,6 @@ const validationSchema = computed(() => { }) } }) - const formIsInvalid = computed(() => !validationSchema.value.isValidSync(form)) const updateField = (newValue, name) => { @@ -234,6 +261,7 @@ const updateField = (newValue, name) => { } const isBalanceEmpty = computed(() => props.balance <= 0) +const isCommunitiesEmpty = computed(() => communities.value.length === 0) const { result: userResult, error: userError } = useQuery( user, @@ -260,6 +288,13 @@ watch(userError, (error) => { function onSubmit() { const transformedForm = validationSchema.value.cast(form) + const parts = transformedForm.identifier.split('/') + if (parts.length === 2) { + transformedForm.identifier = parts[1] + transformedForm.targetCommunity = communities.value.find((com) => { + return com.uuid === parts[0] || com.name === parts[0] || com.url === parts[0] + }) + } emit('set-transaction', { ...transformedForm, selected: radioSelected.value, diff --git a/frontend/src/graphql/communities.graphql b/frontend/src/graphql/communities.graphql new file mode 100644 index 000000000..140ade42e --- /dev/null +++ b/frontend/src/graphql/communities.graphql @@ -0,0 +1,9 @@ +query reachableCommunities { + reachableCommunities { + foreign + uuid + name + description + url + } +} \ No newline at end of file diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 52c7c89b2..e760ccb9f 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -91,19 +91,6 @@ export const listGDTEntriesQuery = gql` } } ` - -export const selectCommunities = gql` - query { - communities { - uuid - name - description - foreign - url - } - } -` - export const queryOptIn = gql` query ($optIn: String!) { queryOptIn(optIn: $optIn) diff --git a/frontend/src/graphql/user.graphql b/frontend/src/graphql/user.graphql index c8617172f..0da877f2f 100644 --- a/frontend/src/graphql/user.graphql +++ b/frontend/src/graphql/user.graphql @@ -3,4 +3,10 @@ fragment userFields on User { firstName lastName alias -} \ No newline at end of file +} + +query user($identifier: String!, $communityIdentifier: String) { + user(identifier: $identifier, communityIdentifier: $communityIdentifier) { + ...userFields + } +} diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 5cfc2740d..ec53038a3 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -231,6 +231,7 @@ }, "gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein.", "identifier": { + "communityIsReachable": "Community nicht gefunden oder nicht erreichbar!", "required": "Der Empfänger ist ein Pflichtfeld.", "typeError": "Der Empfänger muss eine Email, ein Nutzernamen oder eine Gradido ID sein." }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index bd3f91dfd..2fbf4ab2a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -231,6 +231,7 @@ }, "gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point.", "identifier": { + "communityIsReachable": "Community not found our not reachable!", "required": "The recipient is a required field.", "typeError": "The recipient must be an email, a username or a Gradido ID." }, diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 23d6c1237..c4c77bbaa 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -185,6 +185,11 @@ "to1": "para", "validation": { "gddSendAmount": "El campo {_field_} debe ser un número entre {min} y {max} con un máximo de dos decimales", + "identifier": { + "communityIsReachable": "Comunidad no encontrada o no alcanzable!", + "required": "El destinatario es un campo obligatorio.", + "typeError": "El destinatario debe ser un email, un nombre de usuario o un Gradido ID." + }, "is-not": "No es posible transferirte Gradidos a ti mismo", "requiredField": "El campo {fieldName} es obligatorio", "usernmae-regex": "El nombre de usuario debe comenzar con una letra seguida de al menos dos caracteres alfanuméricos.", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c2c87e976..b531274a0 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -191,6 +191,13 @@ "validation": { "gddCreationTime": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de une décimale.", "gddSendAmount": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de deux chiffres après la virgule", + "identifier": { + "communityIsReachable": "Communauté non joignable!", + "communityIsReachable.communityNotFound": "Communauté non trouvée!", + "communityIsReachable.communityNotReachable": "Communauté non joignable!", + "required": "Le destinataire est un champ obligatoire.", + "typeError": "Le destinataire doit être un email, un nom d'utilisateur ou un Gradido ID." + }, "is-not": "Vous ne pouvez pas vous envoyer de Gradido à vous-même", "requiredField": "Le champ {fieldName} est obligatoire", "usernmae-regex": "Le nom d'utilisateur doit commencer par une lettre, suivi d'au moins deux caractères alphanumériques.", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 2d47d64c7..6f78583e5 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -185,6 +185,11 @@ "to1": "aan", "validation": { "gddSendAmount": "Het veld {_field_} moet een getal tussen {min} en {max} met maximaal twee cijfers achter de komma zijn", + "identifier": { + "communityIsReachable": "Community niet gevonden of niet bereikbaar!", + "required": "Ontvanger is een verplicht veld.", + "typeError": "Ontvanger moet een email, een gebruikersnaam of een Gradido ID zijn." + }, "is-not": "Je kunt geen Gradidos aan jezelf overmaken", "requiredField": "{fieldName} is verplicht", "usernmae-regex": "De gebruikersnaam moet met een letter beginnen, waarop minimaal twee alfanumerieke tekens dienen te volgen.", diff --git a/frontend/src/validationSchemas.js b/frontend/src/validationSchemas.js index 53cdc6f86..a90d4556d 100644 --- a/frontend/src/validationSchemas.js +++ b/frontend/src/validationSchemas.js @@ -28,10 +28,21 @@ export const memo = string() export const identifier = string() .required('form.validation.identifier.required') + .test( + 'valid-parts', + 'form.validation.identifier.partsError', + (value) => (value.match(/\//g) || []).length <= 1, // allow only one or zero slash + ) .test('valid-identifier', 'form.validation.identifier.typeError', (value) => { - const isEmail = !!EMAIL_REGEX.test(value) - const isUsername = !!value.match(USERNAME_REGEX) + let userPart = value + const parts = value.split('/') + if (parts.length === 2) { + userPart = parts[1] + } + + const isEmail = !!EMAIL_REGEX.test(userPart) + const isUsername = !!userPart.match(USERNAME_REGEX) // TODO: use valibot and rules from shared - const isGradidoId = validateUuid(value) && versionUuid(value) === 4 + const isGradidoId = validateUuid(userPart) && versionUuid(userPart) === 4 return isEmail || isUsername || isGradidoId }) From 6c862295f49fe7ea79232ccc28cf11dd75b58c21 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 30 Sep 2025 13:46:52 +0200 Subject: [PATCH 02/13] remove not longer needed imports --- backend/src/graphql/resolver/CommunityResolver.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index 88ca37dd9..c3604812e 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -1,7 +1,6 @@ import { Community as DbCommunity, getReachableCommunities, - getCommunityWithFederatedCommunityByIdentifier, getHomeCommunity } from 'database' import { Arg, Args, Authorized, Mutation, Query, Resolver } from 'type-graphql' @@ -21,13 +20,8 @@ import { getCommunityByUuid, } from './util/communities' -import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' -import { getLogger } from 'log4js' -import { communityIsReachable, CommunityIsReachableResult } from '../logic/communityIsReachable' import { CONFIG } from '@/config' -const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.CommunityResolver`) - @Resolver() export class CommunityResolver { @Authorized([RIGHTS.COMMUNITY_WITH_API_KEYS]) From 14682e80852140fb1b1bf1f291eb23badeaeb3d1 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 30 Sep 2025 15:58:29 +0200 Subject: [PATCH 03/13] fix hours validation --- .../components/Contributions/ContributionForm.vue | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index 402d37f43..e5e31b11c 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -171,11 +171,25 @@ const validationSchema = computed(() => { ? currentValue : currentValue.replace(',', '.'), ) + // min and max are needed for html min max which validatedInput will take from this scheme .min(0.01, ({ min }) => ({ key: 'form.validation.hours.min', values: { min } })) .max(maxHours, ({ max }) => ({ key: 'form.validation.hours.max', values: { max } })) .test('decimal-places', 'form.validation.hours.decimal-places', (value) => { if (value === undefined || value === null) return true return /^\d+(\.\d{0,2})?$/.test(value.toString()) + }) + // min and max are not working with string, so we need to do it manually + .test('min-hours', 'form.validation.hours.min', (value) => { + if (value === undefined || value === null || Number(value).isNaN()) { + return false + } + return parseFloat(value) >= 0.01 + }) + .test('max-hours', 'form.validation.hours.max', (value) => { + if (value === undefined || value === null || Number(value).isNaN()) { + return false + } + return parseFloat(value) <= maxHours }), amount: number().min(0.01).max(maxAmounts), }) From bc213707d7f447025d4f99cf361565ded538dc10 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Tue, 30 Sep 2025 16:09:31 +0200 Subject: [PATCH 04/13] test min and max manuel because with string min and max don't work this way with yup --- .../Contributions/ContributionForm.vue | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index e5e31b11c..2161306ee 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -165,7 +165,6 @@ const validationSchema = computed(() => { .required('form.validation.contributionMemo.required'), hours: string() .typeError({ key: 'form.validation.hours.typeError', values: { min: 0.01, max: maxHours } }) - .required() .transform((currentValue) => !currentValue || typeof currentValue !== 'string' ? currentValue @@ -179,18 +178,22 @@ const validationSchema = computed(() => { return /^\d+(\.\d{0,2})?$/.test(value.toString()) }) // min and max are not working with string, so we need to do it manually - .test('min-hours', 'form.validation.hours.min', (value) => { - if (value === undefined || value === null || Number(value).isNaN()) { + .test('min-hours', { key: 'form.validation.hours.min', values: { min: 0.01 } }, (value) => { + if (value === undefined || value === null || Number.isNaN(parseFloat(value))) { return false } return parseFloat(value) >= 0.01 }) - .test('max-hours', 'form.validation.hours.max', (value) => { - if (value === undefined || value === null || Number(value).isNaN()) { - return false - } - return parseFloat(value) <= maxHours - }), + .test( + 'max-hours', + { key: 'form.validation.hours.max', values: { max: maxHours } }, + (value) => { + if (value === undefined || value === null || Number.isNaN(parseFloat(value))) { + return false + } + return parseFloat(value) <= maxHours + }, + ), amount: number().min(0.01).max(maxAmounts), }) }) From f8ef8e111eb596257ac60fb2e37cc4a336483be4 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Wed, 1 Oct 2025 13:49:10 +0200 Subject: [PATCH 05/13] log bug fix. use federated_communities.verified_at instead of communities.authenticated_at date for deciding reachable communities --- .../resolver/CommunityResolver.test.ts | 50 ++++++++++--------- .../src/log4js-config/coloredContext.ts | 2 +- database/src/queries/communities.test.ts | 46 +++++++++++++---- database/src/queries/communities.ts | 9 +++- database/src/seeds/community.ts | 24 ++++++--- 5 files changed, 86 insertions(+), 45 deletions(-) diff --git a/backend/src/graphql/resolver/CommunityResolver.test.ts b/backend/src/graphql/resolver/CommunityResolver.test.ts index b65cb65c0..5e7d929e2 100644 --- a/backend/src/graphql/resolver/CommunityResolver.test.ts +++ b/backend/src/graphql/resolver/CommunityResolver.test.ts @@ -16,10 +16,9 @@ import { reachableCommunities, } from '@/seeds/graphql/queries' import { peterLustig } from '@/seeds/users/peter-lustig' -import { createCommunity, createAuthenticatedForeignCommunity } from 'database/src/seeds/community' +import { createCommunity, createVerifiedFederatedCommunity } from 'database/src/seeds/community' import { getLogger } from 'config-schema/test/testSetup' -import { getCommunityByUuid } from './util/communities' import { CONFIG } from '@/config' jest.mock('@/password/EncryptorUtils') @@ -445,6 +444,7 @@ describe('CommunityResolver', () => { afterAll(async () => { await DbCommunity.clear() + await DbFederatedCommunity.clear() }) describe('with empty list', () => { @@ -483,33 +483,37 @@ describe('CommunityResolver', () => { describe('returns 2 filtered communities even with 3 existing entries', () => { beforeEach(async () => { foreignCom1 = await createCommunity(true, false) - foreignCom2 = await createAuthenticatedForeignCommunity(100, false) + foreignCom2 = await createCommunity(true, false) + const com1FedCom = await createVerifiedFederatedCommunity('1_0', 100, foreignCom1, false) + const com1FedCom2 = await createVerifiedFederatedCommunity('1_1', 100, foreignCom1, false) + const com2FedCom = await createVerifiedFederatedCommunity('1_0', 10000, foreignCom2, false) await Promise.all([ DbCommunity.insert(foreignCom1), - DbCommunity.insert(foreignCom2) + DbCommunity.insert(foreignCom2), + DbFederatedCommunity.insert(com1FedCom), + DbFederatedCommunity.insert(com1FedCom2), + DbFederatedCommunity.insert(com2FedCom) ]) }) it('returns 2 community entries', async () => { - await expect(query({ query: reachableCommunities })).resolves.toMatchObject({ - data: { - reachableCommunities: [ - { - foreign: homeCom1.foreign, - name: homeCom1.name, - description: homeCom1.description, - url: homeCom1.url, - uuid: homeCom1.communityUuid, - }, { - foreign: foreignCom2.foreign, - name: foreignCom2.name, - description: foreignCom2.description, - url: foreignCom2.url, - uuid: foreignCom2.communityUuid, - }, - ], - }, - }) + const result = await query({ query: reachableCommunities }) + expect(result.data.reachableCommunities.length).toBe(2) + expect(result.data.reachableCommunities).toMatchObject([ + { + foreign: homeCom1.foreign, + name: homeCom1.name, + description: homeCom1.description, + url: homeCom1.url, + uuid: homeCom1.communityUuid, + }, { + foreign: foreignCom1.foreign, + name: foreignCom1.name, + description: foreignCom1.description, + url: foreignCom1.url, + uuid: foreignCom1.communityUuid, + } + ]) }) }) diff --git a/config-schema/src/log4js-config/coloredContext.ts b/config-schema/src/log4js-config/coloredContext.ts index 1ebb6b219..1f238fcdb 100644 --- a/config-schema/src/log4js-config/coloredContext.ts +++ b/config-schema/src/log4js-config/coloredContext.ts @@ -33,7 +33,7 @@ function composeDataString(data: (string | Object)[]): string { return data .map((d) => { // if it is a object and his toString function return only garbage - if (typeof d === 'object' && d.toString() === '[object Object]') { + if (d && typeof d === 'object' && d.toString() === '[object Object]') { return inspect(d, ) } if (d) { diff --git a/database/src/queries/communities.test.ts b/database/src/queries/communities.test.ts index 62dedabf1..18975256c 100644 --- a/database/src/queries/communities.test.ts +++ b/database/src/queries/communities.test.ts @@ -1,8 +1,8 @@ -import { Community as DbCommunity } from '..' +import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from '..' import { AppDatabase } from '../AppDatabase' import { getHomeCommunity, getReachableCommunities } from './communities' import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest' -import { createCommunity, createAuthenticatedForeignCommunity } from '../seeds/community' +import { createCommunity, createVerifiedFederatedCommunity } from '../seeds/community' const db = AppDatabase.getInstance() @@ -17,6 +17,7 @@ describe('community.queries', () => { // clean db for every test case beforeEach(async () => { await DbCommunity.clear() + await DbFederatedCommunity.clear() }) describe('getHomeCommunity', () => { it('should return null if no home community exists', async () => { @@ -44,21 +45,44 @@ describe('community.queries', () => { expect(await getReachableCommunities(1000)).toHaveLength(1) }) it('foreign communities authenticated within chosen range', async () => { - await createAuthenticatedForeignCommunity(400) - await createAuthenticatedForeignCommunity(500) - await createAuthenticatedForeignCommunity(1200) + const com1 = await createCommunity(true) + const com2 = await createCommunity(true) + const com3 = await createCommunity(true) + await createVerifiedFederatedCommunity('1_0', 100, com1) + await createVerifiedFederatedCommunity('1_0', 500, com2) + // outside of range + await createVerifiedFederatedCommunity('1_0', 1200, com3) - const community = await getReachableCommunities(1000) - expect(community).toHaveLength(2) + const communities = await getReachableCommunities(1000) + expect(communities).toHaveLength(2) + expect(communities[0].communityUuid).toBe(com1.communityUuid) + expect(communities[1].communityUuid).toBe(com2.communityUuid) + }) + it('multiple federated community api version, result in one community', async () => { + const com1 = await createCommunity(true) + await createVerifiedFederatedCommunity('1_0', 100, com1) + await createVerifiedFederatedCommunity('1_1', 100, com1) + expect(await getReachableCommunities(1000)).toHaveLength(1) + }) + it('multiple federated community api version one outside of range, result in one community', async () => { + const com1 = await createCommunity(true) + await createVerifiedFederatedCommunity('1_0', 100, com1) + // outside of range + await createVerifiedFederatedCommunity('1_1', 1200, com1) + expect(await getReachableCommunities(1000)).toHaveLength(1) }) it('foreign and home community', async () => { + // home community await createCommunity(false) - await createAuthenticatedForeignCommunity(400) - await createAuthenticatedForeignCommunity(1200) + const com1 = await createCommunity(true) + const com2 = await createCommunity(true) + await createVerifiedFederatedCommunity('1_0', 400, com1) + await createVerifiedFederatedCommunity('1_0', 1200, com2) expect(await getReachableCommunities(1000)).toHaveLength(2) }) - it('not authenticated foreign community', async () => { - await createCommunity(true) + it('not verified inside time frame federated community', async () => { + const com1 = await createCommunity(true) + await createVerifiedFederatedCommunity('1_0', 1200, com1) expect(await getReachableCommunities(1000)).toHaveLength(0) }) }) diff --git a/database/src/queries/communities.ts b/database/src/queries/communities.ts index 5bf8798c8..e216f8af6 100644 --- a/database/src/queries/communities.ts +++ b/database/src/queries/communities.ts @@ -43,14 +43,19 @@ export async function getCommunityWithFederatedCommunityByIdentifier( } // returns all reachable communities -// home community and all foreign communities which have been authenticated within the last authenticationTimeoutMs +// home community and all federated communities which have been verified within the last authenticationTimeoutMs export async function getReachableCommunities( authenticationTimeoutMs: number, order?: FindOptionsOrder ): Promise { return await DbCommunity.find({ where: [ - { communityUuid: Not(IsNull()), authenticatedAt: MoreThanOrEqual(new Date(Date.now() - authenticationTimeoutMs)) }, + { + authenticatedAt: Not(IsNull()), + federatedCommunities: { + verifiedAt: MoreThanOrEqual(new Date(Date.now() - authenticationTimeoutMs)) + } + }, { foreign: false }, ], order, diff --git a/database/src/seeds/community.ts b/database/src/seeds/community.ts index b57e839e1..4db872398 100644 --- a/database/src/seeds/community.ts +++ b/database/src/seeds/community.ts @@ -1,4 +1,4 @@ -import { Community } from '../entity' +import { Community, FederatedCommunity } from '../entity' import { randomBytes } from 'node:crypto' import { v4 as uuidv4 } from 'uuid' @@ -14,8 +14,10 @@ export async function createCommunity(foreign: boolean, save: boolean = true): P community.name = 'ForeignCommunity-name' community.description = 'ForeignCommunity-description' community.url = `http://foreign-${Math.random()}/api` + community.authenticatedAt = new Date() } else { community.foreign = false + // todo: generate valid public/private key pair (ed25519) community.privateKey = randomBytes(64) community.name = 'HomeCommunity-name' community.description = 'HomeCommunity-description' @@ -24,11 +26,17 @@ export async function createCommunity(foreign: boolean, save: boolean = true): P return save ? await community.save() : community } -export async function createAuthenticatedForeignCommunity( - authenticatedBeforeMs: number, +export async function createVerifiedFederatedCommunity( + apiVersion: string, + verifiedBeforeMs: number, + community: Community, save: boolean = true -): Promise { - const foreignCom = await createCommunity(true, false) - foreignCom.authenticatedAt = new Date(Date.now() - authenticatedBeforeMs) - return save ? await foreignCom.save() : foreignCom -} \ No newline at end of file +): Promise { + const federatedCommunity = new FederatedCommunity() + federatedCommunity.apiVersion = apiVersion + federatedCommunity.endPoint = community.url + federatedCommunity.publicKey = community.publicKey + federatedCommunity.community = community + federatedCommunity.verifiedAt = new Date(Date.now() - verifiedBeforeMs) + return save ? await federatedCommunity.save() : federatedCommunity +} From bff160a904613fb5f6132aedd72b90383d4e7dcc Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Wed, 1 Oct 2025 14:33:05 +0200 Subject: [PATCH 06/13] remove side effects from validator, transform community_switch to field --- frontend/src/components/CommunitySwitch.vue | 8 ++++- .../components/GddSend/TransactionForm.vue | 32 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index f460efa8e..1e4fff5ff 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -33,6 +33,10 @@ const props = defineProps({ type: Object, default: () => ({}), }, + communityIdentifier: { + type: String, + default: '', + }, }) const emit = defineEmits(['update:modelValue', 'communitiesLoaded']) @@ -57,7 +61,9 @@ onResult(({ data }) => { } }) -const communityIdentifier = computed(() => route.params.communityIdentifier) +const communityIdentifier = computed( + () => route.params.communityIdentifier || props.communityIdentifier, +) function updateCommunity(community) { // console.log('CommunitySwitch.updateCommunity...community=', community) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index b66b39ed1..e14beb812 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -48,8 +48,9 @@ @@ -173,6 +174,7 @@ const entityDataToForm = computed(() => ({ ...props })) const form = reactive({ ...entityDataToForm.value }) const disableSmartValidState = ref(false) const communities = ref([]) +const autoCommunityIdentifier = ref('') const emit = defineEmits(['set-transaction']) @@ -220,6 +222,7 @@ const validationSchema = computed(() => { return object({ memo: memoSchema, amount: amountSchema, + // todo: found a better way, because this validation test has side effects identifier: identifierSchema.test( 'community-is-reachable', 'form.validation.identifier.communityIsReachable', @@ -229,18 +232,13 @@ const validationSchema = computed(() => { if (parts.length !== 2) { return true } - const com = communities.value.find((community) => { + return communities.value.some((community) => { return ( community.uuid === parts[0] || community.name === parts[0] || community.url === parts[0] ) }) - if (com) { - form.targetCommunity = com - return true - } - return false }, ), }) @@ -286,6 +284,26 @@ watch(userError, (error) => { } }) +// if identifier contain valid community identifier of a reachable community: +// set it as target community and change community-switch to show only current value, instead of select +watch( + () => form.identifier, + (value) => { + autoCommunityIdentifier.value = '' + const parts = value.split('/') + if (parts.length === 2) { + const com = communities.value.find( + (community) => + community.uuid === parts[0] || community.name === parts[0] || community.url === parts[0], + ) + if (com) { + form.targetCommunity = com + autoCommunityIdentifier.value = com.uuid + } + } + }, +) + function onSubmit() { const transformedForm = validationSchema.value.cast(form) const parts = transformedForm.identifier.split('/') From 84ebba4070892dc11f09df289f5080e721139b3b Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Wed, 1 Oct 2025 14:40:34 +0200 Subject: [PATCH 07/13] add debug log --- frontend/src/components/CommunitySwitch.vue | 9 ++++++++- frontend/src/components/GddSend/TransactionForm.vue | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index 1e4fff5ff..ed1292a7c 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -22,7 +22,7 @@