uberf-9727: allow adding social id to existing person (#8439)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2025-04-02 18:57:57 +04:00 committed by GitHub
parent f26b1d3a12
commit fe5c74f298
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 297 additions and 8 deletions

View File

@ -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<PersonId>
setCookie: () => Promise<void>
deleteCookie: () => Promise<void>
@ -644,6 +645,20 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async addSocialIdToPerson (
person: PersonUuid,
type: SocialIdType,
value: string,
confirmed: boolean
): Promise<PersonId> {
const request = {
method: 'addSocialIdToPerson' as const,
params: { person, type, value, confirmed }
}
return await this.rpc(request)
}
async getMailboxOptions (): Promise<MailboxOptions> {
const request = {
method: 'getMailboxOptions' as const,

View File

@ -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<string>,

View File

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

View File

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

View File

@ -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<PersonId> {
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<Record<AccountMe
listWorkspaces: wrap(listWorkspaces),
performWorkspaceOperation: wrap(performWorkspaceOperation),
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
ensurePerson: wrap(ensurePerson)
ensurePerson: wrap(ensurePerson),
addSocialIdToPerson: wrap(addSocialIdToPerson)
}
}

View File

@ -1274,6 +1274,42 @@ export async function getInviteEmail (
}
}
export async function addSocialId (
db: AccountDB,
personUuid: PersonUuid,
type: SocialIdType,
value: string,
confirmed: boolean
): Promise<PersonId> {
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<SocialId, '_id' | 'key'> = {
type,
value: normalizedValue,
personUuid
}
if (confirmed) {
newSocialId.verifiedOn = Date.now()
}
return await db.socialId.insertOne(newSocialId)
}
export async function getWorkspaceRole (
db: AccountDB,
account: PersonUuid,