From 00ee0015394bed0bdcc97d55fae1a5cc36f8c0e5 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Wed, 31 May 2023 17:54:54 +0200 Subject: [PATCH] fix(tracker): fix for dying tests (added tabid to writer, refactored other tests) --- .github/workflows/ui-tests.js.yml | 2 +- tracker/tracker/package.json | 3 +- tracker/tracker/src/main/app/sanitizer.ts | 10 +- tracker/tracker/src/main/utils.ts | 10 +- tracker/tracker/src/tests/guards.unit.test.ts | 113 +++++++++++ .../tracker/src/tests/sanitizer.unit.test.ts | 135 +++++++++++++ tracker/tracker/src/tests/utils.unit.test.ts | 186 ++++++++++++++++++ .../src/webworker/BatchWriter.unit.test.ts | 9 +- 8 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 tracker/tracker/src/tests/guards.unit.test.ts create mode 100644 tracker/tracker/src/tests/sanitizer.unit.test.ts create mode 100644 tracker/tracker/src/tests/utils.unit.test.ts diff --git a/.github/workflows/ui-tests.js.yml b/.github/workflows/ui-tests.js.yml index c7b2f093f..56e2f95b4 100644 --- a/.github/workflows/ui-tests.js.yml +++ b/.github/workflows/ui-tests.js.yml @@ -50,7 +50,7 @@ jobs: - name: Jest tests run: | cd tracker/tracker - yarn test + yarn test:ci - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 with: diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index a10ea116c..3804afd21 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -22,7 +22,8 @@ "build": "npm run clean && npm run tscRun && npm run rollup && npm run compile", "prepare": "cd ../../ && husky install tracker/.husky/", "lint-front": "lint-staged", - "test": "jest" + "test": "jest --coverage=false", + "test:ci": "jest --coverage=true" }, "devDependencies": { "@babel/core": "^7.10.2", diff --git a/tracker/tracker/src/main/app/sanitizer.ts b/tracker/tracker/src/main/app/sanitizer.ts index 629d8ed5d..faeda2702 100644 --- a/tracker/tracker/src/main/app/sanitizer.ts +++ b/tracker/tracker/src/main/app/sanitizer.ts @@ -14,6 +14,11 @@ export interface Options { domSanitizer?: (node: Element) => SanitizeLevel } +export const stringWiper = (input: string) => + input + .trim() + .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') + export default class Sanitizer { private readonly obscured: Set = new Set() private readonly hidden: Set = new Set() @@ -59,10 +64,9 @@ export default class Sanitizer { sanitize(id: number, data: string): string { if (this.obscured.has(id)) { // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases? - return data - .trim() - .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') + return stringWiper(data) } + if (this.options.obscureTextNumbers) { data = data.replace(/\d/g, '0') } diff --git a/tracker/tracker/src/main/utils.ts b/tracker/tracker/src/main/utils.ts index ba919a64a..5b33503f6 100644 --- a/tracker/tracker/src/main/utils.ts +++ b/tracker/tracker/src/main/utils.ts @@ -82,14 +82,6 @@ export function hasOpenreplayAttribute(e: Element, attr: string): boolean { return false } -export function isIframeCrossdomain(e: HTMLIFrameElement): boolean { - try { - return e.contentWindow?.location.href !== window.location.href - } catch (e) { - return true - } -} - /** * checks if iframe is accessible **/ @@ -105,7 +97,7 @@ function dec2hex(dec: number) { return dec.toString(16).padStart(2, '0') } -export function generateRandomId(len: number) { +export function generateRandomId(len?: number) { const arr: Uint8Array = new Uint8Array((len || 40) / 2) // msCrypto = IE11 // @ts-ignore diff --git a/tracker/tracker/src/tests/guards.unit.test.ts b/tracker/tracker/src/tests/guards.unit.test.ts new file mode 100644 index 000000000..6690288b3 --- /dev/null +++ b/tracker/tracker/src/tests/guards.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from '@jest/globals' +import { + isNode, + isSVGElement, + isElementNode, + isCommentNode, + isTextNode, + isDocument, + isRootNode, + hasTag, +} from '../main/app/guards.js' + +describe('isNode', () => { + test('returns true for a valid Node object', () => { + const node = document.createElement('div') + expect(isNode(node)).toBe(true) + }) + + test('returns false for a non-Node object', () => { + const obj = { foo: 'bar' } + expect(isNode(obj)).toBe(false) + }) +}) + +describe('isSVGElement', () => { + test('returns true for an SVGElement object', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + expect(isSVGElement(svg)).toBe(true) + }) + + test('returns false for a non-SVGElement object', () => { + const div = document.createElement('div') + expect(isSVGElement(div)).toBe(false) + }) +}) + +describe('isElementNode', () => { + test('returns true for an Element object', () => { + const element = document.createElement('div') + expect(isElementNode(element)).toBe(true) + }) + + test('returns false for a non-Element object', () => { + const textNode = document.createTextNode('Hello') + expect(isElementNode(textNode)).toBe(false) + }) +}) + +describe('isCommentNode', () => { + test('returns true for a Comment object', () => { + const comment = document.createComment('This is a comment') + expect(isCommentNode(comment)).toBe(true) + }) + + test('returns false for a non-Comment object', () => { + const div = document.createElement('div') + expect(isCommentNode(div)).toBe(false) + }) +}) + +describe('isTextNode', () => { + test('returns true for a Text object', () => { + const textNode = document.createTextNode('Hello') + expect(isTextNode(textNode)).toBe(true) + }) + + test('returns false for a non-Text object', () => { + const div = document.createElement('div') + expect(isTextNode(div)).toBe(false) + }) +}) + +describe('isDocument', () => { + test('returns true for a Document object', () => { + const documentObj = document.implementation.createHTMLDocument('Test') + expect(isDocument(documentObj)).toBe(true) + }) + + test('returns false for a non-Document object', () => { + const div = document.createElement('div') + expect(isDocument(div)).toBe(false) + }) +}) + +describe('isRootNode', () => { + test('returns true for a Document object', () => { + const documentObj = document.implementation.createHTMLDocument('Test') + expect(isRootNode(documentObj)).toBe(true) + }) + + test('returns true for a DocumentFragment object', () => { + const fragment = document.createDocumentFragment() + expect(isRootNode(fragment)).toBe(true) + }) + + test('returns false for a non-root Node object', () => { + const div = document.createElement('div') + expect(isRootNode(div)).toBe(false) + }) +}) + +describe('hasTag', () => { + test('returns true if the element has the specified tag name', () => { + const element = document.createElement('input') + expect(hasTag(element, 'input')).toBe(true) + }) + + test('returns false if the element does not have the specified tag name', () => { + const element = document.createElement('div') + // @ts-expect-error + expect(hasTag(element, 'span')).toBe(false) + }) +}) diff --git a/tracker/tracker/src/tests/sanitizer.unit.test.ts b/tracker/tracker/src/tests/sanitizer.unit.test.ts new file mode 100644 index 000000000..30037a0e8 --- /dev/null +++ b/tracker/tracker/src/tests/sanitizer.unit.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals' +import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js' + +describe('stringWiper', () => { + test('should replace all characters with █', () => { + expect(stringWiper('Sensitive Data')).toBe('██████████████') + }) +}) + +describe('Sanitizer', () => { + let sanitizer: Sanitizer + + beforeEach(() => { + const options: Options = { + obscureTextEmails: true, + obscureTextNumbers: false, + domSanitizer: undefined, + } + const app = { + nodes: { + getID: (el: { mockId: number }) => el.mockId, + }, + } + // @ts-expect-error + sanitizer = new Sanitizer(app, options) + }) + + afterEach(() => { + sanitizer.clear() + }) + + test('should handle node and mark it as obscured if parent is obscured', () => { + sanitizer['obscured'].add(2) + sanitizer.handleNode(1, 2, document.createElement('div')) + expect(sanitizer.isObscured(1)).toBe(true) + }) + + test('should handle node and mark it as obscured if it has "masked" or "obscured" attribute', () => { + const node = document.createElement('div') + node.setAttribute('data-openreplay-obscured', '') + sanitizer.handleNode(1, 2, node) + expect(sanitizer.isObscured(1)).toBe(true) + }) + + test('should handle node and mark it as hidden if parent is hidden', () => { + sanitizer['hidden'].add(2) + sanitizer.handleNode(1, 2, document.createElement('div')) + expect(sanitizer.isHidden(1)).toBe(true) + }) + + test('should handle node and mark it as hidden if it has "htmlmasked" or "hidden" attribute', () => { + const node = document.createElement('div') + node.setAttribute('data-openreplay-hidden', '') + sanitizer.handleNode(1, 2, node) + expect(sanitizer.isHidden(1)).toBe(true) + }) + + test('should handle node and sanitize based on custom domSanitizer function', () => { + const domSanitizer = (node: Element): SanitizeLevel => { + if (node.tagName === 'SPAN') { + return SanitizeLevel.Obscured + } + if (node.tagName === 'DIV') { + return SanitizeLevel.Hidden + } + return SanitizeLevel.Plain + } + + const options: Options = { + obscureTextEmails: true, + obscureTextNumbers: false, + domSanitizer, + } + const app = { + nodes: { + getID: jest.fn(), + }, + } + + // @ts-expect-error + sanitizer = new Sanitizer(app, options) + + const spanNode = document.createElement('span') + const divNode = document.createElement('div') + const plainNode = document.createElement('p') + + sanitizer.handleNode(1, 2, spanNode) + sanitizer.handleNode(3, 4, divNode) + sanitizer.handleNode(5, 6, plainNode) + + expect(sanitizer.isObscured(1)).toBe(true) + expect(sanitizer.isHidden(3)).toBe(true) + expect(sanitizer.isObscured(5)).toBe(false) + expect(sanitizer.isHidden(5)).toBe(false) + }) + + test('should sanitize data as obscured if node is marked as obscured', () => { + sanitizer['obscured'].add(1) + const data = 'Sensitive Data' + + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual(stringWiper(data)) + }) + + test('should sanitize data by obscuring text numbers if enabled', () => { + sanitizer['options'].obscureTextNumbers = true + const data = 'Phone: 123-456-7890' + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual('Phone: 000-000-0000') + }) + + test('should sanitize data by obscuring text emails if enabled', () => { + sanitizer['options'].obscureTextEmails = true + const data = 'john.doe@example.com' + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual('********@*******.***') + }) + + test('should return inner text of an element securely by sanitizing it', () => { + const element = document.createElement('div') + sanitizer['obscured'].add(1) + // @ts-expect-error + element.mockId = 1 + element.innerText = 'Sensitive Data' + const sanitizedText = sanitizer.getInnerTextSecure(element) + expect(sanitizedText).toEqual('██████████████') + }) + + test('should return empty string if node element does not exist', () => { + const element = document.createElement('div') + element.innerText = 'Sensitive Data' + const sanitizedText = sanitizer.getInnerTextSecure(element) + expect(sanitizedText).toEqual('') + }) +}) diff --git a/tracker/tracker/src/tests/utils.unit.test.ts b/tracker/tracker/src/tests/utils.unit.test.ts new file mode 100644 index 000000000..2b7836a70 --- /dev/null +++ b/tracker/tracker/src/tests/utils.unit.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, jest, afterEach, beforeEach } from '@jest/globals' +import { + adjustTimeOrigin, + getTimeOrigin, + now, + stars, + normSpaces, + isURL, + deprecationWarn, + getLabelAttribute, + hasOpenreplayAttribute, + canAccessIframe, + generateRandomId, +} from '../main/utils.js' + +describe('adjustTimeOrigin', () => { + test('adjusts the time origin based on performance.now', () => { + jest.spyOn(Date, 'now').mockReturnValue(1000) + jest.spyOn(performance, 'now').mockReturnValue(1000) + adjustTimeOrigin() + + expect(getTimeOrigin()).toBe(0) + }) +}) + +describe('now', () => { + test('returns the current timestamp in milliseconds', () => { + jest.spyOn(Date, 'now').mockReturnValue(2550) + jest.spyOn(performance, 'now').mockReturnValue(2550) + + adjustTimeOrigin() + + expect(now()).toBe(2550) + }) +}) + +describe('stars', () => { + test('returns a string of asterisks with the same length as the input string', () => { + expect(stars('hello')).toBe('*****') + }) + + test('returns an empty string if the input string is empty', () => { + expect(stars('')).toBe('') + }) +}) + +describe('normSpaces', () => { + test('trims the string and replaces multiple spaces with a single space', () => { + expect(normSpaces(' hello world ')).toBe('hello world') + }) + + test('returns an empty string if the input string is empty', () => { + expect(normSpaces('')).toBe('') + }) +}) + +describe('isURL', () => { + test('returns true for a valid URL starting with "https://"', () => { + expect(isURL('https://example.com')).toBe(true) + }) + + test('returns true for a valid URL starting with "http://"', () => { + expect(isURL('http://example.com')).toBe(true) + }) + + test('returns false for a URL without a valid protocol', () => { + expect(isURL('example.com')).toBe(false) + }) + + test('returns false for an empty string', () => { + expect(isURL('')).toBe(false) + }) +}) + +describe('deprecationWarn', () => { + let consoleWarnSpy: jest.SpiedFunction<(args: any) => void> + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + test('prints a warning message for a deprecated feature', () => { + deprecationWarn('oldFeature', 'newFeature') + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: oldFeature is deprecated. Please, use newFeature instead. Visit https://docs.openreplay.com/ for more information.', + ) + }) + + test('does not print a warning message for a deprecated feature that has already been warned', () => { + deprecationWarn('oldFeature2', 'newFeature') + deprecationWarn('oldFeature2', 'newFeature') + expect(consoleWarnSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe('getLabelAttribute', () => { + test('returns the value of "data-openreplay-label" attribute if present', () => { + const element = document.createElement('div') + element.setAttribute('data-openreplay-label', 'Label') + expect(getLabelAttribute(element)).toBe('Label') + }) + + test('returns the value of "data-asayer-label" attribute if "data-openreplay-label" is not present (with deprecation warning)', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + const element = document.createElement('div') + element.setAttribute('data-asayer-label', 'Label') + expect(getLabelAttribute(element)).toBe('Label') + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: "data-asayer-label" attribute is deprecated. Please, use "data-openreplay-label" attribute instead. Visit https://docs.openreplay.com/ for more information.', + ) + consoleWarnSpy.mockRestore() + }) + + test('returns null if neither "data-openreplay-label" nor "data-asayer-label" are present', () => { + const element = document.createElement('div') + expect(getLabelAttribute(element)).toBeNull() + }) +}) + +describe('hasOpenreplayAttribute', () => { + let consoleWarnSpy: jest.SpiedFunction<(args: any) => void> + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + test('returns true and prints a deprecation warning for a deprecated openreplay attribute', () => { + const element = document.createElement('div') + element.setAttribute('data-openreplay-htmlmasked', 'true') + const result = hasOpenreplayAttribute(element, 'htmlmasked') + expect(result).toBe(true) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: "data-openreplay-htmlmasked" attribute is deprecated. Please, use "hidden" attribute instead. Visit https://docs.openreplay.com/installation/sanitize-data for more information.', + ) + }) + + test('returns false for a non-existent openreplay attribute', () => { + const element = document.createElement('div') + const result = hasOpenreplayAttribute(element, 'nonexistent') + expect(result).toBe(false) + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) +}) + +describe('canAccessIframe', () => { + test('returns true if the iframe has a contentDocument', () => { + const iframe = document.createElement('iframe') + Object.defineProperty(iframe, 'contentDocument', { + get: () => document.createElement('div'), + }) + expect(canAccessIframe(iframe)).toBe(true) + }) + + test('returns false if the iframe does not have a contentDocument', () => { + const iframe = document.createElement('iframe') + // Mock iframe.contentDocument to throw an error + Object.defineProperty(iframe, 'contentDocument', { + get: () => { + throw new Error('securityError') + }, + }) + expect(canAccessIframe(iframe)).toBe(false) + }) +}) + +describe('generateRandomId', () => { + test('generates a random ID with the specified length', () => { + const id = generateRandomId(10) + expect(id).toHaveLength(10) + expect(/^[0-9a-f]+$/.test(id)).toBe(true) + }) + + test('generates a random ID with the default length if no length is specified', () => { + const id = generateRandomId() + expect(id).toHaveLength(40) + expect(/^[0-9a-f]+$/.test(id)).toBe(true) + }) +}) diff --git a/tracker/tracker/src/webworker/BatchWriter.unit.test.ts b/tracker/tracker/src/webworker/BatchWriter.unit.test.ts index e9f039988..14dfdfd83 100644 --- a/tracker/tracker/src/webworker/BatchWriter.unit.test.ts +++ b/tracker/tracker/src/webworker/BatchWriter.unit.test.ts @@ -9,7 +9,7 @@ describe('BatchWriter', () => { beforeEach(() => { onBatchMock = jest.fn() - batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock) + batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock, '123') }) afterEach(() => { @@ -21,7 +21,8 @@ describe('BatchWriter', () => { expect(batchWriter['timestamp']).toBe(123456789) expect(batchWriter['url']).toBe('example.com') expect(batchWriter['onBatch']).toBe(onBatchMock) - expect(batchWriter['nextIndex']).toBe(0) + // we add tab id as first in the batch + expect(batchWriter['nextIndex']).toBe(1) expect(batchWriter['beaconSize']).toBe(200000) expect(batchWriter['encoder']).toBeDefined() expect(batchWriter['strDict']).toBeDefined() @@ -30,12 +31,14 @@ describe('BatchWriter', () => { }) test('writeType writes the type of the message', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeType'](message as Message) expect(result).toBe(true) }) test('writeFields encodes the message fields', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeFields'](message as Message) expect(result).toBe(true) @@ -52,6 +55,7 @@ describe('BatchWriter', () => { }) test('writeWithSize writes the message with its size', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeWithSize'](message as Message) expect(result).toBe(true) @@ -72,6 +76,7 @@ describe('BatchWriter', () => { }) test('writeMessage writes the given message', () => { + // @ts-ignore const message = [Messages.Type.Timestamp, 987654321] // @ts-ignore batchWriter['writeWithSize'] = jest.fn().mockReturnValue(true)