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' + }) + }) + }) })