diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts
index 85e3e85696..d45450301a 100644
--- a/server/account/src/__tests__/utils.test.ts
+++ b/server/account/src/__tests__/utils.test.ts
@@ -13,7 +13,17 @@
// limitations under the License.
//
-import { AccountRole, MeasureContext } from '@hcengineering/core'
+import {
+ AccountRole,
+ Branding,
+ MeasureContext,
+ Person,
+ PersonId,
+ PersonUuid,
+ SocialIdType,
+ systemAccountUuid,
+ WorkspaceUuid
+} from '@hcengineering/core'
import {
generateWorkspaceUrl,
cleanEmail,
@@ -27,14 +37,40 @@ import {
hashWithSalt,
verifyPassword,
getAllTransactors,
- wrap
+ wrap,
+ isOtpValid,
+ sendOtpEmail,
+ sendOtp,
+ generateUniqueOtp,
+ getEmailSocialId,
+ confirmEmail,
+ sendEmailConfirmation,
+ GUEST_ACCOUNT,
+ selectWorkspace,
+ signUpByEmail,
+ getAccount,
+ getWorkspaceById,
+ getWorkspaceInfoWithStatusById,
+ updateArchiveInfo,
+ cleanExpiredOtp,
+ getWorkspaces,
+ verifyAllowedServices,
+ getPersonName,
+ getInviteEmail,
+ getFrontUrl,
+ getSesUrl,
+ getSocialIdByKey,
+ getWorkspaceInvite,
+ loginOrSignUpWithProvider,
+ sendEmail
} from '../utils'
// eslint-disable-next-line import/no-named-default
import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform'
-import { TokenError } from '@hcengineering/server-token'
+import { decodeTokenVerbose, generateToken, TokenError } from '@hcengineering/server-token'
import { randomBytes } from 'crypto'
-import { AccountDB } from '../types'
+import { AccountDB, AccountEventType, Workspace } from '../types'
+import { accountPlugin } from '../plugin'
// Mock platform with minimum required functionality
jest.mock('@hcengineering/platform', () => {
@@ -45,10 +81,17 @@ jest.mock('@hcengineering/platform', () => {
...actual.default,
getMetadata: jest.fn(),
addEventListener: jest.fn(),
- plugin: (id: string, plugin: any) => plugin // Simple plugin function mock
+ translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`)
}
})
+// Mock server-token
+jest.mock('@hcengineering/server-token', () => ({
+ TokenError: jest.requireActual('@hcengineering/server-token').TokenError,
+ decodeTokenVerbose: jest.fn(),
+ generateToken: jest.fn()
+}))
+
// Mock analytics
jest.mock('@hcengineering/analytics', () => ({
Analytics: {
@@ -564,4 +607,1300 @@ describe('account utils', () => {
await wrappedMethod(mockCtx, mockDb, mockBranding, request)
})
})
+
+ describe('with mocked fetch', () => {
+ const mockFetch = jest.fn(() => Promise.resolve({ ok: true }))
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ global.fetch = mockFetch as any
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('otp utils', () => {
+ const mockCtx = {
+ error: jest.fn()
+ } as unknown as MeasureContext
+
+ const mockBranding: Branding = {
+ language: 'en',
+ title: 'Test App'
+ }
+
+ const mockDb = {
+ otp: {
+ findOne: jest.fn(),
+ find: jest.fn(),
+ insertOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ const sesUrl = 'https://ses.example.com'
+ const sesAuth = 'test-auth-token'
+
+ const originalConsoleError = console.error
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ console.error = jest.fn()
+ ;(getMetadata as jest.Mock).mockImplementation((key) => {
+ switch (key) {
+ case accountPlugin.metadata.OtpRetryDelaySec:
+ return 30
+ case accountPlugin.metadata.OtpTimeToLiveSec:
+ return 60
+ case accountPlugin.metadata.SES_URL:
+ return sesUrl
+ case accountPlugin.metadata.SES_AUTH_TOKEN:
+ return sesAuth
+ case accountPlugin.metadata.ProductName:
+ return 'Test Product'
+ default:
+ return undefined
+ }
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ console.error = originalConsoleError
+ })
+
+ describe('generateUniqueOtp', () => {
+ test('should generate 6-digit numeric code', async () => {
+ ;(mockDb.otp.findOne as jest.Mock).mockResolvedValue(null)
+ const code = await generateUniqueOtp(mockDb)
+
+ expect(code).toMatch(/^\d{6}$/)
+ expect(mockDb.otp.findOne).toHaveBeenCalledWith({ code })
+ })
+
+ test('should retry if code exists', async () => {
+ ;(mockDb.otp.findOne as jest.Mock)
+ .mockResolvedValueOnce({ code: '123456' })
+ .mockResolvedValueOnce({ code: '234567' })
+ .mockResolvedValueOnce(null)
+
+ const code = await generateUniqueOtp(mockDb)
+
+ expect(code).toMatch(/^\d{6}$/)
+ expect(mockDb.otp.findOne).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('sendOtp', () => {
+ const mockSocialId = {
+ personUuid: '123456-uuid' as PersonUuid,
+ type: SocialIdType.EMAIL,
+ value: 'test@example.com',
+ key: 'email:test@example.com' as PersonId
+ }
+
+ test('should return existing OTP if not expired', async () => {
+ const now = Date.now()
+ const mockOtpData = {
+ code: '123456',
+ expiresOn: now + 60000,
+ createdOn: now - 15000
+ }
+ ;(mockDb.otp.find as jest.Mock).mockResolvedValue([mockOtpData])
+
+ const result = await sendOtp(mockCtx, mockDb, mockBranding, mockSocialId)
+
+ expect(result).toEqual({
+ sent: true,
+ retryOn: mockOtpData.createdOn + 30000
+ })
+ expect(mockDb.otp.insertOne).not.toHaveBeenCalled()
+ })
+
+ test('should generate and send new OTP if expired', async () => {
+ ;(mockDb.otp.find as jest.Mock).mockResolvedValue([])
+ ;(mockDb.otp.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await sendOtp(mockCtx, mockDb, mockBranding, mockSocialId)
+
+ expect(result).toEqual({
+ sent: true,
+ retryOn: expect.any(Number)
+ })
+ expect(mockDb.otp.insertOne).toHaveBeenCalledWith({
+ socialId: mockSocialId.key,
+ code: expect.any(String),
+ expiresOn: expect.any(Number),
+ createdOn: expect.any(Number)
+ })
+ })
+
+ test('should throw error for unsupported social id type', async () => {
+ const invalidSocialId = {
+ personUuid: '123456-uuid' as PersonUuid,
+ type: 'INVALID' as SocialIdType,
+ value: 'test',
+ key: 'invalid:test' as PersonId
+ }
+
+ await expect(sendOtp(mockCtx, mockDb, mockBranding, invalidSocialId)).rejects.toThrow(
+ 'Unsupported OTP social id type'
+ )
+ })
+ })
+
+ describe('sendOtpEmail', () => {
+ beforeEach(() => {
+ global.fetch = jest.fn()
+ })
+
+ test('should send email with OTP', async () => {
+ await sendOtpEmail(mockCtx, mockBranding, '123456', 'test@example.com')
+
+ expect(global.fetch).toHaveBeenCalledWith(`${sesUrl}/send`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${sesAuth}`
+ },
+ body: expect.stringContaining('123456')
+ })
+ })
+
+ test('should handle missing SES URL', async () => {
+ ;(getMetadata as jest.Mock).mockReturnValue(undefined)
+
+ await sendOtpEmail(mockCtx, mockBranding, '123456', 'test@example.com')
+
+ expect(mockCtx.error).toHaveBeenCalledWith('Please provide email service url to enable email otp')
+ expect(global.fetch).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('isOtpValid', () => {
+ test('should return true for valid non-expired OTP', async () => {
+ const mockOtpData = {
+ expiresOn: Date.now() + 60000
+ }
+ ;(mockDb.otp.findOne as jest.Mock).mockResolvedValue(mockOtpData)
+
+ const result = await isOtpValid(mockDb, 'email:test@example.com', '123456')
+ expect(result).toBe(true)
+ })
+
+ test('should return false for expired OTP', async () => {
+ const mockOtpData = {
+ expiresOn: Date.now() - 1000
+ }
+ ;(mockDb.otp.findOne as jest.Mock).mockResolvedValue(mockOtpData)
+
+ const result = await isOtpValid(mockDb, 'email:test@example.com', '123456')
+ expect(result).toBe(false)
+ })
+
+ test('should return false for non-existent OTP', async () => {
+ ;(mockDb.otp.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await isOtpValid(mockDb, 'email:test@example.com', '123456')
+ expect(result).toBe(false)
+ })
+ })
+ })
+
+ describe('email confirmation utils', () => {
+ const mockCtx = {
+ error: jest.fn(),
+ info: jest.fn()
+ } as unknown as MeasureContext
+
+ const mockBranding: Branding = {
+ language: 'en',
+ title: 'Test App',
+ front: 'https://app.example.com'
+ }
+
+ const mockDb = {
+ socialId: {
+ findOne: jest.fn(),
+ updateOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockFetch.mockResolvedValue({ ok: true })
+ ;(getMetadata as jest.Mock).mockImplementation((key) => {
+ switch (key) {
+ case accountPlugin.metadata.SES_URL:
+ return 'https://ses.example.com'
+ case accountPlugin.metadata.SES_AUTH_TOKEN:
+ return 'test-auth-token'
+ case accountPlugin.metadata.ProductName:
+ return 'Test Product'
+ case accountPlugin.metadata.FrontURL:
+ return 'https://app.example.com'
+ default:
+ return undefined
+ }
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('sendEmailConfirmation', () => {
+ const account = 'test-account-id' as PersonUuid
+ const email = 'test@example.com'
+
+ test('should send confirmation email with correct link', async () => {
+ await sendEmailConfirmation(mockCtx, mockBranding, account, email)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://ses.example.com/send',
+ expect.objectContaining({
+ method: 'post',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer test-auth-token'
+ },
+ body: expect.stringContaining('https://app.example.com/login/confirm')
+ })
+ )
+ })
+
+ test('should throw error if SES_URL is missing', async () => {
+ ;(getMetadata as jest.Mock).mockReturnValue(undefined)
+
+ await expect(sendEmailConfirmation(mockCtx, mockBranding, account, email)).rejects.toThrow(
+ new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
+ )
+
+ expect(mockCtx.error).toHaveBeenCalledWith('Please provide SES_URL to enable email confirmations.')
+ })
+
+ test('should use branding front URL if available', async () => {
+ const brandingWithFront = {
+ ...mockBranding,
+ front: 'https://custom.example.com'
+ }
+
+ await sendEmailConfirmation(mockCtx, brandingWithFront, account, email)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('https://custom.example.com/login/confirm')
+ })
+ )
+ })
+ })
+
+ describe('confirmEmail', () => {
+ const account = 'test-account-id'
+ const email = 'test@example.com'
+
+ test('should confirm unverified email', async () => {
+ const mockSocialId = {
+ key: 'email:test@example.com',
+ type: SocialIdType.EMAIL,
+ value: email,
+ verifiedOn: null
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+
+ await confirmEmail(mockCtx, mockDb, account, email)
+
+ expect(mockDb.socialId.updateOne).toHaveBeenCalledWith(
+ { key: mockSocialId.key },
+ { verifiedOn: expect.any(Number) }
+ )
+ })
+
+ test('should throw error if email not found', async () => {
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+
+ await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
+ new PlatformError(
+ new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
+ socialId: email,
+ type: SocialIdType.EMAIL
+ })
+ )
+ )
+ })
+
+ test('should throw error if email already confirmed', async () => {
+ const mockSocialId = {
+ key: 'email:test@example.com',
+ type: SocialIdType.EMAIL,
+ value: email,
+ verifiedOn: Date.now()
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+
+ await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
+ new PlatformError(
+ new Status(Severity.ERROR, platform.status.SocialIdAlreadyConfirmed, {
+ socialId: email,
+ type: SocialIdType.EMAIL
+ })
+ )
+ )
+ })
+
+ test('should normalize email before confirmation', async () => {
+ const mockSocialId = {
+ key: 'email:test@example.com',
+ type: SocialIdType.EMAIL,
+ value: 'test@example.com',
+ verifiedOn: null
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+
+ await confirmEmail(mockCtx, mockDb, account, ' TEST@EXAMPLE.COM ')
+
+ expect(mockDb.socialId.findOne).toHaveBeenCalledWith({
+ type: SocialIdType.EMAIL,
+ value: 'test@example.com'
+ })
+ })
+ })
+
+ describe('getEmailSocialId', () => {
+ test('should find social id by email', async () => {
+ const mockSocialId = {
+ key: 'email:test@example.com',
+ type: SocialIdType.EMAIL,
+ value: 'test@example.com'
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+
+ const result = await getEmailSocialId(mockDb, 'test@example.com')
+ expect(result).toEqual(mockSocialId)
+ expect(mockDb.socialId.findOne).toHaveBeenCalledWith({
+ type: SocialIdType.EMAIL,
+ value: 'test@example.com'
+ })
+ })
+
+ test('should return null if email not found', async () => {
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await getEmailSocialId(mockDb, 'nonexistent@example.com')
+ expect(result).toBeNull()
+ })
+ })
+ })
+
+ describe('sendEmail', () => {
+ beforeEach(() => {
+ ;(getMetadata as jest.Mock).mockImplementation((key) => {
+ switch (key) {
+ case accountPlugin.metadata.SES_URL:
+ return 'https://ses.example.com'
+ case accountPlugin.metadata.SES_AUTH_TOKEN:
+ return 'test-token'
+ default:
+ return undefined
+ }
+ })
+ })
+
+ test('should send email with correct parameters', async () => {
+ const emailInfo = {
+ text: 'Test email',
+ html: '
Test email
',
+ subject: 'Test Subject',
+ to: 'test@example.com'
+ }
+
+ await sendEmail(emailInfo)
+
+ expect(mockFetch).toHaveBeenCalledWith('https://ses.example.com/send', {
+ method: 'post',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer test-token'
+ },
+ body: JSON.stringify(emailInfo)
+ })
+ })
+ })
+ })
+
+ describe('selectWorkspace', () => {
+ const mockCtx = {
+ error: jest.fn(),
+ info: jest.fn()
+ } as unknown as MeasureContext
+
+ const mockBranding = null
+
+ const mockDb = {
+ account: {
+ findOne: jest.fn() as jest.MockedFunction
+ },
+ workspace: {
+ findOne: jest.fn() as jest.MockedFunction
+ },
+ workspaceStatus: {
+ findOne: jest.fn() as jest.MockedFunction
+ },
+ getWorkspaceRole: jest.fn() as jest.MockedFunction,
+ person: {
+ findOne: jest.fn() as jest.MockedFunction
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(generateToken as jest.Mock).mockReturnValue('new-token')
+ })
+
+ describe('guest access', () => {
+ const guestToken = 'guest-token'
+ const workspaceUrl = 'test-workspace'
+ const mockWorkspace = {
+ uuid: 'workspace-uuid' as WorkspaceUuid,
+ url: workspaceUrl,
+ region: 'us',
+ dataId: 'test-data-id'
+ }
+
+ beforeEach(() => {
+ ;(decodeTokenVerbose as jest.Mock).mockReturnValue({
+ account: GUEST_ACCOUNT,
+ workspace: 'workspace-uuid',
+ extra: { guest: 'true' }
+ })
+ ;(getMetadata as jest.Mock).mockReturnValue('http://internal:3000;http://external:3000;us')
+ })
+
+ test('should handle guest access to existing workspace', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+
+ const result = await selectWorkspace(mockCtx, mockDb, mockBranding, guestToken, workspaceUrl, 'external')
+
+ expect(result).toEqual({
+ account: GUEST_ACCOUNT,
+ endpoint: expect.any(String),
+ token: guestToken,
+ workspace: mockWorkspace.uuid,
+ workspaceUrl: mockWorkspace.url,
+ workspaceDataId: mockWorkspace.dataId,
+ role: AccountRole.DocGuest
+ })
+ })
+
+ test('should throw error if workspace not found', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
+
+ await expect(
+ selectWorkspace(mockCtx, mockDb, mockBranding, guestToken, workspaceUrl, 'external')
+ ).rejects.toThrow(
+ new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
+ )
+
+ expect(mockCtx.error).toHaveBeenCalledWith('Workspace not found in selectWorkspace', expect.any(Object))
+ })
+ })
+
+ describe('system account', () => {
+ const systemToken = 'system-token'
+ const workspaceUrl = 'test-workspace'
+ const mockWorkspace = {
+ uuid: 'workspace-uuid' as WorkspaceUuid,
+ url: workspaceUrl,
+ region: 'us'
+ }
+
+ beforeEach(() => {
+ ;(decodeTokenVerbose as jest.Mock).mockReturnValue({
+ account: systemAccountUuid,
+ workspace: 'workspace-uuid',
+ extra: {}
+ })
+ ;(getMetadata as jest.Mock).mockReturnValue('http://internal:3000;http://external:3000;us')
+ })
+
+ test('should handle system account access', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+
+ const result = await selectWorkspace(mockCtx, mockDb, mockBranding, systemToken, workspaceUrl, 'external')
+
+ expect(result).toEqual({
+ account: systemAccountUuid,
+ token: 'new-token',
+ endpoint: 'http://external:3000',
+ workspace: mockWorkspace.uuid,
+ workspaceUrl: mockWorkspace.url,
+ role: AccountRole.Owner
+ })
+ })
+ })
+
+ describe('regular user access', () => {
+ const userToken = 'user-token'
+ const workspaceUrl = 'test-workspace'
+ const mockWorkspace = {
+ uuid: 'workspace-uuid' as WorkspaceUuid,
+ url: workspaceUrl,
+ region: 'us',
+ dataId: 'test-data-id'
+ }
+ const mockAccount = { uuid: 'user-uuid' as PersonUuid }
+ const mockPerson = { uuid: 'user-uuid' as PersonUuid }
+
+ beforeEach(() => {
+ ;(decodeTokenVerbose as jest.Mock).mockReturnValue({
+ account: mockAccount.uuid,
+ workspace: 'workspace-uuid',
+ extra: {}
+ })
+ ;(getMetadata as jest.Mock).mockReturnValue('http://internal:3000;http://external:3000;us')
+ })
+
+ test('should handle regular user access', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
+ ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
+ ;(mockDb.workspaceStatus.findOne as jest.Mock).mockResolvedValue({ isDisabled: false })
+ ;(mockDb.person.findOne as jest.Mock).mockResolvedValue(mockPerson)
+
+ const result = await selectWorkspace(mockCtx, mockDb, mockBranding, userToken, workspaceUrl, 'external')
+
+ expect(result).toEqual({
+ account: mockAccount.uuid,
+ token: 'new-token',
+ endpoint: expect.any(String),
+ workspace: mockWorkspace.uuid,
+ workspaceUrl: mockWorkspace.url,
+ workspaceDataId: mockWorkspace.dataId,
+ role: AccountRole.User
+ })
+ })
+
+ test('should throw error if account not found', async () => {
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
+
+ await expect(
+ selectWorkspace(mockCtx, mockDb, mockBranding, userToken, workspaceUrl, 'external')
+ ).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})))
+ })
+
+ test('should throw error if workspace not found', async () => {
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
+
+ await expect(
+ selectWorkspace(mockCtx, mockDb, mockBranding, userToken, workspaceUrl, 'external')
+ ).rejects.toThrow(
+ new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
+ )
+ })
+
+ test('should throw error if user not a member', async () => {
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+ ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(null)
+
+ await expect(
+ selectWorkspace(mockCtx, mockDb, mockBranding, userToken, workspaceUrl, 'external')
+ ).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
+
+ expect(mockCtx.error).toHaveBeenCalledWith('Not a member of the workspace being selected', expect.any(Object))
+ })
+
+ test('should throw error if workspace is disabled', async () => {
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+ ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
+ ;(mockDb.workspaceStatus.findOne as jest.Mock).mockResolvedValue({ isDisabled: true, mode: 'active' })
+
+ await expect(
+ selectWorkspace(mockCtx, mockDb, mockBranding, userToken, workspaceUrl, 'external')
+ ).rejects.toThrow(
+ new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
+ )
+
+ expect(mockCtx.error).toHaveBeenCalledWith('Selecting a disabled workspace', expect.any(Object))
+ })
+ })
+ })
+
+ describe('signUpByEmail', () => {
+ const mockCtx = {
+ error: jest.fn()
+ } as unknown as MeasureContext
+
+ const mockBranding = null
+
+ const mockDb = {
+ socialId: {
+ findOne: jest.fn() as jest.MockedFunction,
+ insertOne: jest.fn() as jest.MockedFunction
+ },
+ account: {
+ findOne: jest.fn() as jest.MockedFunction,
+ insertOne: jest.fn() as jest.MockedFunction
+ },
+ person: {
+ insertOne: jest.fn() as jest.MockedFunction,
+ updateOne: jest.fn() as jest.MockedFunction
+ },
+ accountEvent: {
+ insertOne: jest.fn() as jest.MockedFunction
+ },
+ setPassword: jest.fn() as jest.MockedFunction
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should create new account when email does not exist', async () => {
+ const email = 'new@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'new-person-uuid' as PersonUuid
+
+ // Mock no existing social id
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+ // Mock person creation
+ ;(mockDb.person.insertOne as jest.Mock).mockResolvedValue(personUuid)
+
+ const result = await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)
+
+ expect(result).toBe(personUuid)
+ expect(mockDb.person.insertOne).toHaveBeenCalledWith({ firstName, lastName })
+ expect(mockDb.socialId.insertOne).toHaveBeenCalledWith({
+ type: SocialIdType.EMAIL,
+ value: email,
+ personUuid,
+ verifiedOn: undefined
+ })
+ expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid })
+ expect(mockDb.setPassword).toHaveBeenCalledWith(personUuid, expect.any(Buffer), expect.any(Buffer))
+ })
+
+ test('should create account for existing email without account', async () => {
+ const email = 'existing@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'existing-person-uuid' as PersonUuid
+
+ // Mock existing social id but no account
+ const mockSocialId = {
+ personUuid,
+ type: SocialIdType.EMAIL,
+ value: email
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)
+
+ expect(result).toBe(personUuid)
+ expect(mockDb.person.updateOne).toHaveBeenCalledWith({ uuid: personUuid }, { firstName, lastName })
+ expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid })
+ expect(mockDb.setPassword).toHaveBeenCalledWith(personUuid, expect.any(Buffer), expect.any(Buffer))
+ })
+
+ test('should throw error if account already exists', async () => {
+ const email = 'existing@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'existing-person-uuid' as PersonUuid
+
+ // Mock existing social id and account
+ const mockSocialId = {
+ personUuid,
+ type: SocialIdType.EMAIL,
+ value: email
+ }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue({ uuid: personUuid })
+
+ await expect(signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)).rejects.toThrow(
+ new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, {}))
+ )
+
+ expect(mockCtx.error).toHaveBeenCalledWith('An account with the provided email already exists', { email })
+ })
+
+ test('should handle confirmed status', async () => {
+ const email = 'new@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'new-person-uuid' as PersonUuid
+ const confirmed = true
+
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+ ;(mockDb.person.insertOne as jest.Mock).mockResolvedValue(personUuid)
+
+ await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName, confirmed)
+
+ expect(mockDb.socialId.insertOne).toHaveBeenCalledWith({
+ type: SocialIdType.EMAIL,
+ value: email,
+ personUuid,
+ verifiedOn: expect.any(Number)
+ })
+ })
+
+ test('should normalize email before processing', async () => {
+ const email = ' TEST@EXAMPLE.COM '
+ const normalizedEmail = 'test@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+
+ await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)
+
+ expect(mockDb.socialId.findOne).toHaveBeenCalledWith({
+ type: SocialIdType.EMAIL,
+ value: normalizedEmail
+ })
+ expect(mockDb.socialId.insertOne).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: normalizedEmail
+ })
+ )
+ })
+
+ test('should create HULY social id when creating account', async () => {
+ const email = 'new@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'new-person-uuid' as PersonUuid
+
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+ ;(mockDb.person.insertOne as jest.Mock).mockResolvedValue(personUuid)
+
+ await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)
+
+ // Verify HULY social id creation
+ expect(mockDb.socialId.insertOne).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: SocialIdType.HULY,
+ value: personUuid,
+ personUuid
+ })
+ )
+ })
+
+ test('should create account event', async () => {
+ const email = 'new@example.com'
+ const password = 'password123'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'new-person-uuid' as PersonUuid
+
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+ ;(mockDb.person.insertOne as jest.Mock).mockResolvedValue(personUuid)
+
+ await signUpByEmail(mockCtx, mockDb, mockBranding, email, password, firstName, lastName)
+
+ expect(mockDb.accountEvent.insertOne).toHaveBeenCalledWith({
+ accountUuid: personUuid,
+ eventType: AccountEventType.ACCOUNT_CREATED,
+ time: expect.any(Number)
+ })
+ })
+ })
+
+ describe('getAccount', () => {
+ const mockDb = {
+ account: {
+ findOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should return account when found', async () => {
+ const mockAccount = { uuid: 'test-uuid' as PersonUuid }
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
+
+ const result = await getAccount(mockDb, 'test-uuid' as PersonUuid)
+ expect(result).toEqual(mockAccount)
+ expect(mockDb.account.findOne).toHaveBeenCalledWith({ uuid: 'test-uuid' })
+ })
+
+ test('should return null when account not found', async () => {
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await getAccount(mockDb, 'nonexistent-uuid' as PersonUuid)
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('workspace utils', () => {
+ const mockDb = {
+ workspace: {
+ findOne: jest.fn(),
+ find: jest.fn()
+ },
+ workspaceStatus: {
+ findOne: jest.fn(),
+ find: jest.fn(),
+ updateOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('getWorkspaceById', () => {
+ test('should return workspace when found', async () => {
+ const mockWorkspace = { uuid: 'test-uuid' as WorkspaceUuid }
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+
+ const result = await getWorkspaceById(mockDb, 'test-uuid' as WorkspaceUuid)
+ expect(result).toEqual(mockWorkspace)
+ expect(mockDb.workspace.findOne).toHaveBeenCalledWith({ uuid: 'test-uuid' })
+ })
+
+ test('should return null when workspace not found', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await getWorkspaceById(mockDb, 'nonexistent-uuid' as WorkspaceUuid)
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('getWorkspaceInfoWithStatusById', () => {
+ test('should return combined workspace and status info', async () => {
+ const mockWorkspace = { uuid: 'test-uuid' as WorkspaceUuid }
+ const mockStatus = { workspaceUuid: 'test-uuid', mode: 'active' }
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+ ;(mockDb.workspaceStatus.findOne as jest.Mock).mockResolvedValue(mockStatus)
+
+ const result = await getWorkspaceInfoWithStatusById(mockDb, 'test-uuid' as WorkspaceUuid)
+ expect(result).toEqual({
+ ...mockWorkspace,
+ status: mockStatus
+ })
+ })
+
+ test('should return null if workspace not found', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
+ ;(mockDb.workspaceStatus.findOne as jest.Mock).mockResolvedValue({ mode: 'active' })
+
+ const result = await getWorkspaceInfoWithStatusById(mockDb, 'test-uuid' as WorkspaceUuid)
+ expect(result).toBeNull()
+ })
+
+ test('should return null if status not found', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-uuid' })
+ ;(mockDb.workspaceStatus.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await getWorkspaceInfoWithStatusById(mockDb, 'test-uuid' as WorkspaceUuid)
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('updateArchiveInfo', () => {
+ const mockCtx = {
+ error: jest.fn()
+ } as unknown as MeasureContext
+
+ test('should update workspace status to archived', async () => {
+ const mockWorkspace = { uuid: 'test-uuid' as WorkspaceUuid }
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
+
+ await updateArchiveInfo(mockCtx, mockDb, 'test-uuid' as WorkspaceUuid, true)
+
+ expect(mockDb.workspaceStatus.updateOne).toHaveBeenCalledWith(
+ { workspaceUuid: 'test-uuid' },
+ { mode: 'archived' }
+ )
+ })
+
+ test('should throw error if workspace not found', async () => {
+ ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
+
+ await expect(updateArchiveInfo(mockCtx, mockDb, 'nonexistent' as WorkspaceUuid, true)).rejects.toThrow(
+ new PlatformError(
+ new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: 'nonexistent' })
+ )
+ )
+ })
+ })
+ })
+
+ describe('cleanExpiredOtp', () => {
+ const mockDb = {
+ otp: {
+ deleteMany: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should delete expired OTP records', async () => {
+ await cleanExpiredOtp(mockDb)
+
+ expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({
+ expiresOn: { $lte: expect.any(Number) }
+ })
+ })
+ })
+
+ describe('getWorkspaces', () => {
+ const mockDb = {
+ workspace: {
+ find: jest.fn()
+ },
+ workspaceStatus: {
+ find: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should return all workspaces with status when no filters provided', async () => {
+ const mockWorkspaces = [{ uuid: 'ws1' as WorkspaceUuid }, { uuid: 'ws2' as WorkspaceUuid }]
+ const mockStatuses = [
+ { workspaceUuid: 'ws1', isDisabled: false, mode: 'active' },
+ { workspaceUuid: 'ws2', isDisabled: true, mode: 'archived' }
+ ]
+ ;(mockDb.workspace.find as jest.Mock).mockResolvedValue(mockWorkspaces)
+ ;(mockDb.workspaceStatus.find as jest.Mock).mockResolvedValue(mockStatuses)
+
+ const result = await getWorkspaces(mockDb)
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({
+ ...mockWorkspaces[0],
+ status: mockStatuses[0]
+ })
+ })
+
+ test('should filter by disabled status', async () => {
+ const mockWorkspaces = [{ uuid: 'ws1' as WorkspaceUuid }, { uuid: 'ws2' as WorkspaceUuid }]
+ const mockStatuses = [
+ { workspaceUuid: 'ws1', isDisabled: false, mode: 'active' },
+ { workspaceUuid: 'ws2', isDisabled: true, mode: 'active' }
+ ]
+ ;(mockDb.workspace.find as jest.Mock).mockResolvedValue(mockWorkspaces)
+ ;(mockDb.workspaceStatus.find as jest.Mock).mockResolvedValue(mockStatuses)
+
+ const result = await getWorkspaces(mockDb, true)
+ expect(result).toHaveLength(1)
+ expect(result[0].uuid).toBe('ws2')
+ })
+
+ test('should filter by region', async () => {
+ const region = 'us-east'
+ ;(mockDb.workspace.find as jest.Mock).mockResolvedValue([])
+ ;(mockDb.workspaceStatus.find as jest.Mock).mockResolvedValue([])
+
+ await getWorkspaces(mockDb, null, region)
+ expect(mockDb.workspace.find).toHaveBeenCalledWith({ region })
+ })
+
+ test('should filter by mode', async () => {
+ const mockWorkspaces = [{ uuid: 'ws1' as WorkspaceUuid }, { uuid: 'ws2' as WorkspaceUuid }]
+ const mockStatuses = [
+ { workspaceUuid: 'ws1', isDisabled: false, mode: 'active' },
+ { workspaceUuid: 'ws2', isDisabled: false, mode: 'archived' }
+ ]
+ ;(mockDb.workspace.find as jest.Mock).mockResolvedValue(mockWorkspaces)
+ ;(mockDb.workspaceStatus.find as jest.Mock).mockResolvedValue(mockStatuses)
+
+ const result = await getWorkspaces(mockDb, null, null, 'active')
+ expect(result).toHaveLength(1)
+ expect(result[0].uuid).toBe('ws1')
+ })
+ })
+
+ describe('verifyAllowedServices', () => {
+ test('should not throw for allowed service', () => {
+ const services = ['service1', 'service2']
+ const extra = { service: 'service1' }
+
+ expect(() => {
+ verifyAllowedServices(services, extra)
+ }).not.toThrow()
+ })
+
+ test('should not throw for admin', () => {
+ const services = ['service1']
+ const extra = { service: 'service2', admin: 'true' }
+
+ expect(() => {
+ verifyAllowedServices(services, extra)
+ }).not.toThrow()
+ })
+
+ test('should throw for unauthorized service', () => {
+ const services = ['service1']
+ const extra = { service: 'service2' }
+
+ expect(() => {
+ verifyAllowedServices(services, extra)
+ }).toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
+ })
+ })
+
+ describe('getPersonName', () => {
+ test('should combine first and last name', () => {
+ const person: Person = {
+ uuid: 'test-uuid' as PersonUuid,
+ firstName: 'John',
+ lastName: 'Doe'
+ }
+
+ expect(getPersonName(person)).toBe('John Doe')
+ })
+ })
+
+ describe('loginOrSignUpWithProvider', () => {
+ const mockCtx = {
+ error: jest.fn()
+ } as unknown as MeasureContext
+
+ const mockDb = {
+ socialId: {
+ findOne: jest.fn(),
+ insertOne: jest.fn(),
+ updateOne: jest.fn()
+ },
+ account: {
+ findOne: jest.fn(),
+ insertOne: jest.fn()
+ },
+ accountEvent: {
+ findOne: jest.fn(),
+ insertOne: jest.fn()
+ },
+ person: {
+ findOne: jest.fn(),
+ insertOne: jest.fn(),
+ updateOne: jest.fn()
+ },
+ resetPassword: jest.fn()
+ } as unknown as AccountDB
+
+ const mockBranding = null
+ const mockSocialId = { type: SocialIdType.GOOGLE, value: 'test-id' }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(generateToken as jest.Mock).mockReturnValue('new-token')
+ })
+
+ test('should create new account when no social ids exist', async () => {
+ const email = 'test@example.com'
+ const firstName = 'John'
+ const lastName = 'Doe'
+ const personUuid = 'new-person' as PersonUuid
+
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+ ;(mockDb.person.insertOne as jest.Mock).mockResolvedValue(personUuid)
+ ;(mockDb.person.findOne as jest.Mock).mockResolvedValue({ firstName, lastName })
+ ;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await loginOrSignUpWithProvider(
+ mockCtx,
+ mockDb,
+ mockBranding,
+ email,
+ firstName,
+ lastName,
+ mockSocialId
+ )
+
+ expect(result).toEqual({
+ account: personUuid,
+ socialId: expect.any(String),
+ name: 'John Doe',
+ token: 'new-token'
+ })
+ })
+
+ test('should return null when signup disabled and no account exists', async () => {
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await loginOrSignUpWithProvider(
+ mockCtx,
+ mockDb,
+ mockBranding,
+ 'test@example.com',
+ 'John',
+ 'Doe',
+ mockSocialId,
+ true
+ )
+
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('getWorkspaceInvite', () => {
+ const mockDb = {
+ invite: {
+ findOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should return invite when found by id', async () => {
+ const mockInvite = { id: 'test-invite', workspaceUuid: 'ws1' as WorkspaceUuid }
+ ;(mockDb.invite.findOne as jest.Mock).mockResolvedValueOnce(mockInvite)
+
+ const result = await getWorkspaceInvite(mockDb, 'test-invite')
+ expect(result).toEqual(mockInvite)
+ expect(mockDb.invite.findOne).toHaveBeenCalledWith({ id: 'test-invite' })
+ })
+
+ test('should check migrated invites when not found by id', async () => {
+ const mockInvite = { id: 'new-id', migratedFrom: 'old-id' }
+ ;(mockDb.invite.findOne as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(mockInvite)
+
+ const result = await getWorkspaceInvite(mockDb, 'old-id')
+ expect(result).toEqual(mockInvite)
+ expect(mockDb.invite.findOne).toHaveBeenCalledWith({ migratedFrom: 'old-id' })
+ })
+
+ test('should return null when invite not found', async () => {
+ ;(mockDb.invite.findOne as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
+
+ const result = await getWorkspaceInvite(mockDb, 'nonexistent')
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('getSocialIdByKey', () => {
+ const mockDb = {
+ socialId: {
+ findOne: jest.fn()
+ }
+ } as unknown as AccountDB
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should return social id when found', async () => {
+ const mockSocialId = { key: 'email:test@example.com' as PersonId }
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(mockSocialId)
+
+ const result = await getSocialIdByKey(mockDb, 'email:test@example.com' as PersonId)
+ expect(result).toEqual(mockSocialId)
+ expect(mockDb.socialId.findOne).toHaveBeenCalledWith({ key: 'email:test@example.com' })
+ })
+
+ test('should return null when social id not found', async () => {
+ ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null)
+
+ const result = await getSocialIdByKey(mockDb, 'nonexistent' as PersonId)
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('getSesUrl', () => {
+ beforeEach(() => {
+ ;(getMetadata as jest.Mock).mockImplementation((key) => {
+ switch (key) {
+ case accountPlugin.metadata.SES_URL:
+ return 'https://ses.example.com'
+ case accountPlugin.metadata.SES_AUTH_TOKEN:
+ return 'test-token'
+ default:
+ return undefined
+ }
+ })
+ })
+
+ afterEach(() => {
+ ;(getMetadata as jest.Mock).mockReset()
+ })
+
+ test('should return SES URL and auth token when configured', () => {
+ const result = getSesUrl()
+ expect(result).toEqual({
+ sesURL: 'https://ses.example.com',
+ sesAuth: 'test-token'
+ })
+ })
+
+ test('should throw error when SES URL not configured', () => {
+ ;(getMetadata as jest.Mock).mockReturnValue(undefined)
+
+ expect(() => getSesUrl()).toThrow('Please provide email service url')
+ })
+ })
+
+ describe('getFrontUrl', () => {
+ const mockBranding: Branding = {
+ front: 'https://custom.example.com'
+ }
+
+ beforeEach(() => {
+ ;(getMetadata as jest.Mock).mockImplementation((key) => {
+ if (key === accountPlugin.metadata.FrontURL) {
+ return 'https://default.example.com'
+ }
+ return undefined
+ })
+ })
+
+ test('should return branding front URL when available', () => {
+ const result = getFrontUrl(mockBranding)
+ expect(result).toBe('https://custom.example.com')
+ })
+
+ test('should return FRONT_URL when no branding front URL', () => {
+ const result = getFrontUrl(null)
+ expect(result).toBe('https://default.example.com')
+ })
+
+ test('should throw error when no URL available', () => {
+ ;(getMetadata as jest.Mock).mockReturnValue(undefined)
+ expect(() => getFrontUrl(null)).toThrow('Please provide front url')
+ })
+ })
+
+ describe('getInviteEmail', () => {
+ const mockBranding: Branding = {
+ language: 'en',
+ front: 'https://app.example.com'
+ }
+
+ const mockWorkspace: Workspace = {
+ uuid: 'test-workspace-uuid' as WorkspaceUuid,
+ name: 'Test Workspace',
+ url: 'test-workspace'
+ }
+
+ test('should generate invite email content', async () => {
+ const result = await getInviteEmail(mockBranding, 'test@example.com', 'invite-id', mockWorkspace, 24)
+
+ expect(result).toEqual({
+ text: expect.any(String),
+ html: expect.any(String),
+ subject: expect.any(String),
+ to: 'test@example.com'
+ })
+ })
+ })
})