diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 6cca8bc2c5..027c71e5dd 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -152,6 +152,7 @@ export interface AccountClient { ) => Promise updateSocialId: (personId: PersonId, displayValue: string) => Promise exchangeGuestToken: (token: string) => Promise + releaseSocialId: (personUuid: PersonUuid, type: SocialIdType, value: string) => Promise createIntegration: (integration: Integration) => Promise updateIntegration: (integration: Integration) => Promise deleteIntegration: (integrationKey: IntegrationKey) => Promise @@ -781,6 +782,15 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async releaseSocialId (personUuid: PersonUuid, type: SocialIdType, value: string): Promise { + const request = { + method: 'releaseSocialId' as const, + params: { personUuid, type, value } + } + + await this.rpc(request) + } + async createIntegration (integration: Integration): Promise { const request = { method: 'createIntegration' as const, diff --git a/server/account/src/__tests__/serviceOperations.test.ts b/server/account/src/__tests__/serviceOperations.test.ts index 83d8eb1e73..9e1ba3e221 100644 --- a/server/account/src/__tests__/serviceOperations.test.ts +++ b/server/account/src/__tests__/serviceOperations.test.ts @@ -29,6 +29,7 @@ import { getIntegrationSecret, listIntegrations, listIntegrationsSecrets, + releaseSocialId, updateIntegration, updateIntegrationSecret } from '../serviceOperations' @@ -1435,4 +1436,80 @@ describe('integration methods', () => { expect(mockDb.integrationSecret.find).not.toHaveBeenCalled() }) }) + + describe('releaseSocialId', () => { + const mockCtx = { + error: jest.fn() + } as unknown as MeasureContext + + const mockDb = {} as unknown as AccountDB + const mockBranding = null + const mockToken = 'test-token' + + // Create spy on doReleaseSocialId + const doReleaseSocialIdSpy = jest.spyOn(utils, 'doReleaseSocialId').mockImplementation(async () => {}) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + doReleaseSocialIdSpy.mockRestore() + }) + + test('should allow github service to release social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const params = { + personUuid: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value' + } + + await releaseSocialId(mockCtx, mockDb, mockBranding, mockToken, params) + + expect(doReleaseSocialIdSpy).toHaveBeenCalledWith(mockDb, params.personUuid, params.type, params.value, 'github') + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized' } + }) + + const params = { + personUuid: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value' + } + + await expect(releaseSocialId(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(doReleaseSocialIdSpy).not.toHaveBeenCalled() + }) + + test('should throw error for invalid parameters', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const invalidParams = [ + { type: SocialIdType.GITHUB, value: 'test' }, // missing personUuid + { personUuid: 'test' as PersonUuid, value: 'test' }, // missing type + { personUuid: 'test' as PersonUuid, type: SocialIdType.GITHUB }, // missing value + { personUuid: 'test' as PersonUuid, type: 'invalid' as SocialIdType, value: 'test' } // invalid type + ] + + for (const params of invalidParams) { + await expect(releaseSocialId(mockCtx, mockDb, mockBranding, mockToken, params as any)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + ) + } + + expect(doReleaseSocialIdSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 8d111c39ce..ed6fe5b6c6 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -64,7 +64,8 @@ import { getWorkspaceInvite, loginOrSignUpWithProvider, sendEmail, - addSocialId + addSocialId, + doReleaseSocialId } from '../utils' // eslint-disable-next-line import/no-named-default import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform' @@ -2074,4 +2075,90 @@ describe('account utils', () => { }) }) }) + + describe('social id release utils', () => { + describe('doReleaseSocialId', () => { + const mockDb = { + socialId: { + find: jest.fn(), + updateOne: jest.fn() + }, + account: { + findOne: jest.fn() + }, + accountEvent: { + insertOne: jest.fn() + } + } as unknown as AccountDB + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should release social id and create event', async () => { + const personUuid = 'test-person' as PersonUuid + const type = SocialIdType.GITHUB + const value = 'test-value' + const releasedBy = 'github' + const socialIdId = 'test-social-id' as PersonId + + const mockSocialIds = [ + { + _id: socialIdId, + value + } + ] + const mockAccount = { uuid: personUuid } + + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue(mockSocialIds) + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount) + + await doReleaseSocialId(mockDb, personUuid, type, value, releasedBy) + + expect(mockDb.socialId.updateOne).toHaveBeenCalledWith( + { _id: socialIdId }, + { value: `${value}#${socialIdId}`, isDeleted: true } + ) + expect(mockDb.accountEvent.insertOne).toHaveBeenCalledWith({ + accountUuid: personUuid, + eventType: AccountEventType.SOCIAL_ID_RELEASED, + time: expect.any(Number), + data: { + socialId: socialIdId, + releasedBy + } + }) + }) + + test('should handle missing account', async () => { + const personUuid = 'test-person' as PersonUuid + const type = SocialIdType.GITHUB + const value = 'test-value' + const socialIds = [{ _id: 'id1' as PersonId, value }] + + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue(socialIds) + ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null) + + await doReleaseSocialId(mockDb, personUuid, type, value, '') + + expect(mockDb.socialId.updateOne).toHaveBeenCalled() + expect(mockDb.accountEvent.insertOne).not.toHaveBeenCalled() + }) + + test('should throw error when no social id found', async () => { + const personUuid = 'test-person' as PersonUuid + const type = SocialIdType.GITHUB + const value = 'test-value' + + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue([]) + + await expect(doReleaseSocialId(mockDb, personUuid, type, value, '')).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, {})) + ) + + expect(mockDb.socialId.updateOne).not.toHaveBeenCalled() + expect(mockDb.accountEvent.insertOne).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index c42426dad4..8aa98f4213 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -77,7 +77,7 @@ import { isEmail, isOtpValid, normalizeValue, - releaseSocialId, + doReleaseSocialId, selectWorkspace, sendEmail, sendEmailConfirmation, @@ -359,7 +359,7 @@ export async function createWorkspace ( ctx.info('Creating workspace record', { workspaceName, account, region }) // Any confirmed social ID will do - const socialId = await db.socialId.findOne({ personUuid: account, verifiedOn: { $gt: 0 } }) + const socialId = (await getSocialIds(ctx, db, branding, token, { confirmed: true }))[0] if (socialId == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotConfirmed, {})) @@ -1280,7 +1280,7 @@ export async function getLoginInfoByToken ( if (!isDocGuest && !isSystem) { // Any confirmed social ID will do - socialId = await db.socialId.findOne({ personUuid: accountUuid, verifiedOn: { $gt: 0 } }) + socialId = (await getSocialIds(ctx, db, branding, token, { confirmed: true }))[0] if (socialId == null) { return { account: accountUuid @@ -1367,7 +1367,9 @@ export async function getSocialIds ( throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } - return await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } }) + const socialIds = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } }) + + return socialIds.filter((si) => si.isDeleted !== true) } export async function getPerson ( @@ -1636,7 +1638,7 @@ async function deleteMailbox ( await db.mailboxSecret.deleteMany({ mailbox }) await db.mailbox.deleteMany({ mailbox }) - await releaseSocialId(db, account, SocialIdType.EMAIL, mailbox) + await doReleaseSocialId(db, account, SocialIdType.EMAIL, mailbox, 'deleteMailbox') ctx.info('Mailbox deleted', { mailbox, account }) } diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index 62bce1f732..b13b815f91 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -63,7 +63,8 @@ import { addSocialId, getWorkspaces, updateWorkspaceRole, - getPersonName + getPersonName, + doReleaseSocialId } from './utils' // Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params @@ -518,6 +519,26 @@ export async function getPersonInfo ( } } +export async function releaseSocialId ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { personUuid: PersonUuid, type: SocialIdType, value: string } +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + + verifyAllowedServices(['github'], extra) + + const { personUuid, type, value } = params + + if (personUuid == null || !Object.values(SocialIdType).includes(type) || value == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + await doReleaseSocialId(db, personUuid, type, value, extra?.service ?? '') +} + export async function addSocialIdToPerson ( ctx: MeasureContext, db: AccountDB, @@ -811,6 +832,7 @@ export type AccountServiceMethods = | 'addSocialIdToPerson' | 'updateSocialId' | 'getPersonInfo' + | 'releaseSocialId' | 'createIntegration' | 'updateIntegration' | 'deleteIntegration' @@ -839,6 +861,7 @@ export function getServiceMethods (): Partial { const socialIds = await db.socialId.find({ personUuid, type, value }) + + if (socialIds.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, {})) + } + + const account = await db.account.findOne({ uuid: personUuid as AccountUuid }) + for (const socialId of socialIds) { await db.socialId.updateOne({ _id: socialId._id }, { value: `${socialId.value}#${socialId._id}`, isDeleted: true }) + if (account != null) { + await db.accountEvent.insertOne({ + accountUuid: account.uuid, + eventType: AccountEventType.SOCIAL_ID_RELEASED, + time: Date.now(), + data: { + socialId: socialId._id, + releasedBy: releasedBy ?? '' + } + }) + } } }