diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 52df046c62..216b3864df 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -126,6 +126,7 @@ export interface AccountClient { firstName: string, lastName: string ) => Promise<{ uuid: PersonUuid, socialId: PersonId }> + addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -644,6 +645,20 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } + async addSocialIdToPerson ( + person: PersonUuid, + type: SocialIdType, + value: string, + confirmed: boolean + ): Promise { + const request = { + method: 'addSocialIdToPerson' as const, + params: { person, type, value, confirmed } + } + + return await this.rpc(request) + } + async getMailboxOptions (): Promise { const request = { method: 'getMailboxOptions' as const, diff --git a/packages/platform/src/platform.ts b/packages/platform/src/platform.ts index a3204d91df..624c807cb0 100644 --- a/packages/platform/src/platform.ts +++ b/packages/platform/src/platform.ts @@ -163,7 +163,8 @@ export default plugin(platformId, { WorkspaceLimitReached: '' as StatusCode<{ workspace: string }>, InvalidOtp: '' as StatusCode, InviteNotFound: '' as StatusCode<{ email: string }>, - MailboxError: '' as StatusCode<{ reason: string }> + MailboxError: '' as StatusCode<{ reason: string }>, + SocialIdAlreadyExists: '' as StatusCode }, metadata: { locale: '' as Metadata, diff --git a/server/account/src/__tests__/operations.test.ts b/server/account/src/__tests__/operations.test.ts index 6460fd6fbe..6050f5b5a4 100644 --- a/server/account/src/__tests__/operations.test.ts +++ b/server/account/src/__tests__/operations.test.ts @@ -13,14 +13,13 @@ // limitations under the License. // -// src/__tests__/operations.test.ts - -import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core' +import { AccountRole, MeasureContext, PersonId, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core' import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform' import { decodeTokenVerbose } from '@hcengineering/server-token' +import * as utils from '../utils' import { AccountDB } from '../types' -import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations' +import { createInvite, createInviteLink, sendInvite, resendInvite, addSocialIdToPerson } from '../operations' import { accountPlugin } from '../plugin' // Mock platform @@ -454,4 +453,106 @@ describe('invite operations', () => { expect(global.fetch).toHaveBeenCalled() }) }) + + describe('addSocialIdToPerson', () => { + const mockCtx = { + error: jest.fn() + } as unknown as MeasureContext + + const mockDb = {} as unknown as AccountDB + const mockBranding = null + const mockToken = 'test-token' + + // Create spy only for this test suite + const addSocialIdSpy = jest.spyOn(utils, 'addSocialId') + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + // Restore the original implementation + addSocialIdSpy.mockRestore() + }) + + test('should allow github service to add social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + const newSocialId = 'new-social-id' as PersonId + addSocialIdSpy.mockResolvedValue(newSocialId) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: true + } + + const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) + + expect(result).toBe(newSocialId) + expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) + }) + + test('should allow admin to add social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { admin: 'true' } + }) + const newSocialId = 'new-social-id' as PersonId + addSocialIdSpy.mockResolvedValue(newSocialId) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) + + expect(result).toBe(newSocialId) + expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'other-service' } + }) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(addSocialIdSpy).not.toHaveBeenCalled() + }) + + test('should throw error for regular user', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + workspace: 'test-workspace', + extra: {} + }) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(addSocialIdSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 460e7f4a64..13c2e220c5 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -62,7 +62,8 @@ import { getSocialIdByKey, getWorkspaceInvite, loginOrSignUpWithProvider, - sendEmail + sendEmail, + addSocialId } from '../utils' // eslint-disable-next-line import/no-named-default import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform' @@ -1926,4 +1927,121 @@ describe('account utils', () => { }) }) }) + + describe('addSocialId', () => { + const mockDb = { + person: { + findOne: jest.fn() + }, + socialId: { + findOne: jest.fn(), + insertOne: jest.fn() + } + } as unknown as AccountDB + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should add new social id', async () => { + const value = 'test@example.com' + const type = SocialIdType.EMAIL + const person = 'test-person-uuid' as PersonUuid + const confirmed = false + const normalizedValue = 'test@example.com' + const newPersonId = 'new-person-id' as PersonId + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.person.findOne as jest.Mock).mockResolvedValue({}) + ;(mockDb.socialId.insertOne as jest.Mock).mockResolvedValue(newPersonId) + + const result = await addSocialId(mockDb, person, type, value, confirmed) + + expect(mockDb.socialId.insertOne).toHaveBeenCalledWith({ + type, + value: normalizedValue, + personUuid: person, + verifiedOn: undefined + }) + expect(result).toBe(newPersonId) + + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + type, + value: normalizedValue + }) + }) + + test('should add confirmed social id with verification timestamp', async () => { + const value = 'test@example.com' + const type = SocialIdType.EMAIL + const person = 'test-person-uuid' as PersonUuid + const confirmed = true + const newPersonId = 'new-person-id' as PersonId + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.person.findOne as jest.Mock).mockResolvedValue({}) + ;(mockDb.socialId.insertOne as jest.Mock).mockResolvedValue(newPersonId) + + const result = await addSocialId(mockDb, person, type, value, confirmed) + + expect(mockDb.socialId.insertOne).toHaveBeenCalledWith({ + type, + value, + personUuid: person, + verifiedOn: expect.any(Number) + }) + expect(result).toBe(newPersonId) + }) + + test('should throw error if person is not found', async () => { + const value = 'test@example.com' + const type = SocialIdType.EMAIL + const person = 'test-person-uuid' as PersonUuid + + ;(mockDb.person.findOne as jest.Mock).mockResolvedValue(null) + + await expect(addSocialId(mockDb, person, type, value, false)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person })) + ) + }) + + test('should throw error if social id already exists', async () => { + const value = 'test@example.com' + const type = SocialIdType.EMAIL + const person = 'test-person-uuid' as PersonUuid + const existingSocialId = { + type, + value, + personUuid: 'other-person-uuid' as PersonUuid + } + + ;(mockDb.person.findOne as jest.Mock).mockResolvedValue({}) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(existingSocialId) + + await expect(addSocialId(mockDb, person, type, value, false)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdAlreadyExists, {})) + ) + }) + + test('should normalize value', async () => { + const value = ' TEST@EXAMPLE.COM ' + const normalizedValue = 'test@example.com' + const type = SocialIdType.EMAIL + const person = 'test-person-uuid' as PersonUuid + const newPersonId = 'new-person-id' as PersonId + + ;(mockDb.person.findOne as jest.Mock).mockResolvedValue({}) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.socialId.insertOne as jest.Mock).mockResolvedValue(newPersonId) + + const result = await addSocialId(mockDb, person, type, value, false) + + expect(mockDb.socialId.insertOne).toHaveBeenCalledWith(expect.objectContaining({ value: normalizedValue })) + expect(result).toBe(newPersonId) + expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ + type, + value: normalizedValue + }) + }) + }) }) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index ccd06f2ea6..34c06b4adf 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -101,7 +101,8 @@ import { getWorkspaceRole, normalizeValue, isEmail, - generatePassword + generatePassword, + addSocialId } from './utils' // Move to config? @@ -2107,6 +2108,21 @@ async function deleteMailbox ( ctx.info('Mailbox deleted', { mailbox, account }) } +export async function addSocialIdToPerson ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean } +): Promise { + const { person, type, value, confirmed } = params + const { extra } = decodeTokenVerbose(ctx, token) + + verifyAllowedServices(['github'], extra) + + return await addSocialId(db, person, type, value, confirmed) +} + async function releaseSocialId ( db: AccountDB, personUuid: PersonUuid, @@ -2169,6 +2185,7 @@ export type AccountMethods = | 'createMailbox' | 'getMailboxes' | 'deleteMailbox' + | 'addSocialIdToPerson' /** * @public @@ -2228,7 +2245,8 @@ export function getMethods (hasSignUp: boolean = true): Partial { + const normalizedValue = normalizeValue(value ?? '') + + if (!Object.values(SocialIdType).includes(type) || normalizedValue.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const person = await db.person.findOne({ uuid: personUuid }) + if (person == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: personUuid })) + } + + const socialId = await db.socialId.findOne({ type, value: normalizedValue }) + if (socialId != null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdAlreadyExists, {})) + } + + const newSocialId: Omit = { + type, + value: normalizedValue, + personUuid + } + + if (confirmed) { + newSocialId.verifiedOn = Date.now() + } + + return await db.socialId.insertOne(newSocialId) +} + export async function getWorkspaceRole ( db: AccountDB, account: PersonUuid,