platform/server/account/src/__tests__/operations.test.ts
2025-03-25 09:15:09 +03:00

458 lines
17 KiB
TypeScript

//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// src/__tests__/operations.test.ts
import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core'
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token'
import { AccountDB } from '../types'
import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations'
import { accountPlugin } from '../plugin'
// Mock platform
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
...actual,
...actual.default,
getMetadata: jest.fn(),
translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`)
}
})
// Mock server-token
jest.mock('@hcengineering/server-token', () => ({
decodeTokenVerbose: jest.fn(),
generateToken: jest.fn()
}))
describe('invite operations', () => {
const mockCtx = {
error: jest.fn(),
info: jest.fn()
} as unknown as MeasureContext
const mockBranding = null
const mockDb = {
account: {
findOne: jest.fn()
},
workspace: {
findOne: jest.fn()
},
invite: {
insertOne: jest.fn(),
findOne: jest.fn(),
updateOne: jest.fn()
},
getWorkspaceRole: jest.fn(),
person: {
findOne: jest.fn()
}
} as unknown as AccountDB
const mockToken = 'test-token'
const mockAccount = { uuid: 'account-uuid' as PersonUuid }
const mockWorkspace = {
uuid: 'workspace-uuid' as WorkspaceUuid,
name: 'Test Workspace',
url: 'test-workspace'
}
beforeEach(() => {
jest.clearAllMocks()
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: {}
})
})
describe('createInvite', () => {
test('should create invite for authorized maintainer', async () => {
const inviteId = 'new-invite-id'
const beforeTest = Date.now()
const expectedExpiration = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInvite(mockCtx, mockDb, mockBranding, mockToken, {
exp: expectedExpiration,
email: 'test@example.com',
limit: 1,
role: AccountRole.User
})
const afterTest = Date.now()
expect(result).toBe(inviteId)
expect(mockDb.invite.insertOne).toHaveBeenCalledWith({
workspaceUuid: mockWorkspace.uuid,
expiresOn: expect.any(Number),
email: 'test@example.com',
remainingUses: 1,
role: AccountRole.User,
autoJoin: undefined
})
// Get the actual expiresOn value from the call
const actualCall = (mockDb.invite.insertOne as jest.Mock).mock.calls[0][0]
expect(actualCall.expiresOn).toBeGreaterThanOrEqual(beforeTest + expectedExpiration)
expect(actualCall.expiresOn).toBeLessThanOrEqual(afterTest + expectedExpiration)
})
test('should throw error if caller role is insufficient', 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)
await expect(
createInvite(mockCtx, mockDb, mockBranding, mockToken, {
exp: 24 * 60 * 60 * 1000,
email: 'test@example.com',
limit: 1,
role: AccountRole.Owner
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
})
describe('createInviteLink', () => {
const frontUrl = 'https://app.example.com'
beforeEach(() => {
;(getMetadata as jest.Mock).mockImplementation((key) => {
if (key === accountPlugin.metadata.FrontURL) return frontUrl
return undefined
})
})
test('should create basic invite link', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}`)
})
test('should create link with auto-join parameters', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John',
lastName: 'Doe'
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}&autoJoin&firstName=John&lastName=Doe`)
})
test('should create link with redirect parameter', async () => {
const inviteId = 'new-invite-id'
const navigateUrl = '/workspace/calendar'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
navigateUrl
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}&navigateUrl=${encodeURIComponent(navigateUrl)}`)
})
test('should create link with all parameters', async () => {
const inviteId = 'new-invite-id'
const navigateUrl = '/workspace/settings?tab=members'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John',
lastName: 'Doe',
navigateUrl
})
expect(result).toBe(
`${frontUrl}/login/join?inviteId=${inviteId}&autoJoin&firstName=John&lastName=Doe&navigateUrl=${encodeURIComponent(navigateUrl)}`
)
})
// Negative scenarios
test('should throw error for auto-join without firstName', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
})
test('should throw error for auto-join without schedule service', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'other' }
})
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John'
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
test('should throw error for insufficient role', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Guest)
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
})
describe('sendInvite', () => {
const mockEmail = 'test@example.com'
const sesUrl = 'https://ses.example.com'
const sesAuth = 'test-auth-token'
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({ ok: true })
;(getMetadata as jest.Mock).mockImplementation((key) => {
switch (key) {
case accountPlugin.metadata.MAIL_URL:
return sesUrl
case accountPlugin.metadata.MAIL_AUTH_TOKEN:
return sesAuth
case accountPlugin.metadata.FrontURL:
return 'https://app.example.com'
default:
return undefined
}
})
})
test('should send invite email with correct parameters', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
await sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User,
expHours: 48
})
expect(global.fetch).toHaveBeenCalledWith(`${sesUrl}/send`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${sesAuth}`
},
body: expect.stringContaining(mockEmail)
})
// Verify invite was created with correct parameters
expect(mockDb.invite.insertOne).toHaveBeenCalledWith(
expect.objectContaining({
email: mockEmail,
remainingUses: 1,
role: AccountRole.User,
expiresOn: expect.any(Number)
})
)
})
test('should throw error if caller has insufficient role', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Guest)
await expect(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
expect(global.fetch).not.toHaveBeenCalled()
})
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(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(
new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: mockWorkspace.uuid })
)
)
expect(global.fetch).not.toHaveBeenCalled()
})
test('should throw error if account not found', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
await expect(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: mockAccount.uuid }))
)
expect(global.fetch).not.toHaveBeenCalled()
})
test('should use custom expiration hours', async () => {
const inviteId = 'new-invite-id'
const customExpHours = 72
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const beforeTest = Date.now()
await sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User,
expHours: customExpHours
})
const afterTest = Date.now()
const actualCall = (mockDb.invite.insertOne as jest.Mock).mock.calls[0][0]
const expectedExpiration = customExpHours * 60 * 60 * 1000
// Check if expiration time is within the expected range
const minExpected = beforeTest + expectedExpiration
const maxExpected = afterTest + expectedExpiration
expect(actualCall.expiresOn).toBeGreaterThanOrEqual(minExpected - 1) // Allow 1ms tolerance
expect(actualCall.expiresOn).toBeLessThanOrEqual(maxExpected)
})
})
describe('resendInvite', () => {
const mockEmail = 'test@example.com'
test('should resend existing invite', async () => {
const existingInvite = {
id: 'existing-invite-id',
workspaceUuid: mockWorkspace.uuid,
email: mockEmail
}
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.findOne as jest.Mock).mockResolvedValue(existingInvite)
global.fetch = jest.fn().mockResolvedValue({ ok: true })
await resendInvite(mockCtx, mockDb, mockBranding, mockToken, mockEmail, AccountRole.User)
expect(mockDb.invite.updateOne).toHaveBeenCalledWith(
{ id: existingInvite.id },
expect.objectContaining({
expiresOn: expect.any(Number),
remainingUses: 1,
role: AccountRole.User
})
)
expect(global.fetch).toHaveBeenCalled()
})
test('should create new invite if none exists', async () => {
const newInviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.findOne as jest.Mock).mockResolvedValue(null)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(newInviteId)
global.fetch = jest.fn().mockResolvedValue({ ok: true })
await resendInvite(mockCtx, mockDb, mockBranding, mockToken, mockEmail, AccountRole.User)
expect(mockDb.invite.insertOne).toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalled()
})
})
})