From 241ee3be06ad49dde6c6381ec46fb14242c375c9 Mon Sep 17 00:00:00 2001 From: Chunosov Date: Tue, 15 Apr 2025 12:51:57 +0700 Subject: [PATCH] allow user to manage their integrations (#8545) Signed-off-by: Nikolay Chunosov --- .../src/__tests__/serviceOperations.test.ts | 434 +++++++++++++++++- server/account/src/serviceOperations.ts | 120 ++--- server/account/src/utils.ts | 43 +- 3 files changed, 510 insertions(+), 87 deletions(-) diff --git a/server/account/src/__tests__/serviceOperations.test.ts b/server/account/src/__tests__/serviceOperations.test.ts index 57cafe2d8d..83d8eb1e73 100644 --- a/server/account/src/__tests__/serviceOperations.test.ts +++ b/server/account/src/__tests__/serviceOperations.test.ts @@ -226,9 +226,68 @@ describe('integration methods', () => { await createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) expect(mockDb.integration.insertOne).toHaveBeenCalledWith(mockIntegration) }) + test('should allow verified user to create their integration', async () => { + const mockAccount = 'test-account' + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + expect(mockDb.integration.insertOne).toHaveBeenCalledWith(mockIntegration) + }) + + test('should throw error when user creates integration for different social id', async () => { + const mockAccount = 'test-account' + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await expect(createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + expect(mockDb.integration.insertOne).not.toHaveBeenCalled() + }) + test('should throw error when social id not found', async () => { ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ extra: { service: 'github' } @@ -322,8 +381,10 @@ describe('integration methods', () => { }) describe('updateIntegration', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, data: { someData: 'value' } @@ -337,6 +398,8 @@ describe('integration methods', () => { ...mockIntegration, data: { oldData: 'old' } }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) await updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) @@ -348,6 +411,60 @@ describe('integration methods', () => { }, { data: mockIntegration.data } ) + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) + }) + + test('should allow verified user to update their integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegration, + data: { oldData: 'old' } + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) + + expect(mockDb.integration.updateOne).toHaveBeenCalledWith( + { + socialId: mockIntegration.socialId, + kind: mockIntegration.kind, + workspaceUuid: mockIntegration.workspaceUuid + }, + { data: mockIntegration.data } + ) + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when use updates integration for different social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegration, + data: { oldData: 'old' } + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + await expect(updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.updateOne).not.toHaveBeenCalled() + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should throw error when integration not found', async () => { @@ -355,6 +472,7 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) await expect(updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) @@ -377,8 +495,10 @@ describe('integration methods', () => { }) describe('deleteIntegration', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockIntegrationKey: IntegrationKey = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid } @@ -391,10 +511,57 @@ describe('integration methods', () => { ...mockIntegrationKey, data: {} }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) await deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey) expect(mockDb.integration.deleteMany).toHaveBeenCalledWith(mockIntegrationKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) + }) + + test('should allow verified user to delete their integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegrationKey, + data: {} + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey) + + expect(mockDb.integration.deleteMany).toHaveBeenCalledWith(mockIntegrationKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when user deletes integration for different social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegrationKey, + data: {} + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await expect(deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.deleteMany).not.toHaveBeenCalled() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should throw error when integration not found', async () => { @@ -402,6 +569,7 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) await expect(deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) @@ -424,15 +592,17 @@ describe('integration methods', () => { }) describe('getIntegration', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockKey: IntegrationKey = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid } test('should allow verified user to get their integration', async () => { ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ - account: 'test-account', + account: mockAccount, extra: {} }) @@ -442,13 +612,19 @@ describe('integration methods', () => { } ;(mockDb.socialId.find as jest.Mock).mockResolvedValue([ - { _id: mockKey.socialId, personUuid: 'test-account', verifiedOn: 1 } + { _id: mockKey.socialId, personUuid: mockAccount, verifiedOn: 1 } ]) ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) const result = await getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey) expect(result).toEqual(mockIntegration) expect(mockDb.integration.findOne).toHaveBeenCalledWith(mockKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should throw error when there is no matching verified social id', async () => { @@ -678,8 +854,9 @@ describe('integration methods', () => { extra: { service } }) + const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', @@ -695,20 +872,101 @@ describe('integration methods', () => { ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) await addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) expect(mockDb.integrationSecret.insertOne).toHaveBeenCalledWith(mockSecret) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) } }) + test('should allow verified user services to create their integration secret', async () => { + jest.clearAllMocks() + const mockAccount = 'test-account' + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockSecret: IntegrationSecret = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + const mockIntegration: Integration = { + socialId: mockSecret.socialId, + kind: mockSecret.kind, + workspaceUuid: mockSecret.workspaceUuid, + data: {} + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) + + expect(mockDb.integrationSecret.insertOne).toHaveBeenCalledWith(mockSecret) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when user services adds integration secret for different social id', async () => { + jest.clearAllMocks() + const mockAccount = 'test-account' + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockSecret: IntegrationSecret = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + const mockIntegration: Integration = { + socialId: mockSecret.socialId, + kind: mockSecret.kind, + workspaceUuid: mockSecret.workspaceUuid, + data: {} + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + await expect(addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.insertOne).not.toHaveBeenCalled() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + test('should throw error when integration does not exist', async () => { ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ extra: { service: 'github' } }) + const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', @@ -716,12 +974,15 @@ describe('integration methods', () => { } ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) await expect(addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) ) expect(mockDb.integrationSecret.insertOne).not.toHaveBeenCalled() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) }) test('should throw error if secret already exists', async () => { @@ -776,8 +1037,10 @@ describe('integration methods', () => { }) describe('updateIntegrationSecret', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', @@ -799,10 +1062,59 @@ describe('integration methods', () => { ...mockSecret, secret: 'old-secret' }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) await updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) expect(mockDb.integrationSecret.updateOne).toHaveBeenCalledWith(mockSecretKey, { secret: mockSecret.secret }) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) + }) + + test('should allow verified user to update their secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecret, + secret: 'old-secret' + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) + + await updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) + + expect(mockDb.integrationSecret.updateOne).toHaveBeenCalledWith(mockSecretKey, { secret: mockSecret.secret }) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when user updates secret for different social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecret, + secret: 'old-secret' + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await expect(updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.updateOne).not.toHaveBeenCalledWith() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should throw error when secret not found', async () => { @@ -810,6 +1122,7 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) await expect(updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) @@ -833,8 +1146,10 @@ describe('integration methods', () => { }) describe('deleteIntegrationSecret', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockSecretKey: IntegrationSecretKey = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key' @@ -848,10 +1163,60 @@ describe('integration methods', () => { ...mockSecretKey, secret: 'test-secret' }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) await deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) expect(mockDb.integrationSecret.deleteMany).toHaveBeenCalledWith(mockSecretKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) + }) + + test('should allow verified user to delete their secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecretKey, + secret: 'test-secret' + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) + + await deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) + + expect(mockDb.integrationSecret.deleteMany).toHaveBeenCalledWith(mockSecretKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when user deletes secret for different social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecretKey, + secret: 'test-secret' + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) + + await expect(deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.deleteMany).not.toHaveBeenCalledWith() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should throw error when secret not found', async () => { @@ -859,6 +1224,7 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) await expect(deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) @@ -881,8 +1247,10 @@ describe('integration methods', () => { }) describe('getIntegrationSecret', () => { + const mockAccount = 'test-account' + const mockSocialId = 'test-social-id' as PersonId const mockSecretKey: IntegrationSecretKey = { - socialId: 'test-social-id' as PersonId, + socialId: mockSocialId, kind: 'test-kind', workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key' @@ -898,11 +1266,56 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(mockSecret) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) const result = await getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) expect(result).toEqual(mockSecret) expect(mockDb.integrationSecret.findOne).toHaveBeenCalledWith(mockSecretKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ _id: mockSocialId, verifiedOn: { $gt: 0 } }) + }) + + test('should allow verified user to get their secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(mockSecret) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) + + const result = await getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) + + expect(result).toEqual(mockSecret) + expect(mockDb.integrationSecret.findOne).toHaveBeenCalledWith(mockSecretKey) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) + }) + + test('should throw error when user gets secret for different account', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: mockAccount + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(mockSecret) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({}) + + await expect(getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.findOne).not.toHaveBeenCalledWith() + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + _id: mockSocialId, + personUuid: mockAccount, + verifiedOn: { $gt: 0 } + }) }) test('should return null when integration secret not found', async () => { @@ -910,6 +1323,7 @@ describe('integration methods', () => { extra: { service: 'github' } }) ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) const result = await getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) expect(result).toBeNull() diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index 77200a2200..62bce1f732 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -47,6 +47,8 @@ import type { WorkspaceStatus } from './types' import { + integrationServices, + findExistingIntegration, cleanEmail, getAccount, getEmailSocialId, @@ -551,9 +553,6 @@ export async function updateSocialId ( await db.socialId.updateOne({ _id: personId }, { displayValue }) } -// Move to config? -const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox'] - export async function createIntegration ( ctx: MeasureContext, db: AccountDB, @@ -561,31 +560,13 @@ export async function createIntegration ( token: string, params: Integration ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, data } = params - - if (kind == null || socialId == null || workspaceUuid === undefined) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) - } - - const existingSocialId = await db.socialId.findOne({ _id: socialId }) - if (existingSocialId == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: socialId })) - } - - if (workspaceUuid != null) { - const workspace = await getWorkspaceById(db, workspaceUuid) - if (workspace == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) - } - } - - const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existing = await findExistingIntegration(account, db, params, extra) if (existing != null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationAlreadyExists, {})) } + const { socialId, kind, workspaceUuid, data } = params await db.integration.insertOne({ socialId, kind, workspaceUuid, data }) } @@ -596,19 +577,13 @@ export async function updateIntegration ( token: string, params: Integration ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, data } = params - - if (kind == null || socialId == null || workspaceUuid === undefined) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) - } - - const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existing = await findExistingIntegration(account, db, params, extra) if (existing == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) } + const { socialId, kind, workspaceUuid, data } = params await db.integration.updateOne({ socialId, kind, workspaceUuid }, { data }) } @@ -619,19 +594,13 @@ export async function deleteIntegration ( token: string, params: IntegrationKey ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid } = params - - if (kind == null || socialId == null || workspaceUuid === undefined) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) - } - - const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existing = await findExistingIntegration(account, db, params, extra) if (existing == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) } + const { socialId, kind, workspaceUuid } = params await db.integrationSecret.deleteMany({ socialId, kind, workspaceUuid }) await db.integration.deleteMany({ socialId, kind, workspaceUuid }) } @@ -710,22 +679,14 @@ export async function addIntegrationSecret ( token: string, params: IntegrationSecret ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, key, secret } = params - - if (kind == null || socialId == null || workspaceUuid === undefined || key == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) - } - - const integrationKey: IntegrationKey = { socialId, kind, workspaceUuid } - const secretKey: IntegrationSecretKey = { ...integrationKey, key } - - const existingIntegration = await db.integration.findOne(integrationKey) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existingIntegration = await findExistingIntegration(account, db, params, extra) if (existingIntegration == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) } + const { socialId, kind, workspaceUuid, key, secret } = params + const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } const existingSecret = await db.integrationSecret.findOne(secretKey) if (existingSecret != null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretAlreadyExists, {})) @@ -741,15 +702,14 @@ export async function updateIntegrationSecret ( token: string, params: IntegrationSecret ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, key, secret } = params - const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } - - if (kind == null || socialId == null || workspaceUuid === undefined || key == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existingIntegration = await findExistingIntegration(account, db, params, extra) + if (existingIntegration == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) } + const { socialId, kind, workspaceUuid, key, secret } = params + const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } const existingSecret = await db.integrationSecret.findOne(secretKey) if (existingSecret == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) @@ -765,15 +725,14 @@ export async function deleteIntegrationSecret ( token: string, params: IntegrationSecretKey ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, key } = params - const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } - - if (kind == null || socialId == null || workspaceUuid === undefined || key == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existingIntegration = await findExistingIntegration(account, db, params, extra) + if (existingIntegration == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) } + const { socialId, kind, workspaceUuid, key } = params + const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } const existingSecret = await db.integrationSecret.findOne(secretKey) if (existingSecret == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) @@ -789,14 +748,13 @@ export async function getIntegrationSecret ( token: string, params: IntegrationSecretKey ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) - const { socialId, kind, workspaceUuid, key } = params - - if (kind == null || socialId == null || workspaceUuid === undefined || key == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + const { extra, account } = decodeTokenVerbose(ctx, token) + const existingIntegration = await findExistingIntegration(account, db, params, extra) + if (existingIntegration == null) { + return null } + const { socialId, kind, workspaceUuid, key } = params const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key }) return existing @@ -809,10 +767,20 @@ export async function listIntegrationsSecrets ( token: string, params: Partial ): Promise { - const { extra } = decodeTokenVerbose(ctx, token) - verifyAllowedServices(integrationServices, extra) + const { extra, account } = decodeTokenVerbose(ctx, token) const { socialId, kind, workspaceUuid, key } = params + if (!verifyAllowedServices(integrationServices, extra, false)) { + if (socialId != null) { + const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } }) + if (existingSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } else { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + return await db.integrationSecret.find({ socialId, kind, workspaceUuid, key }) } diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index ab0cb0cae7..6c629c921e 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -56,7 +56,8 @@ import { WorkspaceLoginInfo, WorkspaceStatus, AccountEventType, - Meta + Meta, + Integration } from './types' import { Analytics } from '@hcengineering/analytics' import { TokenError, decodeTokenVerbose, generateToken } from '@hcengineering/server-token' @@ -1420,3 +1421,43 @@ export async function setTimezoneIfNotDefined ( ctx.error('Failed to set account timezone', err) } } + +// Move to config? +export const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox'] + +export async function findExistingIntegration ( + account: AccountUuid, + db: AccountDB, + params: Integration, + extra: any +): Promise { + const { socialId, kind, workspaceUuid } = params + if (kind == null || socialId == null || workspaceUuid === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + if (verifyAllowedServices(integrationServices, extra, false)) { + const existingSocialId = await db.socialId.findOne({ _id: socialId, verifiedOn: { $gt: 0 } }) + if (existingSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: socialId })) + } + } else { + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + // Allow accounts to operate on their own integrations + const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } }) + if (existingSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + + if (workspaceUuid != null) { + const workspace = await getWorkspaceById(db, workspaceUuid) + if (workspace == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) + } + } + + return await db.integration.findOne({ socialId, kind, workspaceUuid }) +}