mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-28 10:57:36 +00:00
458 lines
17 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|