diff --git a/__tests__/helpers.spec.js b/__tests__/helpers.spec.js index fe65089..1a1efbd 100644 --- a/__tests__/helpers.spec.js +++ b/__tests__/helpers.spec.js @@ -123,4 +123,215 @@ describe('Helpers', () => { expect(fs.open).toHaveBeenCalledWith('rp-launch-uuid-fileOne.tmp', 'w', expect.any(Function)); }); }); + + describe('cleanBinaryCharacters', () => { + it('should return empty string for falsy input', () => { + expect(helpers.cleanBinaryCharacters('')).toBe(''); + expect(helpers.cleanBinaryCharacters(null)).toBe(''); + expect(helpers.cleanBinaryCharacters(undefined)).toBe(''); + }); + + it('should replace NUL character with replacement char', () => { + expect(helpers.cleanBinaryCharacters('hello\x00world')).toBe('hello\uFFFDworld'); + }); + + it('should replace ESC character with replacement char', () => { + expect(helpers.cleanBinaryCharacters('test\x1Bvalue')).toBe('test\uFFFDvalue'); + }); + + it('should replace multiple binary characters', () => { + const input = '\x00start\x01mid\x1Bend'; + const result = helpers.cleanBinaryCharacters(input); + expect(result).toBe('\uFFFDstart\uFFFDmid\uFFFDend'); + expect(result).not.toContain('\x00'); + expect(result).not.toContain('\x01'); + expect(result).not.toContain('\x1B'); + }); + + it('should not replace normal whitespace characters (tab, newline, carriage return)', () => { + const input = 'line1\tindented\nline2\r\nline3'; + expect(helpers.cleanBinaryCharacters(input)).toBe(input); + }); + + it('should leave regular text unchanged', () => { + expect(helpers.cleanBinaryCharacters('Hello World 123!')).toBe('Hello World 123!'); + }); + + it('should replace all purely binary control characters', () => { + const binaryCodes = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0b, 0x0c, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, + 0x1f, 0x7f, + ]; + const input = binaryCodes.map((c) => String.fromCharCode(c)).join(''); + const result = helpers.cleanBinaryCharacters(input); + expect(result).toBe('\uFFFD'.repeat(binaryCodes.length)); + }); + + it('should replace BS (\\x08) control character', () => { + expect(helpers.cleanBinaryCharacters('foo\x08bar')).toBe('foo\uFFFDbar'); + }); + + it('should replace VT (\\x0B) control character', () => { + expect(helpers.cleanBinaryCharacters('foo\x0Bbar')).toBe('foo\uFFFDbar'); + }); + + it('should replace FF (\\x0C) control character', () => { + expect(helpers.cleanBinaryCharacters('foo\x0Cbar')).toBe('foo\uFFFDbar'); + }); + + it('should replace SO..SI and DLE (\\x0E-\\x10) control characters', () => { + const input = 'a\x0Eb\x0Fc\x10d'; + expect(helpers.cleanBinaryCharacters(input)).toBe('a\uFFFDb\uFFFDc\uFFFDd'); + }); + + it('should replace FS..US (\\x1C-\\x1F) control characters', () => { + const input = 'a\x1Cb\x1Dc\x1Ed\x1Fe'; + expect(helpers.cleanBinaryCharacters(input)).toBe('a\uFFFDb\uFFFDc\uFFFDd\uFFFDe'); + }); + + it('should replace DEL (\\x7F) control character', () => { + expect(helpers.cleanBinaryCharacters('foo\x7Fbar')).toBe('foo\uFFFDbar'); + }); + + it('should preserve only TAB, LF, CR among C0 whitespace controls', () => { + expect(helpers.cleanBinaryCharacters('\t\n\r')).toBe('\t\n\r'); + }); + }); + + describe('sanitizeField', () => { + it('should return null/undefined for null/undefined input', () => { + expect(helpers.sanitizeField(null, 256)).toBeNull(); + expect(helpers.sanitizeField(undefined, 256)).toBeUndefined(); + }); + + it('should return empty string for empty string input', () => { + expect(helpers.sanitizeField('', 256)).toBe(''); + }); + + it('should not truncate strings within limit', () => { + expect(helpers.sanitizeField('short', 256)).toBe('short'); + }); + + it('should truncate strings exceeding limit with "..." suffix', () => { + const input = 'a'.repeat(300); + const result = helpers.sanitizeField(input, 256); + expect(result.length).toBe(256); + expect(result.endsWith('...')).toBe(true); + }); + + it('should clean binary characters and truncate', () => { + const input = 'bad\x00name' + 'n'.repeat(300); + const result = helpers.sanitizeField(input, 256); + expect(result).not.toContain('\x00'); + expect(result.length).toBe(256); + expect(result.endsWith('...')).toBe(true); + }); + + it('should skip binary cleanup when replaceBinaryChars is false', () => { + const input = 'bad\x00name'; + const result = helpers.sanitizeField(input, 256, { replaceBinaryChars: false }); + expect(result).toContain('\x00'); + }); + + it('should skip truncation when truncateFields is false', () => { + const input = 'a'.repeat(300); + const result = helpers.sanitizeField(input, 256, { truncateFields: false }); + expect(result.length).toBe(300); + }); + + it('should handle limit of 0', () => { + expect(helpers.sanitizeField('test', 0)).toBe(''); + }); + + it('should handle limit smaller than replacement length', () => { + const result = helpers.sanitizeField('a'.repeat(10), 2); + expect(result.length).toBe(2); + }); + }); + + describe('truncateAttributes', () => { + it('should return non-array input unchanged', () => { + expect(helpers.truncateAttributes(null)).toBeNull(); + expect(helpers.truncateAttributes(undefined)).toBeUndefined(); + }); + + it('should filter out non-object entries', () => { + const result = helpers.truncateAttributes(['string', 123, { key: 'k', value: 'v' }]); + expect(result).toEqual([{ key: 'k', value: 'v' }]); + }); + + it('should truncate attribute keys longer than 128 chars', () => { + const longKey = 'k'.repeat(140); + const result = helpers.truncateAttributes([{ key: longKey, value: 'v' }]); + expect(result[0].key.length).toBe(128); + expect(result[0].key.endsWith('...')).toBe(true); + }); + + it('should truncate attribute values longer than 128 chars', () => { + const longValue = 'v'.repeat(140); + const result = helpers.truncateAttributes([{ key: 'k', value: longValue }]); + expect(result[0].value.length).toBe(128); + expect(result[0].value.endsWith('...')).toBe(true); + }); + + it('should not truncate attributes within limit', () => { + const result = helpers.truncateAttributes([{ key: 'k'.repeat(128), value: 'v'.repeat(128) }]); + expect(result[0].key.length).toBe(128); + expect(result[0].value.length).toBe(128); + }); + + it('should preserve system attribute flag', () => { + const result = helpers.truncateAttributes([{ key: 'k', value: 'v', system: true }]); + expect(result[0].system).toBe(true); + }); + + it('should limit attributes to 256 entries sorted by key', () => { + const attributes = []; + for (let i = 0; i < 300; i++) { + attributes.push({ key: `k${i.toString().padStart(3, '0')}`, value: 'v' }); + } + const result = helpers.truncateAttributes(attributes); + expect(result.length).toBe(256); + expect(result[0].key).toBe('k000'); + expect(result[255].key).toBe('k255'); + }); + + it('should clean binary characters in attribute keys and values', () => { + const result = helpers.truncateAttributes([{ key: 'a\x00key', value: 'v\x1Balue' }]); + expect(result[0].key).not.toContain('\x00'); + expect(result[0].value).not.toContain('\x1B'); + expect(result[0].key).toContain('\uFFFD'); + expect(result[0].value).toContain('\uFFFD'); + }); + + it('should skip binary cleanup when replaceBinaryChars is false', () => { + const result = helpers.truncateAttributes([{ key: 'a\x00key', value: 'v\x1Balue' }], { + replaceBinaryChars: false, + }); + expect(result[0].key).toContain('\x00'); + expect(result[0].value).toContain('\x1B'); + }); + + it('should skip length truncation when truncateAttributes is false', () => { + const longValue = 'v'.repeat(200); + const result = helpers.truncateAttributes([{ key: 'k', value: longValue }], { + truncateAttributes: false, + }); + expect(result[0].value.length).toBe(200); + }); + + it('should filter attributes without a value when truncation is enabled', () => { + const result = helpers.truncateAttributes([ + { key: 'k1', value: 'v1' }, + { key: 'k2' }, + ]); + expect(result.length).toBe(1); + expect(result[0].key).toBe('k1'); + }); + + it('should return empty array for empty input', () => { + expect(helpers.truncateAttributes([])).toEqual([]); + }); + }); }); diff --git a/__tests__/report-portal-client.spec.js b/__tests__/report-portal-client.spec.js index 4754279..9e47ce6 100644 --- a/__tests__/report-portal-client.spec.js +++ b/__tests__/report-portal-client.spec.js @@ -1074,6 +1074,225 @@ describe('ReportPortal javascript client', () => { }, 100); }); + describe('sanitization and truncation', () => { + it('should truncate long launch name in startLaunch', () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const myPromise = Promise.resolve({ id: 'testidlaunch' }); + jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + + const longName = 'n'.repeat(300); + client.startLaunch({ name: longName, startTime: 12345 }); + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.name.length).toBe(256); + expect(callArgs.name.endsWith('...')).toBe(true); + }); + + it('should clean binary characters in launch name', () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const myPromise = Promise.resolve({ id: 'testidlaunch' }); + jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + + client.startLaunch({ name: 'bad\x00launch\x1Bname', startTime: 12345 }); + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.name).not.toContain('\x00'); + expect(callArgs.name).not.toContain('\x1B'); + expect(callArgs.name).toContain('\uFFFD'); + }); + + it('should truncate launch description in startLaunch', () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const myPromise = Promise.resolve({ id: 'testidlaunch' }); + jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + + const longDesc = 'd'.repeat(3000); + client.startLaunch({ description: longDesc, startTime: 12345 }); + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.description.length).toBe(2048); + expect(callArgs.description.endsWith('...')).toBe(true); + }); + + it('should truncate and sanitize attributes in startLaunch', () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const myPromise = Promise.resolve({ id: 'testidlaunch' }); + jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue([]); + + client.startLaunch({ + startTime: 12345, + attributes: [{ key: 'k'.repeat(140), value: 'v'.repeat(140) }], + }); + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.attributes[0].key.length).toBe(128); + expect(callArgs.attributes[0].value.length).toBe(128); + }); + + it('should limit attribute count to 256 in startTestItem', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + client.map = { + launchId: { + children: [], + promiseStart: Promise.resolve(), + realId: 'realLaunchId', + finishSend: false, + }, + }; + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'item-id' }); + + const attributes = []; + for (let i = 0; i < 300; i++) { + attributes.push({ key: `k${i.toString().padStart(3, '0')}`, value: 'value' }); + } + + const item = client.startTestItem( + { name: 'Test', type: 'SUITE', attributes }, + 'launchId', + ); + await item.promise; + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.attributes.length).toBe(256); + }); + + it('should clean binary characters in test item name and description', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + client.map = { + launchId: { + children: [], + promiseStart: Promise.resolve(), + realId: 'realLaunchId', + finishSend: false, + }, + }; + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'item-id' }); + + const item = client.startTestItem( + { + name: 'test\x00item', + type: 'SUITE', + description: 'desc\x1Bription', + }, + 'launchId', + ); + await item.promise; + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.name).not.toContain('\x00'); + expect(callArgs.description).not.toContain('\x1B'); + }); + + it('should truncate long test item name to 1024 chars', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + client.map = { + launchId: { + children: [], + promiseStart: Promise.resolve(), + realId: 'realLaunchId', + finishSend: false, + }, + }; + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'item-id' }); + + const item = client.startTestItem( + { name: 'n'.repeat(2000), type: 'SUITE' }, + 'launchId', + ); + await item.promise; + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.name.length).toBe(1024); + expect(callArgs.name.endsWith('...')).toBe(true); + }); + + it('should clean binary chars in attributes in startTestItem', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + client.map = { + launchId: { + children: [], + promiseStart: Promise.resolve(), + realId: 'realLaunchId', + finishSend: false, + }, + }; + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'item-id' }); + + const item = client.startTestItem( + { + name: 'Test', + type: 'SUITE', + attributes: [{ key: 'a\x00key', value: 'v\x1Balue' }], + }, + 'launchId', + ); + await item.promise; + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.attributes[0].key).not.toContain('\x00'); + expect(callArgs.attributes[0].value).not.toContain('\x1B'); + }); + + it('should not apply sanitization when all options are disabled', () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + truncateAttributes: false, + truncateFields: false, + replaceBinaryChars: false, + }); + const myPromise = Promise.resolve({ id: 'testidlaunch' }); + jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue([]); + + const longName = 'n'.repeat(300); + client.startLaunch({ + name: longName, + startTime: 12345, + attributes: [{ key: 'a\x00key', value: 'v'.repeat(200) }], + }); + + const callArgs = client.restClient.create.mock.calls[0][1]; + expect(callArgs.name.length).toBe(300); + expect(callArgs.attributes[0].key).toContain('\x00'); + expect(callArgs.attributes[0].value.length).toBe(200); + }); + }); + describe('saveLog', () => { it('should return object with tempId and promise', () => { const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); diff --git a/index.d.ts b/index.d.ts index fedd35d..30b9df9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -159,6 +159,34 @@ declare module '@reportportal/client-javascript' { * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. */ oauth?: OAuthConfig; + /** + * Truncate test item attributes to default maximum length (128 chars). Default: true. + */ + truncateAttributes?: boolean; + /** + * Truncate request fields (name, description) to configured limits. Default: true. + */ + truncateFields?: boolean; + /** + * Replace binary control characters with the Unicode replacement character (U+FFFD). Default: true. + */ + replaceBinaryChars?: boolean; + /** + * Maximum allowed launch name length. Default: 256. + */ + launchNameLengthLimit?: number; + /** + * Maximum allowed test item name length. Default: 1024. + */ + itemNameLengthLimit?: number; + /** + * Maximum allowed launch description length. Default: 2048. + */ + launchDescriptionLengthLimit?: number; + /** + * Maximum allowed test item description length. Default: 65536. + */ + itemDescriptionLengthLimit?: number; } /** diff --git a/lib/commons/config.js b/lib/commons/config.js index 0700b92..010802c 100644 --- a/lib/commons/config.js +++ b/lib/commons/config.js @@ -1,5 +1,11 @@ const { ReportPortalRequiredOptionError, ReportPortalValidationError } = require('./errors'); const { OUTPUT_TYPES } = require('../constants/outputs'); +const { + LAUNCH_NAME_LENGTH_LIMIT, + ITEM_NAME_LENGTH_LIMIT, + LAUNCH_DESCRIPTION_LENGTH_LIMIT, + ITEM_DESCRIPTION_LENGTH_LIMIT, +} = require('../constants/limits'); const getOption = (options, optionName, defaultValue) => { if (!Object.prototype.hasOwnProperty.call(options, optionName) || !options[optionName]) { @@ -116,6 +122,15 @@ const getClientConfig = (options) => { launchUuidPrint: options.launchUuidPrint, launchUuidPrintOutput, skippedIsNotIssue: !!options.skippedIsNotIssue, + truncateAttributes: options.truncateAttributes !== false, + truncateFields: options.truncateFields !== false, + replaceBinaryChars: options.replaceBinaryChars !== false, + launchNameLengthLimit: options.launchNameLengthLimit || LAUNCH_NAME_LENGTH_LIMIT, + itemNameLengthLimit: options.itemNameLengthLimit || ITEM_NAME_LENGTH_LIMIT, + launchDescriptionLengthLimit: + options.launchDescriptionLengthLimit || LAUNCH_DESCRIPTION_LENGTH_LIMIT, + itemDescriptionLengthLimit: + options.itemDescriptionLengthLimit || ITEM_DESCRIPTION_LENGTH_LIMIT, }; } catch (error) { // don't throw the error up to not break the entire process diff --git a/lib/constants/limits.js b/lib/constants/limits.js new file mode 100644 index 0000000..731ab13 --- /dev/null +++ b/lib/constants/limits.js @@ -0,0 +1,17 @@ +const ATTRIBUTE_LENGTH_LIMIT = 128; +const ATTRIBUTE_NUMBER_LIMIT = 256; +const TRUNCATE_REPLACEMENT = '...'; +const LAUNCH_NAME_LENGTH_LIMIT = 256; +const ITEM_NAME_LENGTH_LIMIT = 1024; +const LAUNCH_DESCRIPTION_LENGTH_LIMIT = 2048; +const ITEM_DESCRIPTION_LENGTH_LIMIT = 65536; + +module.exports = { + ATTRIBUTE_LENGTH_LIMIT, + ATTRIBUTE_NUMBER_LIMIT, + TRUNCATE_REPLACEMENT, + LAUNCH_NAME_LENGTH_LIMIT, + ITEM_NAME_LENGTH_LIMIT, + LAUNCH_DESCRIPTION_LENGTH_LIMIT, + ITEM_DESCRIPTION_LENGTH_LIMIT, +}; diff --git a/lib/helpers.js b/lib/helpers.js index 4a7dcac..629baf4 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -3,12 +3,93 @@ const glob = require('glob'); const os = require('os'); const RestClient = require('./rest'); const pjson = require('../package.json'); +const { + ATTRIBUTE_LENGTH_LIMIT, + ATTRIBUTE_NUMBER_LIMIT, + TRUNCATE_REPLACEMENT, + LAUNCH_NAME_LENGTH_LIMIT, + ITEM_NAME_LENGTH_LIMIT, + LAUNCH_DESCRIPTION_LENGTH_LIMIT, + ITEM_DESCRIPTION_LENGTH_LIMIT, +} = require('./constants/limits'); const MIN = 3; const MAX = 256; const PJSON_VERSION = pjson.version; const PJSON_NAME = pjson.name; +const BINARY_CHAR_CODES = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0b, 0x0c, 0x0e, 0x0f, 0x10, 0x11, + 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x7f, +]; + +const BINARY_CHARS = new RegExp( + `[${BINARY_CHAR_CODES.map((c) => `\\u${c.toString(16).padStart(4, '0')}`).join('')}]`, + 'g', +); + +const cleanBinaryCharacters = (text) => { + if (!text) return ''; + return text.replace(BINARY_CHARS, '\uFFFD'); +}; + +const truncateString = (text, limit) => { + if (text.length <= limit) return text; + if (limit <= TRUNCATE_REPLACEMENT.length) return text.slice(0, limit); + return text.slice(0, limit - TRUNCATE_REPLACEMENT.length) + TRUNCATE_REPLACEMENT; +}; + +const sanitizeField = (value, limit, options = {}) => { + const { replaceBinaryChars = true, truncateFields = true } = options; + if (!value) return value; + + let result = value; + if (replaceBinaryChars) result = cleanBinaryCharacters(result); + if (truncateFields) result = truncateString(result, Math.max(0, limit)); + return result; +}; + +const isAttributeObject = (attr) => attr !== null && typeof attr === 'object'; + +const compareAttributesByKey = (a, b) => + String(a.key || '').localeCompare(String(b.key || '')); + +const cleanAttributeBinaryChars = (attr) => { + const result = { ...attr }; + if (result.key != null) result.key = cleanBinaryCharacters(String(result.key)); + if (result.value != null) result.value = cleanBinaryCharacters(String(result.value)); + return result; +}; + +const truncateAttributeFields = (attr) => { + const result = { ...attr }; + if (result.key) result.key = truncateString(String(result.key), ATTRIBUTE_LENGTH_LIMIT); + result.value = truncateString(String(result.value), ATTRIBUTE_LENGTH_LIMIT); + return result; +}; + +const truncateAttributes = (attributes, options = {}) => { + const { replaceBinaryChars = true, truncateAttributes: truncateEnabled = true } = options; + + if (!Array.isArray(attributes)) return attributes; + + let result = attributes.filter(isAttributeObject).map((attr) => ({ ...attr })); + if (result.length === 0) return []; + + if (result.length > ATTRIBUTE_NUMBER_LIMIT) { + result.sort(compareAttributesByKey); + result = result.slice(0, ATTRIBUTE_NUMBER_LIMIT); + } + + if (replaceBinaryChars) { + result = result.map(cleanAttributeBinaryChars); + } + + if (!truncateEnabled) return result; + + return result.filter((attr) => attr.value != null).map(truncateAttributeFields); +}; + const getUUIDFromFileName = (filename) => filename.match(/rplaunch-(.*)\.tmp/)[1]; const formatName = (name) => { @@ -100,6 +181,17 @@ const saveLaunchUuidToFile = (launchUuid) => { }; module.exports = { + ATTRIBUTE_LENGTH_LIMIT, + ATTRIBUTE_NUMBER_LIMIT, + TRUNCATE_REPLACEMENT, + LAUNCH_NAME_LENGTH_LIMIT, + ITEM_NAME_LENGTH_LIMIT, + LAUNCH_DESCRIPTION_LENGTH_LIMIT, + ITEM_DESCRIPTION_LENGTH_LIMIT, + cleanBinaryCharacters, + truncateString, + sanitizeField, + truncateAttributes, formatName, now, getServerResult, diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 7adbb25..ad80ec6 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -78,6 +78,24 @@ class RPClient { } } + sanitizeData(data, { nameLimit, descriptionLimit } = {}) { + const opts = { + replaceBinaryChars: this.config.replaceBinaryChars, + truncateFields: this.config.truncateFields, + truncateAttributes: this.config.truncateAttributes, + }; + if (nameLimit && data.name != null) { + data.name = helpers.sanitizeField(data.name, nameLimit, opts); + } + if (descriptionLimit && data.description != null) { + data.description = helpers.sanitizeField(data.description, descriptionLimit, opts); + } + if (data.attributes) { + data.attributes = helpers.truncateAttributes(data.attributes, opts); + } + return data; + } + calculateItemRetriesChainMapKey(launchId, parentId, name, itemId = '') { return `${launchId}__${parentId}__${name}__${itemId}`; } @@ -228,15 +246,21 @@ class RPClient { }; systemAttr.push(skippedIsNotIssueAttribute); } - const attributes = Array.isArray(launchDataRQ.attributes) + const rawAttributes = Array.isArray(launchDataRQ.attributes) ? launchDataRQ.attributes.concat(systemAttr) : systemAttr; - const launchData = { - name: this.config.launch || 'Test launch name', - startTime: this.helpers.now(), - ...launchDataRQ, - attributes, - }; + const launchData = this.sanitizeData( + { + name: this.config.launch || 'Test launch name', + startTime: this.helpers.now(), + ...launchDataRQ, + attributes: rawAttributes, + }, + { + nameLimit: this.config.launchNameLengthLimit, + descriptionLimit: this.config.launchDescriptionLengthLimit, + }, + ); this.map[tempId] = this.getNewItemObj((resolve, reject) => { const url = 'launch'; @@ -291,7 +315,10 @@ class RPClient { ); } - const finishExecutionData = { endTime: this.helpers.now(), ...finishExecutionRQ }; + const finishExecutionData = this.sanitizeData( + { endTime: this.helpers.now(), ...finishExecutionRQ }, + { descriptionLimit: this.config.launchDescriptionLengthLimit }, + ); launchObj.finishSend = true; Promise.all(launchObj.children.map((itemId) => this.map[itemId].promiseFinish)).then( @@ -452,6 +479,12 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } + + const sanitizedData = this.sanitizeData( + { ...launchData }, + { descriptionLimit: this.config.launchDescriptionLengthLimit }, + ); + let resolvePromise; let rejectPromise; const promise = new Promise((resolve, reject) => { @@ -462,8 +495,8 @@ class RPClient { launchObj.promiseFinish.then( () => { const url = ['launch', launchObj.realId, 'update'].join('/'); - this.logDebug(`Update launch with tempId ${launchTempId}`, launchData); - this.restClient.update(url, launchData).then( + this.logDebug(`Update launch with tempId ${launchTempId}`, sanitizedData); + this.restClient.update(url, sanitizedData).then( (response) => { this.logDebug(`Launch with tempId ${launchTempId} were successfully updated`, response); resolvePromise(response); @@ -532,11 +565,17 @@ class RPClient { const testCaseId = testItemDataRQ.testCaseId || helpers.generateTestCaseId(testItemDataRQ.codeRef, testItemDataRQ.parameters); - const testItemData = { - startTime: this.helpers.now(), - ...testItemDataRQ, - ...(testCaseId && { testCaseId }), - }; + const testItemData = this.sanitizeData( + { + startTime: this.helpers.now(), + ...testItemDataRQ, + ...(testCaseId && { testCaseId }), + }, + { + nameLimit: this.config.itemNameLengthLimit, + descriptionLimit: this.config.itemDescriptionLengthLimit, + }, + ); let parentPromise = launchObj.promiseStart; if (parentTempId) { @@ -632,11 +671,14 @@ class RPClient { ); } - const finishTestItemData = { - endTime: this.helpers.now(), - ...(itemObj.children.length ? {} : { status: RP_STATUSES.PASSED }), - ...finishTestItemRQ, - }; + const finishTestItemData = this.sanitizeData( + { + endTime: this.helpers.now(), + ...(itemObj.children.length ? {} : { status: RP_STATUSES.PASSED }), + ...finishTestItemRQ, + }, + { descriptionLimit: this.config.itemDescriptionLengthLimit }, + ); if ( finishTestItemData.status === RP_STATUSES.SKIPPED &&