From 75c207a82215d9ada5ed6376a2a940c7a9bda1fb Mon Sep 17 00:00:00 2001 From: Victor Ilyushchenko Date: Wed, 19 Feb 2025 10:28:58 +0300 Subject: [PATCH 1/4] UBERF-9144: Stay in same view after delete sub-issue (#8051) * UBERF-9144: Stay in same view after delete sub-issue Signed-off-by: Victor Ilyushchenko * fmt Signed-off-by: Victor Ilyushchenko * move link resolver back to text-editor-resources Signed-off-by: Victor Ilyushchenko * ff Signed-off-by: Victor Ilyushchenko --------- Signed-off-by: Victor Ilyushchenko --- .../src/components/extension/reference.ts | 59 +++++++++++++++---- plugins/text-editor-resources/src/index.ts | 1 + plugins/tracker-resources/src/index.ts | 14 ++++- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/plugins/text-editor-resources/src/components/extension/reference.ts b/plugins/text-editor-resources/src/components/extension/reference.ts index 65f9004b17..8c1b69165a 100644 --- a/plugins/text-editor-resources/src/components/extension/reference.ts +++ b/plugins/text-editor-resources/src/components/extension/reference.ts @@ -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 { 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 ( return label } -export async function getReferenceFromUrl (text: string): Promise { +export async function getReferenceFromUrl (urlString: string): Promise { + 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, _class: Ref> } | 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 | 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 } } diff --git a/plugins/text-editor-resources/src/index.ts b/plugins/text-editor-resources/src/index.ts index 0e2d6fe394..b5747179f9 100644 --- a/plugins/text-editor-resources/src/index.ts +++ b/plugins/text-editor-resources/src/index.ts @@ -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' diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 4397654845..62f2e12ce1 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -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 { }, 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() + } } }) } From be9656d25613e8566514138ef8e8527a21e6b4e9 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Wed, 19 Feb 2025 14:29:18 +0700 Subject: [PATCH 2/4] UBERF-9491 Filter out default undefined values from component props (#8052) Signed-off-by: Alexander Onnikov --- packages/ui/src/components/Component.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Component.svelte b/packages/ui/src/components/Component.svelte index 26f10aec6b..e83560e83e 100644 --- a/packages/ui/src/components/Component.svelte +++ b/packages/ui/src/components/Component.svelte @@ -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 } } From 711badfadbd2c7226985e2b5b8fb075927c67688 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Wed, 19 Feb 2025 14:09:58 +0400 Subject: [PATCH 3/4] uberf-9488: part of account unit tests (#8054) Signed-off-by: Alexey Zinoviev --- server/account/src/__tests__/utils.test.ts | 643 +++++++++++++++++---- server/account/src/utils.ts | 52 +- 2 files changed, 570 insertions(+), 125 deletions(-) diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 46752ca69c..85e3e85696 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -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) + }) + }) }) diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 956fa121f2..fac5a1026d 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -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) } From 7e18269f06a4e73964af8dcfa90d4496387dc4b3 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Wed, 19 Feb 2025 14:29:18 +0700 Subject: [PATCH 4/4] UBERF-9491 Filter out default undefined values from component props (#8052) Signed-off-by: Alexander Onnikov --- packages/ui/src/components/Component.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Component.svelte b/packages/ui/src/components/Component.svelte index 26f10aec6b..e83560e83e 100644 --- a/packages/ui/src/components/Component.svelte +++ b/packages/ui/src/components/Component.svelte @@ -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 } }