uberf-10228: expose release social id to services (#8562)

This commit is contained in:
Alexey Zinoviev 2025-04-15 18:06:59 +04:00 committed by GitHub
parent 498551f4e3
commit 163295783b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 229 additions and 10 deletions

View File

@ -152,6 +152,7 @@ export interface AccountClient {
) => Promise<PersonId>
updateSocialId: (personId: PersonId, displayValue: string) => Promise<PersonId>
exchangeGuestToken: (token: string) => Promise<string>
releaseSocialId: (personUuid: PersonUuid, type: SocialIdType, value: string) => Promise<void>
createIntegration: (integration: Integration) => Promise<void>
updateIntegration: (integration: Integration) => Promise<void>
deleteIntegration: (integrationKey: IntegrationKey) => Promise<void>
@ -781,6 +782,15 @@ class AccountClientImpl implements AccountClient {
await this.rpc(request)
}
async releaseSocialId (personUuid: PersonUuid, type: SocialIdType, value: string): Promise<void> {
const request = {
method: 'releaseSocialId' as const,
params: { personUuid, type, value }
}
await this.rpc(request)
}
async createIntegration (integration: Integration): Promise<void> {
const request = {
method: 'createIntegration' as const,

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<Record<AccountServiceMethods, Acco
addSocialIdToPerson: wrap(addSocialIdToPerson),
updateSocialId: wrap(updateSocialId),
getPersonInfo: wrap(getPersonInfo),
releaseSocialId: wrap(releaseSocialId),
createIntegration: wrap(createIntegration),
updateIntegration: wrap(updateIntegration),
deleteIntegration: wrap(deleteIntegration),

View File

@ -68,7 +68,8 @@ export interface AccountEvent {
}
export enum AccountEventType {
ACCOUNT_CREATED = 'account_created'
ACCOUNT_CREATED = 'account_created',
SOCIAL_ID_RELEASED = 'social_id_released'
}
export interface Member {

View File

@ -1370,15 +1370,34 @@ export async function addSocialId (
return await db.socialId.insertOne(newSocialId)
}
export async function releaseSocialId (
export async function doReleaseSocialId (
db: AccountDB,
personUuid: PersonUuid,
type: SocialIdType,
value: string
value: string,
releasedBy: string
): Promise<void> {
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 ?? ''
}
})
}
}
}