diff --git a/package.json b/package.json index 9b8b6c3e..6b61cf7b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react-test-renderer": "^16.12.0", "react-textarea-autosize": "^4.0.5", "sinon-as-promised": "^4.0.3", + "html-entities": "^2.6.0", "whatwg-fetch": "^1.0.0" }, "resolutions": { diff --git a/src/components/Container.jsx b/src/components/Container.jsx index cca6f8a5..86cc0c31 100644 --- a/src/components/Container.jsx +++ b/src/components/Container.jsx @@ -11,13 +11,15 @@ import NotificationContainer from '../helpers/Notification'; import Constants from '../constants'; import { IntlProvider } from 'react-intl'; import { executeEventsFromCurrentRecord } from '../helpers/ExecuteEvents'; +import { deepUnescapeStrings } from '../helpers/encodingUtils'; export class Container extends addMoreDecorator(Component) { constructor(props) { super(props); this.childControls = {}; - const { observations, metadata } = this.props; - const controlRecordTree = new ControlRecordTreeBuilder().build(metadata, observations); + const { observations } = this.props; + this.metadata = deepUnescapeStrings(this.props.metadata); + const controlRecordTree = new ControlRecordTreeBuilder().build(this.metadata, observations); this.updatedControlRecordTree = controlRecordTree; this.state = { errors: [], data: controlRecordTree, collapse: props.collapse, notification: {} }; @@ -28,7 +30,7 @@ export class Container extends addMoreDecorator(Component) { this.onEventTrigger = this.onEventTrigger.bind(this); this.showNotification = this.showNotification.bind(this); - const initScript = this.props.metadata.events && this.props.metadata.events.onFormInit; + const initScript = this.metadata.events && this.metadata.events.onFormInit; let updatedTree; try { if (initScript) { @@ -169,8 +171,8 @@ export class Container extends addMoreDecorator(Component) { } render() { - const { metadata: { controls, - name: formName, version: formVersion }, validate, translations, patient } = this.props; + const { controls, name: formName, version: formVersion } = this.metadata; + const { validate, translations, patient } = this.props; const formTranslations = { ...translations.labels, ...translations.concepts }; const patientUuid = patient ? patient.uuid : undefined; const childProps = { diff --git a/src/helpers/encodingUtils.js b/src/helpers/encodingUtils.js index d9d08cf8..9f2b1a75 100644 --- a/src/helpers/encodingUtils.js +++ b/src/helpers/encodingUtils.js @@ -1,3 +1,5 @@ +import { decode } from 'html-entities'; + export function utf8ToBase64(str) { if (str === undefined || str === null || str === '') { return ''; @@ -9,6 +11,28 @@ export function utf8ToBase64(str) { return btoa(binaryString); } +export function unescapeHtml(str) { + if (typeof str !== 'string') return str; + return decode(str); +} + +export function deepUnescapeStrings(obj) { + if (typeof obj === 'string') { + return unescapeHtml(obj); + } + if (Array.isArray(obj)) { + return obj.map(deepUnescapeStrings); + } + if (typeof obj === 'object' && obj !== null) { + const result = {}; + for (const key of Object.keys(obj)) { + result[key] = deepUnescapeStrings(obj[key]); + } + return result; + } + return obj; +} + export function base64ToUtf8(b64) { if (b64 === undefined || b64 === null || b64 === '') { return ''; @@ -22,7 +46,6 @@ export function base64ToUtf8(b64) { const decoder = new TextDecoder(); return decoder.decode(bytes); } catch (e) { - console.error('Error decoding base64 string:', e); - return ''; + throw e; } } diff --git a/test/helpers/encodingUtils.spec.js b/test/helpers/encodingUtils.spec.js new file mode 100644 index 00000000..22e2f5a1 --- /dev/null +++ b/test/helpers/encodingUtils.spec.js @@ -0,0 +1,135 @@ +import { expect } from 'chai'; +import { + unescapeHtml, + deepUnescapeStrings, + utf8ToBase64, + base64ToUtf8, +} from 'src/helpers/encodingUtils'; + +describe('encodingUtils', () => { + describe('unescapeHtml', () => { + it('should unescape < to <', () => { + expect(unescapeHtml('a < b')).to.equal('a < b'); + }); + + it('should unescape > to >', () => { + expect(unescapeHtml('a > b')).to.equal('a > b'); + }); + + it('should unescape & to &', () => { + expect(unescapeHtml('a & b')).to.equal('a & b'); + }); + + it('should unescape multiple entities in a single string', () => { + expect(unescapeHtml('if (a < b && c > d)')) + .to.equal('if (a < b && c > d)'); + }); + + it('should unescape " to "', () => { + expect(unescapeHtml('a "quoted" b')).to.equal('a "quoted" b'); + }); + + it('should unescape ' to \'', () => { + expect(unescapeHtml('it's')).to.equal("it's"); + }); + + it('should return the same string when no entities are present', () => { + expect(unescapeHtml('hello world')).to.equal('hello world'); + }); + + it('should return non-string values as-is', () => { + expect(unescapeHtml(42)).to.equal(42); + expect(unescapeHtml(null)).to.equal(null); + expect(unescapeHtml(undefined)).to.equal(undefined); + }); + }); + + describe('deepUnescapeStrings', () => { + it('should unescape a plain string', () => { + expect(deepUnescapeStrings('a < b')).to.equal('a < b'); + }); + + it('should unescape all string values in an object', () => { + const input = { name: 'test', script: 'if (a < b && c > d) {}' }; + const result = deepUnescapeStrings(input); + expect(result.name).to.equal('test'); + expect(result.script).to.equal('if (a < b && c > d) {}'); + }); + + it('should unescape strings in nested objects', () => { + const input = { + events: { + onFormInit: 'x > 0', + onChange: 'y < 10', + }, + }; + const result = deepUnescapeStrings(input); + expect(result.events.onFormInit).to.equal('x > 0'); + expect(result.events.onChange).to.equal('y < 10'); + }); + + it('should unescape strings inside arrays', () => { + const input = ['a < b', 'c > d']; + const result = deepUnescapeStrings(input); + expect(result).to.deep.equal(['a < b', 'c > d']); + }); + + it('should handle arrays of objects', () => { + const input = [{ label: '<bold>' }, { label: 'plain' }]; + const result = deepUnescapeStrings(input); + expect(result[0].label).to.equal(''); + expect(result[1].label).to.equal('plain'); + }); + + it('should return numbers as-is', () => { + expect(deepUnescapeStrings(42)).to.equal(42); + }); + + it('should return null as-is', () => { + expect(deepUnescapeStrings(null)).to.equal(null); + }); + + it('should return boolean as-is', () => { + expect(deepUnescapeStrings(true)).to.equal(true); + }); + + it('should handle a metadata-like structure with escaped events', () => { + const metadata = { + name: 'Vitals', + controls: [{ id: '1', type: 'label', value: 'Pulse' }], + events: { + onFormInit: 'function(ctx) { if (a < b && c > d) { return; } }', + }, + }; + const result = deepUnescapeStrings(metadata); + expect(result.events.onFormInit) + .to.equal('function(ctx) { if (a < b && c > d) { return; } }'); + expect(result.name).to.equal('Vitals'); + expect(result.controls[0].value).to.equal('Pulse'); + }); + }); + + describe('base64ToUtf8', () => { + it('should return empty string for undefined', () => { + expect(base64ToUtf8(undefined)).to.equal(''); + }); + + it('should return empty string for null', () => { + expect(base64ToUtf8(null)).to.equal(''); + }); + + it('should return empty string for empty string', () => { + expect(base64ToUtf8('')).to.equal(''); + }); + + it('should throw for invalid base64', () => { + expect(() => base64ToUtf8('not-valid-base64!!!')).to.throw(); + }); + + it('should decode a valid base64 string', () => { + const original = 'function(ctx) { return ctx; }'; + const encoded = utf8ToBase64(original); + expect(base64ToUtf8(encoded)).to.equal(original); + }); + }); +});