mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-07 16:07:24 +00:00
Merge branch 'develop' into staging-new
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
d0c7fd20ab
@ -33,11 +33,23 @@
|
||||
let _is: AnyComponent | AnySvelteComponent = is
|
||||
let _props: any = props
|
||||
|
||||
// See https://github.com/sveltejs/svelte/issues/4068
|
||||
// When passing undefined prop value, then Svelte uses default value only first time when
|
||||
// component is instantiated. On the next update the value will be set to undefined.
|
||||
// Here we filter out undefined values from props on updates to ensure we don't overwrite them.
|
||||
const filterDefaultUndefined = (pnew: any, pold: any): any =>
|
||||
pnew != null
|
||||
? Object.fromEntries(Object.entries(pnew).filter(([k, v]) => v !== undefined || pold?.[k] !== undefined))
|
||||
: pnew
|
||||
|
||||
$: if (!deepEqual(_is, is)) {
|
||||
_is = is
|
||||
}
|
||||
$: if (!deepEqual(_props, props)) {
|
||||
_props = props
|
||||
$: {
|
||||
const p = filterDefaultUndefined(props, _props)
|
||||
if (!deepEqual(_props, p)) {
|
||||
_props = p
|
||||
}
|
||||
}
|
||||
|
||||
let Ctor: any
|
||||
@ -61,6 +73,7 @@
|
||||
.then((res) => {
|
||||
if (current === counter) {
|
||||
Ctor = res
|
||||
_props = props
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
@ -72,9 +85,11 @@
|
||||
} else {
|
||||
loading = false
|
||||
Ctor = component
|
||||
_props = props
|
||||
}
|
||||
} else {
|
||||
Ctor = _is
|
||||
_props = props
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,18 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import { mergeAttributes, type Editor } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import MentionList from '../MentionList.svelte'
|
||||
@ -9,8 +24,9 @@ import Suggestion, { type SuggestionKeyDownProps, type SuggestionOptions, type S
|
||||
import { type Class, type Doc, type Ref } from '@hcengineering/core'
|
||||
import { getMetadata, getResource } from '@hcengineering/platform'
|
||||
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { parseLocation } from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
|
||||
import { parseLocation, type Location } from '@hcengineering/ui'
|
||||
import workbench, { type Application } from '@hcengineering/workbench'
|
||||
|
||||
export interface ReferenceExtensionOptions extends ReferenceOptions {
|
||||
@ -104,7 +120,7 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
|
||||
|
||||
const renderLabel = (props: ReferenceNodeProps): void => {
|
||||
span.setAttribute('data-label', props.label)
|
||||
span.innerText = options.renderLabel({ options, props: props ?? node.attrs })
|
||||
span.innerText = options.renderLabel({ options, props: props ?? (node.attrs as ReferenceNodeProps) })
|
||||
}
|
||||
|
||||
const id = node.attrs.id
|
||||
@ -328,16 +344,37 @@ export async function getReferenceLabel<T extends Doc> (
|
||||
return label
|
||||
}
|
||||
|
||||
export async function getReferenceFromUrl (text: string): Promise<ReferenceNodeProps | undefined> {
|
||||
export async function getReferenceFromUrl (urlString: string): Promise<ReferenceNodeProps | undefined> {
|
||||
const target = await getTargetObjectFromUrl(urlString)
|
||||
if (target === undefined) return
|
||||
|
||||
const label = await getReferenceLabel(target._class, target._id)
|
||||
if (label === '') return
|
||||
|
||||
return {
|
||||
id: target._id,
|
||||
objectclass: target._class,
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTargetObjectFromUrl (
|
||||
urlOrLocation: string | Location
|
||||
): Promise<{ _id: Ref<Doc>, _class: Ref<Class<Doc>> } | undefined> {
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
const url = new URL(text)
|
||||
let location: Location
|
||||
if (typeof urlOrLocation === 'string') {
|
||||
const url = new URL(urlOrLocation)
|
||||
|
||||
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
|
||||
if (url.origin !== frontUrl) return
|
||||
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
|
||||
if (url.origin !== frontUrl) return
|
||||
|
||||
const location = parseLocation(url)
|
||||
location = parseLocation(url)
|
||||
} else {
|
||||
location = urlOrLocation
|
||||
}
|
||||
|
||||
const appAlias = (location.path[2] ?? '').trim()
|
||||
if (!(appAlias.length > 0)) return
|
||||
@ -363,12 +400,8 @@ export async function getReferenceFromUrl (text: string): Promise<ReferenceNodeP
|
||||
const _id: Ref<Doc> | undefined =
|
||||
linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
|
||||
|
||||
const label = await getReferenceLabel(objectclass, _id)
|
||||
if (label === '') return
|
||||
|
||||
return {
|
||||
id: _id,
|
||||
objectclass,
|
||||
label
|
||||
_id,
|
||||
_class: objectclass
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import { openImage, downloadImage, expandImage, moreImageActions } from './compo
|
||||
import { configureNote, isEditableNote } from './components/extension/note'
|
||||
import { createInlineComment, shouldShowCreateInlineCommentAction } from './components/extension/inlineComment'
|
||||
import { isTextStylingEnabled, openBackgroundColorOptions, openTextColorOptions } from './components/extension/colors'
|
||||
export { getTargetObjectFromUrl, getReferenceFromUrl, getReferenceLabel } from './components/extension/reference'
|
||||
|
||||
export * from '@hcengineering/presentation/src/types'
|
||||
export type { EditorKitOptions } from './kits/editor-kit'
|
||||
|
@ -170,11 +170,12 @@ import ProjectSpacePresenter from './components/projects/ProjectSpacePresenter.s
|
||||
|
||||
import { get } from 'svelte/store'
|
||||
import { settingId } from '@hcengineering/setting'
|
||||
import type { TaskType } from '@hcengineering/task'
|
||||
import { getAllStates } from '@hcengineering/task-resources'
|
||||
import view, { type Filter } from '@hcengineering/view'
|
||||
import EstimationValueEditor from './components/issues/timereport/EstimationValueEditor.svelte'
|
||||
import TimePresenter from './components/issues/timereport/TimePresenter.svelte'
|
||||
import type { TaskType } from '@hcengineering/task'
|
||||
import view, { type Filter } from '@hcengineering/view'
|
||||
import { getTargetObjectFromUrl } from '@hcengineering/text-editor-resources'
|
||||
|
||||
export { default as AssigneeEditor } from './components/issues/AssigneeEditor.svelte'
|
||||
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
|
||||
@ -265,12 +266,19 @@ async function deleteIssue (issue: Issue | Issue[]): Promise<void> {
|
||||
},
|
||||
action: async () => {
|
||||
const objs = Array.isArray(issue) ? issue : [issue]
|
||||
|
||||
const target = await getTargetObjectFromUrl(getCurrentLocation())
|
||||
const deletingFromTargetIssuePage = objs.some((obj) => obj._id === target?._id)
|
||||
|
||||
try {
|
||||
await deleteObjects(getClient(), objs as unknown as Doc[])
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
closePanel()
|
||||
|
||||
if (deletingFromTargetIssuePage) {
|
||||
closePanel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -13,126 +13,555 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { generateWorkspaceUrl } from '../utils'
|
||||
import { AccountRole, MeasureContext } from '@hcengineering/core'
|
||||
import {
|
||||
generateWorkspaceUrl,
|
||||
cleanEmail,
|
||||
isEmail,
|
||||
isShallowEqual,
|
||||
getRolePower,
|
||||
getEndpoints,
|
||||
_getRegions,
|
||||
EndpointKind,
|
||||
getEndpoint,
|
||||
hashWithSalt,
|
||||
verifyPassword,
|
||||
getAllTransactors,
|
||||
wrap
|
||||
} 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 { randomBytes } from 'crypto'
|
||||
|
||||
import { AccountDB } from '../types'
|
||||
|
||||
// Mock platform with minimum required functionality
|
||||
jest.mock('@hcengineering/platform', () => {
|
||||
const actual = jest.requireActual('@hcengineering/platform')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
...actual.default,
|
||||
getMetadata: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
plugin: (id: string, plugin: any) => plugin // Simple plugin function mock
|
||||
}
|
||||
})
|
||||
|
||||
// Mock analytics
|
||||
jest.mock('@hcengineering/analytics', () => ({
|
||||
Analytics: {
|
||||
handleError: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('account utils', () => {
|
||||
describe('cleanEmail', () => {
|
||||
test.each([
|
||||
[' Test@Example.com ', 'test@example.com', 'should trim spaces and convert to lowercase'],
|
||||
['USER@DOMAIN.COM', 'user@domain.com', 'should convert uppercase to lowercase'],
|
||||
['normal@email.com', 'normal@email.com', 'should keep already normalized email unchanged'],
|
||||
['Mixed.Case@Example.COM', 'mixed.case@example.com', 'should normalize mixed case email'],
|
||||
[' spaced@email.com ', 'spaced@email.com', 'should trim multiple spaces']
|
||||
])('%s -> %s (%s)', (input, expected) => {
|
||||
expect(cleanEmail(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEmail', () => {
|
||||
test.each([
|
||||
['test@example.com', true, 'basic valid email'],
|
||||
['user.name@domain.com', true, 'email with dot in local part'],
|
||||
['user+tag@domain.co.uk', true, 'email with plus and subdomain'],
|
||||
['small@domain.museum', true, 'email with long TLD'],
|
||||
['user@subdomain.domain.com', true, 'email with multiple subdomains'],
|
||||
['user-name@domain.com', true, 'email with hyphen in local part'],
|
||||
['user123@domain.com', true, 'email with numbers'],
|
||||
['user.name+tag@domain.com', true, 'email with dot and plus'],
|
||||
['not-an-email', false, 'string without @ symbol'],
|
||||
['@missing-user.com', false, 'missing local part'],
|
||||
['missing-domain@', false, 'missing domain part'],
|
||||
['spaces in@email.com', false, 'spaces in local part'],
|
||||
['missing@domain', false, 'incomplete domain'],
|
||||
['.invalid@email.com', false, 'leading dot in local part'],
|
||||
['invalid@email.', false, 'trailing dot in domain'],
|
||||
['invalid@@email.com', false, 'double @ symbol'],
|
||||
['invalid@.com', false, 'missing domain part before dot'],
|
||||
['invalid.@domain.com', false, 'trailing dot in local part'],
|
||||
['invalid..email@domain.com', false, 'consecutive dots in local part'],
|
||||
['invalid@domain..com', false, 'consecutive dots in domain'],
|
||||
['invalid@-domain.com', false, 'leading hyphen in domain'],
|
||||
['invalid@domain-.com', false, 'trailing hyphen in domain'],
|
||||
['very.unusual."@".unusual.com@example.com', false, 'invalid special characters'],
|
||||
[' space@domain.com', false, 'leading space'],
|
||||
['space@domain.com ', false, 'trailing space']
|
||||
])('%s -> %s (%s)', (input, expected) => {
|
||||
expect(isEmail(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isShallowEqual', () => {
|
||||
test.each([
|
||||
[{ a: 1, b: 2, c: 'test' }, { a: 1, b: 2, c: 'test' }, true, 'identical objects should be equal'],
|
||||
[
|
||||
{ a: 1, b: 2, c: 'test' },
|
||||
{ a: 1, b: 2, c: 'different' },
|
||||
false,
|
||||
'objects with different values should not be equal'
|
||||
],
|
||||
[{ a: 1, b: 2 }, { a: 1, c: 2 }, false, 'objects with different keys should not be equal'],
|
||||
[{ a: 1, b: 2 }, { a: 1, b: 2, c: 3 }, false, 'objects with different number of keys should not be equal'],
|
||||
[
|
||||
{ x: null, y: undefined },
|
||||
{ x: null, y: undefined },
|
||||
true,
|
||||
'objects with null and undefined values should be equal'
|
||||
],
|
||||
[{ a: 1 }, { a: '1' }, false, 'objects with different value types should not be equal'],
|
||||
[{}, {}, true, 'empty objects should be equal']
|
||||
])('%# %s', (obj1, obj2, expected, description) => {
|
||||
expect(isShallowEqual(obj1, obj2)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateWorkspaceUrl', () => {
|
||||
const generateWorkspaceUrlTestCases = [
|
||||
test.each([
|
||||
// Basic cases
|
||||
{
|
||||
input: 'Simple Project',
|
||||
expected: 'simpleproject',
|
||||
description: 'removes spaces between words'
|
||||
},
|
||||
{
|
||||
input: 'UPPERCASE',
|
||||
expected: 'uppercase',
|
||||
description: 'converts uppercase to lowercase'
|
||||
},
|
||||
{
|
||||
input: 'lowercase',
|
||||
expected: 'lowercase',
|
||||
description: 'preserves already lowercase text'
|
||||
},
|
||||
['Simple Project', 'simpleproject', 'removes spaces between words'],
|
||||
['UPPERCASE', 'uppercase', 'converts uppercase to lowercase'],
|
||||
['lowercase', 'lowercase', 'preserves already lowercase text'],
|
||||
['Test Workspace', 'testworkspace', 'basic workspace name'],
|
||||
|
||||
// Number handling
|
||||
{
|
||||
input: '123Project',
|
||||
expected: 'project',
|
||||
description: 'removes numbers from the beginning of string'
|
||||
},
|
||||
{
|
||||
input: 'Project123',
|
||||
expected: 'project123',
|
||||
description: 'preserves numbers at the end of string'
|
||||
},
|
||||
{
|
||||
input: 'Pro123ject',
|
||||
expected: 'pro123ject',
|
||||
description: 'preserves numbers in the middle of string'
|
||||
},
|
||||
['123Project', 'project', 'removes numbers from the beginning of string'],
|
||||
['Project123', 'project123', 'preserves numbers at the end of string'],
|
||||
['Pro123ject', 'pro123ject', 'preserves numbers in the middle of string'],
|
||||
['Workspace 123', 'workspace123', 'workspace with numbers'],
|
||||
|
||||
// Special characters
|
||||
{
|
||||
input: 'My-Project',
|
||||
expected: 'my-project',
|
||||
description: 'preserves hyphens between words'
|
||||
},
|
||||
{
|
||||
input: 'My_Project',
|
||||
expected: 'myproject',
|
||||
description: 'removes underscores between words'
|
||||
},
|
||||
{
|
||||
input: 'Project@#$%',
|
||||
expected: 'project',
|
||||
description: 'removes all special characters'
|
||||
},
|
||||
// Special characters and hyphens
|
||||
['My-Project', 'my-project', 'preserves hyphens between words'],
|
||||
['My_Project', 'myproject', 'removes underscores between words'],
|
||||
['Project@#$%', 'project', 'removes all special characters'],
|
||||
['workspace-with-hyphens', 'workspace-with-hyphens', 'preserves existing hyphens'],
|
||||
['--test--', 'test', 'removes leading and trailing hyphens'],
|
||||
|
||||
// Complex combinations
|
||||
{
|
||||
input: 'My-Awesome-Project-123',
|
||||
expected: 'my-awesome-project-123',
|
||||
description: 'preserves hyphens and numbers in complex strings'
|
||||
},
|
||||
{
|
||||
input: '123-Project-456',
|
||||
expected: 'project-456',
|
||||
description: 'removes leading numbers but preserves hyphens and trailing numbers'
|
||||
},
|
||||
{
|
||||
input: '@#$My&&Project!!',
|
||||
expected: 'myproject',
|
||||
description: 'removes all special characters while preserving alphanumeric content'
|
||||
},
|
||||
['My-Awesome-Project-123', 'my-awesome-project-123', 'preserves hyphens and numbers in complex strings'],
|
||||
['123-Project-456', 'project-456', 'removes leading numbers but preserves hyphens and trailing numbers'],
|
||||
['@#$My&&Project!!', 'myproject', 'removes all special characters while preserving alphanumeric content'],
|
||||
['Multiple Spaces', 'multiplespaces', 'collapses multiple spaces'],
|
||||
['a.b.c', 'abc', 'removes dots'],
|
||||
['UPPER.case.123', 'uppercase123', 'handles mixed case with dots and numbers'],
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
input: '',
|
||||
expected: '',
|
||||
description: 'returns empty string for empty input'
|
||||
},
|
||||
{
|
||||
input: '123456',
|
||||
expected: '',
|
||||
description: 'returns empty string when input contains only numbers'
|
||||
},
|
||||
{
|
||||
input: '@#$%^&',
|
||||
expected: '',
|
||||
description: 'returns empty string when input contains only special characters'
|
||||
},
|
||||
{
|
||||
input: ' ',
|
||||
expected: '',
|
||||
description: 'returns empty string when input contains only spaces'
|
||||
},
|
||||
{
|
||||
input: 'a-b-c-1-2-3',
|
||||
expected: 'a-b-c-1-2-3',
|
||||
description: 'preserves alternating letters, numbers, and hyphens'
|
||||
},
|
||||
{
|
||||
input: '---Project---',
|
||||
expected: 'project',
|
||||
description: 'removes redundant hyphens while preserving content'
|
||||
},
|
||||
{
|
||||
input: 'Project!!!Name!!!123',
|
||||
expected: 'projectname123',
|
||||
description: 'removes exclamation marks while preserving alphanumeric content'
|
||||
},
|
||||
{
|
||||
input: '!@#Project123Name!@#',
|
||||
expected: 'project123name',
|
||||
description: 'removes surrounding special characters while preserving alphanumeric content'
|
||||
}
|
||||
]
|
||||
['', '', 'handles empty string'],
|
||||
['123456', '', 'handles numbers only'],
|
||||
['@#$%^&', '', 'handles special characters only'],
|
||||
[' ', '', 'handles spaces only'],
|
||||
['a-b-c-1-2-3', 'a-b-c-1-2-3', 'preserves alternating letters, numbers, and hyphens'],
|
||||
['---Project---', 'project', 'removes redundant hyphens'],
|
||||
['Project!!!Name!!!123', 'projectname123', 'removes exclamation marks'],
|
||||
['!@#Project123Name!@#', 'project123name', 'removes surrounding special characters'],
|
||||
[' spaces ', 'spaces', 'trims leading and trailing spaces']
|
||||
])('%s -> %s (%s)', (input, expected, description) => {
|
||||
expect(generateWorkspaceUrl(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
generateWorkspaceUrlTestCases.forEach(({ input, expected, description }) => {
|
||||
it(description, () => {
|
||||
expect(generateWorkspaceUrl(input)).toEqual(expected)
|
||||
describe('getRolePower', () => {
|
||||
it('should maintain correct power hierarchy', () => {
|
||||
const owner = getRolePower(AccountRole.Owner)
|
||||
const maintainer = getRolePower(AccountRole.Maintainer)
|
||||
const user = getRolePower(AccountRole.User)
|
||||
const guest = getRolePower(AccountRole.Guest)
|
||||
const docGuest = getRolePower(AccountRole.DocGuest)
|
||||
|
||||
// Verify hierarchy
|
||||
expect(owner).toBeGreaterThan(maintainer)
|
||||
expect(maintainer).toBeGreaterThan(user)
|
||||
expect(user).toBeGreaterThan(guest)
|
||||
expect(guest).toBeGreaterThan(docGuest)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactor utils', () => {
|
||||
describe('getEndpoints', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test.each([
|
||||
['http://localhost:3000', ['http://localhost:3000'], 'single endpoint'],
|
||||
[
|
||||
'http://localhost:3000,http://localhost:3001',
|
||||
['http://localhost:3000', 'http://localhost:3001'],
|
||||
'multiple endpoints'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
['http://internal:3000;http://external:3000;us'],
|
||||
'endpoint with internal, external urls and region'
|
||||
],
|
||||
[
|
||||
' http://localhost:3000 , http://localhost:3001 ',
|
||||
['http://localhost:3000', 'http://localhost:3001'],
|
||||
'endpoints with whitespace'
|
||||
],
|
||||
[
|
||||
'http://localhost:3000,,,http://localhost:3001',
|
||||
['http://localhost:3000', 'http://localhost:3001'],
|
||||
'endpoints with empty entries'
|
||||
]
|
||||
])('should parse "%s" into %j (%s)', (input, expected) => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(input)
|
||||
expect(getEndpoints()).toEqual(expected)
|
||||
})
|
||||
|
||||
test('should throw error when no transactors provided', () => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(undefined)
|
||||
expect(() => getEndpoints()).toThrow('Please provide transactor endpoint url')
|
||||
})
|
||||
|
||||
test('should throw error when empty transactors string provided', () => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue('')
|
||||
expect(() => getEndpoints()).toThrow('Please provide transactor endpoint url')
|
||||
})
|
||||
|
||||
test('should throw error when only commas provided', () => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(',,,')
|
||||
expect(() => getEndpoints()).toThrow('Please provide transactor endpoint url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getRegions', () => {
|
||||
const originalEnv = process.env
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
test.each<[string, string | undefined, { region: string, name: string }[], string]>([
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
undefined,
|
||||
[{ region: 'us', name: '' }],
|
||||
'single region from transactor'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us,http://internal:3001;http://external:3001;eu',
|
||||
undefined,
|
||||
[
|
||||
{ region: 'us', name: '' },
|
||||
{ region: 'eu', name: '' }
|
||||
],
|
||||
'multiple regions from transactors'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
'us|United States',
|
||||
[{ region: 'us', name: 'United States' }],
|
||||
'region with name from env'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us,http://internal:3001;http://external:3001;eu',
|
||||
'us|United States;eu|European Union',
|
||||
[
|
||||
{ region: 'us', name: 'United States' },
|
||||
{ region: 'eu', name: 'European Union' }
|
||||
],
|
||||
'multiple regions with names from env'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
'eu|European Union',
|
||||
[
|
||||
{ region: 'eu', name: 'European Union' },
|
||||
{ region: 'us', name: '' }
|
||||
],
|
||||
'combines regions from env and transactors'
|
||||
]
|
||||
])(
|
||||
'should handle transactors="%s" and REGION_INFO="%s" (%s)',
|
||||
(transactors, regionInfo, expected, description) => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(transactors)
|
||||
if (regionInfo !== undefined) {
|
||||
process.env.REGION_INFO = regionInfo
|
||||
} else {
|
||||
delete process.env.REGION_INFO
|
||||
}
|
||||
expect(_getRegions()).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('getEndpoint', () => {
|
||||
const mockCtx = {
|
||||
error: jest.fn()
|
||||
} as unknown as MeasureContext
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test.each([
|
||||
[
|
||||
'workspace1',
|
||||
'us',
|
||||
EndpointKind.Internal,
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
'http://internal:3000',
|
||||
'single endpoint internal'
|
||||
],
|
||||
[
|
||||
'workspace1',
|
||||
'us',
|
||||
EndpointKind.External,
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
'http://external:3000',
|
||||
'single endpoint external'
|
||||
],
|
||||
[
|
||||
'workspace1',
|
||||
'eu',
|
||||
EndpointKind.Internal,
|
||||
'http://internal:3000;http://external:3000;us,http://internal:3001;http://external:3001;eu',
|
||||
'http://internal:3001',
|
||||
'multiple endpoints choose by region internal'
|
||||
],
|
||||
[
|
||||
'workspace1',
|
||||
'eu',
|
||||
EndpointKind.External,
|
||||
'http://internal:3000;http://external:3000;us,http://internal:3001;http://external:3001;eu',
|
||||
'http://external:3001',
|
||||
'multiple endpoints choose by region external'
|
||||
]
|
||||
])(
|
||||
'should handle workspace="%s" region="%s" kind=%s (%s)',
|
||||
(workspace, region, kind, transactors, expected, description) => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(transactors)
|
||||
expect(getEndpoint(mockCtx, workspace, region, kind)).toBe(expected)
|
||||
expect(mockCtx.error).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
test('should fall back to default region if requested region not found', () => {
|
||||
const transactors = 'http://internal:3000;http://external:3000;'
|
||||
;(getMetadata as jest.Mock).mockReturnValue(transactors)
|
||||
|
||||
expect(getEndpoint(mockCtx, 'workspace1', 'nonexistent', EndpointKind.Internal)).toBe('http://internal:3000')
|
||||
|
||||
expect(mockCtx.error).toHaveBeenCalledWith('No transactors for the target region, will use default region', {
|
||||
group: 'nonexistent'
|
||||
})
|
||||
})
|
||||
|
||||
test('should throw error when no transactors available', () => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue('http://internal:3000;http://external:3000;us')
|
||||
|
||||
expect(() => getEndpoint(mockCtx, 'workspace1', 'nonexistent', EndpointKind.Internal)).toThrow(
|
||||
'Please provide transactor endpoint url'
|
||||
)
|
||||
|
||||
expect(mockCtx.error).toHaveBeenCalledWith('No transactors for the default region')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllTransactors', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test.each([
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
EndpointKind.Internal,
|
||||
['http://internal:3000'],
|
||||
'single internal endpoint'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us',
|
||||
EndpointKind.External,
|
||||
['http://external:3000'],
|
||||
'single external endpoint'
|
||||
],
|
||||
[
|
||||
'http://internal:3000;http://external:3000;us,http://internal:3001;http://external:3001;eu',
|
||||
EndpointKind.Internal,
|
||||
['http://internal:3000', 'http://internal:3001'],
|
||||
'multiple internal endpoints'
|
||||
],
|
||||
[';http://external:3000;us', EndpointKind.Internal, [''], 'empty internal url']
|
||||
])('should get all %s endpoints for "%s" (%s)', (transactors, kind, expected, description) => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(transactors)
|
||||
expect(getAllTransactors(kind)).toEqual(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[undefined, 'undefined transactors'],
|
||||
['', 'empty transactors'],
|
||||
[',,,', 'only commas']
|
||||
])('should throw error for %s', (transactors, description) => {
|
||||
;(getMetadata as jest.Mock).mockReturnValue(transactors)
|
||||
expect(() => getAllTransactors(EndpointKind.Internal)).toThrow('Please provide transactor endpoint url')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password utils', () => {
|
||||
describe('hashWithSalt', () => {
|
||||
test.each([
|
||||
['simple', 'basic password'],
|
||||
['p@ssw0rd!123', 'complex password'],
|
||||
['', 'empty password'],
|
||||
['a'.repeat(100), 'long password'],
|
||||
['🔑password', 'password with emoji'],
|
||||
[' password ', 'password with spaces']
|
||||
])('should hash "%s" consistently (%s)', (password, description) => {
|
||||
const salt = randomBytes(32)
|
||||
const hash1 = hashWithSalt(password, salt)
|
||||
const hash2 = hashWithSalt(password, salt)
|
||||
|
||||
// Same password + salt should produce same hash
|
||||
expect(Buffer.compare(hash1 as any, hash2 as any)).toBe(0)
|
||||
})
|
||||
|
||||
test('should produce different hashes for different salts', () => {
|
||||
const password = 'password123'
|
||||
const salt1 = randomBytes(32)
|
||||
const salt2 = randomBytes(32)
|
||||
|
||||
const hash1 = hashWithSalt(password, salt1)
|
||||
const hash2 = hashWithSalt(password, salt2)
|
||||
|
||||
// Same password with different salts should produce different hashes
|
||||
expect(Buffer.compare(hash1 as any, hash2 as any)).not.toBe(0)
|
||||
})
|
||||
|
||||
test('should produce different hashes for different passwords', () => {
|
||||
const salt = randomBytes(32)
|
||||
const hash1 = hashWithSalt('password1', salt)
|
||||
const hash2 = hashWithSalt('password2', salt)
|
||||
|
||||
// Different passwords with same salt should produce different hashes
|
||||
expect(Buffer.compare(hash1 as any, hash2 as any)).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyPassword', () => {
|
||||
test.each([
|
||||
['correct password', 'password123', true],
|
||||
['wrong password', 'wrongpass', false],
|
||||
['empty password', '', false],
|
||||
['long password', 'a'.repeat(100), true],
|
||||
['password with spaces', ' password123 ', true],
|
||||
['password with special chars', 'p@ssw0rd!123', true]
|
||||
])('should verify %s', (description, password, shouldMatch) => {
|
||||
const salt = randomBytes(32)
|
||||
const hash = shouldMatch ? hashWithSalt(password, salt) : hashWithSalt('different', salt)
|
||||
|
||||
expect(verifyPassword(password, hash, salt)).toBe(shouldMatch)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['null hash', 'password123', null, randomBytes(32)],
|
||||
['null salt', 'password123', randomBytes(32), null],
|
||||
['null both', 'password123', null, null]
|
||||
])('should handle %s', (description, password, hash, salt) => {
|
||||
expect(verifyPassword(password, hash, salt)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrap', () => {
|
||||
const mockCtx = {
|
||||
error: jest.fn()
|
||||
} as unknown as MeasureContext
|
||||
|
||||
const mockDb = {} as unknown as AccountDB
|
||||
const mockBranding = null
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('should handle successful execution', async () => {
|
||||
const mockResult = { data: 'test' }
|
||||
const mockMethod = jest.fn().mockResolvedValue(mockResult)
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: ['param1', 'param2'] }
|
||||
|
||||
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request)
|
||||
|
||||
expect(result).toEqual({ id: 'req1', result: mockResult })
|
||||
expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, 'param1', 'param2')
|
||||
})
|
||||
|
||||
test('should handle token parameter', async () => {
|
||||
const mockResult = { data: 'test' }
|
||||
const mockMethod = jest.fn().mockResolvedValue(mockResult)
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: ['param1'] }
|
||||
const token = 'test-token'
|
||||
|
||||
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, token)
|
||||
|
||||
expect(result).toEqual({ id: 'req1', result: mockResult })
|
||||
expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, token, 'param1')
|
||||
})
|
||||
|
||||
test('should handle PlatformError', async () => {
|
||||
const errorStatus = new Status(Severity.ERROR, 'test-error' as any, {})
|
||||
const mockMethod = jest.fn().mockRejectedValue(new PlatformError(errorStatus))
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: [] }
|
||||
|
||||
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request)
|
||||
|
||||
expect(result).toEqual({ error: errorStatus })
|
||||
expect(mockCtx.error).toHaveBeenCalledWith('error', { status: errorStatus })
|
||||
})
|
||||
|
||||
test('should handle TokenError', async () => {
|
||||
const mockMethod = jest.fn().mockRejectedValue(new TokenError('test error'))
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: [] }
|
||||
|
||||
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request)
|
||||
|
||||
expect(result).toEqual({
|
||||
error: new Status(Severity.ERROR, platform.status.Unauthorized, {})
|
||||
})
|
||||
})
|
||||
|
||||
test('should handle internal server error', async () => {
|
||||
const error = new Error('unexpected error')
|
||||
const mockMethod = jest.fn().mockRejectedValue(error)
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: [] }
|
||||
|
||||
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request)
|
||||
|
||||
expect(result.error.code).toBe(platform.status.InternalServerError)
|
||||
expect(mockCtx.error).toHaveBeenCalledWith('error', {
|
||||
status: expect.any(Status),
|
||||
err: error
|
||||
})
|
||||
})
|
||||
|
||||
test('should not report non-internal errors to analytics', async () => {
|
||||
const errorStatus = new Status(Severity.ERROR, 'known-error' as any, {})
|
||||
const mockMethod = jest.fn().mockRejectedValue(new PlatformError(errorStatus))
|
||||
const wrappedMethod = wrap(mockMethod)
|
||||
const request = { id: 'req1', params: [] }
|
||||
|
||||
await wrappedMethod(mockCtx, mockDb, mockBranding, request)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -179,7 +179,11 @@ const toTransactor = (line: string): { internalUrl: string, region: string, exte
|
||||
return { internalUrl: internalUrl ?? '', region: region ?? '', externalUrl: externalUrl ?? internalUrl ?? '' }
|
||||
}
|
||||
|
||||
const getEndpoints = (): string[] => {
|
||||
/**
|
||||
* Internal. Exported for testing only.
|
||||
* @returns list of endpoints
|
||||
*/
|
||||
export const getEndpoints = (): string[] => {
|
||||
const transactorsUrl = getMetadata(accountPlugin.metadata.Transactors)
|
||||
if (transactorsUrl === undefined) {
|
||||
throw new Error('Please provide transactor endpoint url')
|
||||
@ -200,26 +204,37 @@ let regionInfo: RegionInfo[] = []
|
||||
|
||||
export const getRegions = (): RegionInfo[] => {
|
||||
if (regionInfo.length === 0) {
|
||||
const endpoints = getEndpoints()
|
||||
.map(toTransactor)
|
||||
.map((it) => ({ region: it.region.trim(), name: '' }))
|
||||
if (process.env.REGION_INFO !== undefined) {
|
||||
regionInfo = process.env.REGION_INFO.split(';')
|
||||
.map((it) => it.split('|'))
|
||||
.map((it) => ({ region: it[0].trim(), name: it[1].trim() }))
|
||||
// We need to add all endpoints if they are not in info.
|
||||
for (const endpoint of endpoints) {
|
||||
if (regionInfo.find((it) => it.region === endpoint.region) === undefined) {
|
||||
regionInfo.push(endpoint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
regionInfo = endpoints
|
||||
}
|
||||
regionInfo = _getRegions()
|
||||
}
|
||||
return regionInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal. Exported for tests only.
|
||||
* @returns list of endpoints
|
||||
*/
|
||||
export const _getRegions = (): RegionInfo[] => {
|
||||
let _regionInfo: RegionInfo[] = []
|
||||
const endpoints = getEndpoints()
|
||||
.map(toTransactor)
|
||||
.map((it) => ({ region: it.region.trim(), name: '' }))
|
||||
if (process.env.REGION_INFO !== undefined) {
|
||||
_regionInfo = process.env.REGION_INFO.split(';')
|
||||
.map((it) => it.split('|'))
|
||||
.map((it) => ({ region: it[0].trim(), name: it[1].trim() }))
|
||||
// We need to add all endpoints if they are not in info.
|
||||
for (const endpoint of endpoints) {
|
||||
if (_regionInfo.find((it) => it.region === endpoint.region) === undefined) {
|
||||
_regionInfo.push(endpoint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_regionInfo = endpoints
|
||||
}
|
||||
|
||||
return _regionInfo
|
||||
}
|
||||
|
||||
export const getEndpoint = (
|
||||
ctx: MeasureContext,
|
||||
workspace: string,
|
||||
@ -290,8 +305,9 @@ export function cleanEmail (email: string): string {
|
||||
}
|
||||
|
||||
export function isEmail (email: string): boolean {
|
||||
// RFC 5322 compliant email regex
|
||||
const EMAIL_REGEX =
|
||||
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-](?:\.?[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-])*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-zA-Z0-9-]*[a-zA-Z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/ // eslint-disable-line no-control-regex
|
||||
return EMAIL_REGEX.test(email)
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user