diff --git a/plugins/sdk/api/api.js b/plugins/sdk/api/api.js index 1f473335058..ec5b6545065 100644 --- a/plugins/sdk/api/api.js +++ b/plugins/sdk/api/api.js @@ -32,7 +32,16 @@ const validOptions = [ "bom_at", "bom_rqp", "bom_ra", - "bom_d" + "bom_d", + "upcl", // user property cache. dart only + "ew", // event whitelist dart only + "upw", // user property whitelist dart only + "sw", // segment whitelist dart only + "esw", // event segment whitelist dart only + "eb", // event blacklist dart only + "upb", // user property blacklist dart only + "sb", // segment blacklist dart only + "esb" // event segment blacklist dart only ]; plugins.register("/permissions/features", function(ob) { diff --git a/plugins/sdk/frontend/public/javascripts/countly.views.js b/plugins/sdk/frontend/public/javascripts/countly.views.js index 893a69d7da0..dd6e25b81e3 100644 --- a/plugins/sdk/frontend/public/javascripts/countly.views.js +++ b/plugins/sdk/frontend/public/javascripts/countly.views.js @@ -47,6 +47,11 @@ bom_d: { android: v2_android, ios: v2_ios, web: v2_web, flutter: v2_flutter, react_native: v2_react_native } }; + var nonJSONExperimentalKeys = ['eb', 'upb', 'sb', 'ew', 'upw', 'sw']; + var jsonExperimentalKeys = ['esb', 'esw']; + var shouldShowExperimental = true; + var experimentalKeys = ['upcl', 'filter_preset'].concat(nonJSONExperimentalKeys, jsonExperimentalKeys); + var FEATURE_NAME = "sdk"; var SDK = countlyVue.views.create({ template: CV.T('/sdk/templates/sdk-main.html'), @@ -121,14 +126,62 @@ var enforceData = enforcement || {}; for (var key in this.configs) { if (this.diff.indexOf(key) === -1) { - this.configs[key].value = typeof data[key] !== "undefined" ? data[key] : this.configs[key].default; + var stored = typeof data[key] !== "undefined" ? data[key] : this.configs[key].default; + // format experimental fields for UI + if (nonJSONExperimentalKeys.indexOf(key) !== -1) { + if (Array.isArray(stored)) { + this.configs[key].value = this.arrayToCsv(stored); + } + else if (typeof stored === 'string') { + this.configs[key].value = stored; + } + else { + this.configs[key].value = ''; + } + } + else if (jsonExperimentalKeys.indexOf(key) !== -1) { + if (typeof stored === 'object') { + try { + this.configs[key].value = JSON.stringify(stored, null, 2); + } + catch (ex) { + this.configs[key].value = '{}'; + } + } + else if (typeof stored === 'string') { + this.configs[key].value = stored; + } + else { + this.configs[key].value = '{}'; + } + } + else { + this.configs[key].value = stored; + } this.configs[key].enforced = !!enforceData[key]; } } + if (this.diff.indexOf('filter_preset') === -1 && typeof data.filter_preset === 'undefined') { + var hasBlacklist = (data.eb && data.eb.length) || (data.upb && data.upb.length) || (data.sb && data.sb.length) || (data.esb && Object.keys(data.esb || {}).length); + var hasWhitelist = (data.ew && data.ew.length) || (data.upw && data.upw.length) || (data.sw && data.sw.length) || (data.esw && Object.keys(data.esw || {}).length); + if (hasBlacklist && !hasWhitelist) { + this.configs.filter_preset.value = 'Blacklisting'; + } + else if (hasWhitelist && !hasBlacklist) { + this.configs.filter_preset.value = 'Whitelisting'; + } + else { + this.configs.filter_preset.value = this.configs.filter_preset.default; + } + } + return this.configs; }, isTableLoading: function() { return this.$store.getters["countlySDK/sdk/isTableLoading"]; + }, + showExperimental: function() { + return shouldShowExperimental; } }, data: function() { @@ -154,6 +207,10 @@ label: "SDK Limits", list: ["lkl", "lvs", "lsv", "lbc", "ltlpt", "ltl"] }, + experimental: { + label: "Experimental", + list: ["upcl", "filter_preset", "eb", "upb", "sb", "esb", "ew", "upw", "sw", "esw"] + }, }, configs: { tracking: { @@ -396,9 +453,102 @@ default: 60, enforced: false, value: null + }, + upcl: { + type: "number", + name: "User Property Cache", + description: "How many user property to store in cache before they would be batched and sent to server (default: 100)", + default: 100, + enforced: false, + value: null + }, + filter_preset: { + type: "preset", + name: "Filtering Preset", + description: "Choose whether to use Blacklisting or Whitelisting presets for filtering", + default: "Blacklisting", + enforced: false, + value: null, + presets: [ + { name: "Blacklisting" }, + { name: "Whitelisting" }, + ] + }, + eb: { + type: "text", + name: "Event Blacklist", + description: "CSV* list of custom event keys to blacklist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'event1,event2 or "event3"' } + }, + upb: { + type: "text", + name: "User Property Blacklist", + description: "CSV* list of user property keys to blacklist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'prop1,prop2 or "prop3"' } + }, + sb: { + type: "text", + name: "Segmentation Blacklist", + description: "CSV* list of segmentation keys to blacklist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'key1,key2 or "key3"' } + }, + esb: { + type: "text", + name: "Event Segmentation Blacklist", + description: "Arrays of segmentation keys to blacklist for specific events (default: {})
Example: { \"event1\": [\"seg1\", \"seg2\"] }", + default: "{}", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 6, placeholder: '{"event1": ["seg1","seg2"]}' } + }, + ew: { + type: "text", + name: "Event Whitelist", + description: "CSV* list of custom event keys to whitelist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'event1,event2 or "event3"' } + }, + upw: { + type: "text", + name: "User Property Whitelist", + description: "CSV* list of user property keys to whitelist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'prop1,prop2 or "prop3"' } + }, + sw: { + type: "text", + name: "Segmentation Whitelist", + description: "CSV* list of segmentation keys to whitelist in SDK (default: empty)
* Use double quotes for values with commas", + default: "", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 4, placeholder: 'key1,key2 or "key3"' } + }, + esw: { + type: "text", + name: "Event Segmentation Whitelist", + description: "Arrays of segmentation keys to whitelist for specific events (default: {})
Example: { \"event1\": [\"seg1\", \"seg2\"] }", + default: "{}", + enforced: false, + value: null, + attrs: { type: 'textarea', rows: 6, placeholder: '{"event1": ["seg1","seg2"]}' } } }, diff: [], + validationErrors: {}, description: "Not all SDKs and SDK versions yet support this feature. Refer to respective SDK documentation for more information" }; }, @@ -406,9 +556,67 @@ var self = this; this.$nextTick(function() { self.checkSdkSupport(); + self.isJSONInputValid('esb'); + self.isJSONInputValid('esw'); }); }, methods: { + /** + * returns whether a given key should be shown in the UI + * @param {string} key - Config key to check + * @returns {boolean} - True if should be shown, false otherwise + */ + shouldShowKey: function(key) { + if (!this.getData || !this.getData[key]) { + return false; + } + if (!shouldShowExperimental && experimentalKeys.indexOf(key) !== -1) { + return false; + } + if (key === 'filter_preset') { + return true; + } + var blacklistKeys = ['eb', 'upb', 'sb', 'esb']; + var whitelistKeys = ['ew', 'upw', 'sw', 'esw']; + if (blacklistKeys.indexOf(key) !== -1) { + return (this.getData.filter_preset && (this.getData.filter_preset.value === 'Blacklisting')); + } + if (whitelistKeys.indexOf(key) !== -1) { + return (this.getData.filter_preset && (this.getData.filter_preset.value === 'Whitelisting')); + } + return (blacklistKeys.concat(whitelistKeys).indexOf(key) === -1); + }, + + /** + * normalized value retrieval (uses explicit value or default) + * @param {string} key - Config key to get value for + * @returns {*} - Normalized value (explicit or default) + */ + valueFor: function(key) { + if (!this.getData || !this.getData[key]) { + return undefined; + } + // prefer explicit value if not null/undefined + var val = this.getData[key].value; + if (typeof val !== 'undefined' && val !== null) { + return val; + } + return this.getData[key].default; + }, + + /** + * Generate slug/test-id from display name or fallback key + * @param {string} key - Config key to get slug for + * @returns {string} - Slugified version of the display name or key + */ + slugFor: function(key) { + if (!this.getData || !this.getData[key]) { + return key; + } + var name = this.getData[key].name || key; + return name.toLowerCase().replaceAll(' ', '-'); + }, + onChange: function(key, value) { log("onChange", key, value); this.configs[key].value = value; @@ -418,6 +626,9 @@ this.diff.push("bom_preset"); } } + if (jsonExperimentalKeys.indexOf(key) !== -1) { + this.isJSONInputValid(key); + } if (this.diff.indexOf(key) === -1) { this.diff.push(key); } @@ -434,6 +645,87 @@ } } }, + + /** + * Quoted CSV to array parser + * @param {String} str - CSV string + * @returns {Array} - Array of parsed values + */ + csvToArray: function(str) { + if (typeof str !== 'string') { + return []; + } + var re = /(?:\s*("(?:[^"]|"")*"|[^,]*?)\s*)(?:,|$)/g; + return Array.from(str.matchAll(re)) + .map(function(m) { + var val = m[1]; + if (!val) { + return null; + } + if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') { + val = val.slice(1, -1).replace(/""/g, '"'); + } + else { + val = val.trim(); + } + return val.length ? val : null; + }) + .filter(function(v) { + return v !== null; + }); + }, + + /** + * Convert an array to a CSV string + * @param {Array} arr - Array of values + * @returns {String} - CSV string + */ + arrayToCsv: function(arr) { + if (!Array.isArray(arr)) { + return ''; + } + return arr.filter(function(e) { + return e !== null; + }).map(function(e) { + e = String(e); + // quote if contains comma, quote, newline or carriage return, or starts/ends with whitespace + if (/[,"\n\r]/.test(e) || /^\s|\s$/.test(e)) { + return '"' + e.replace(/"/g, '""') + '"'; + } + return e; + }).join(','); + }, + + /** + * Validate a string input and set validationErrors accordingly + * @param {String} key - Config key to validate + * @returns {Boolean} - True if valid, false otherwise + */ + isJSONInputValid: function(key) { + if (!key) { + return true; + } + var val = this.configs[key].value; + if (typeof val === 'string') { + try { + JSON.parse(val); + this.validationErrors[key] = ''; + return true; + } + catch (ex) { + this.validationErrors[key] = ex.message || 'Invalid JSON'; + return false; + } + } + else if (typeof val === 'object') { // already parsed + this.validationErrors[key] = ''; + return true; + } + else { + this.validationErrors[key] = 'Invalid JSON'; + return false; + } + }, downloadConfig: function() { log("downloadConfig"); var params = this.$store.getters["countlySDK/sdk/all"] || {}; @@ -480,6 +772,28 @@ } }); } + if (key === 'filter_preset') { + if (preset.name === 'Blacklisting') { + ['ew', 'upw', 'sw', 'esw'].forEach(function(k) { + if (this.configs[k]) { + this.configs[k].enforced = false; + if (this.diff.indexOf(k) === -1) { + this.diff.push(k); + } + } + }, this); + } + else if (preset.name === 'Whitelisting') { + ['eb', 'upb', 'sb', 'esb'].forEach(function(k) { + if (this.configs[k]) { + this.configs[k].enforced = false; + if (this.diff.indexOf(k) === -1) { + this.diff.push(k); + } + } + }, this); + } + } }, enforce(key) { if (key && !this.configs[key]) { @@ -492,8 +806,7 @@ helper_title = "Enforce current setting?"; } var self = this; - // eslint-disable-next-line no-console - console.log(`enforce:[${key}]`); + log(`enforce:[${key}]`); CountlyHelpers.confirm(helper_msg, "green", function(result) { if (!result) { @@ -589,6 +902,18 @@ } } if (self.diff.length !== 0) { + try { + if (typeof self.configs.esb.value === 'string') { + JSON.parse(self.configs.esb.value || '{}'); + } + if (typeof self.configs.esw.value === 'string') { + JSON.parse(self.configs.esw.value || '{}'); + } + } + catch (ex) { + CountlyHelpers.notify({ message: ex.message || 'Invalid experimental configuration', sticky: false, type: 'error' }); + return; + } self.save(); } }, @@ -597,14 +922,80 @@ ); }, save: function(enforcement) { + if (this.validationErrors && Object.keys(this.validationErrors).some(function(k) { + return !!this.validationErrors[k]; + }, this)) { + CountlyHelpers.notify({ message: 'Please fix format errors before saving', sticky: false, type: 'error' }); + return; + } var params = this.$store.getters["countlySDK/sdk/all"]; log(`save with enforcement: ${JSON.stringify(enforcement)}`); log(`save: ${JSON.stringify(params)} and diff: ${JSON.stringify(this.diff)}`); var data = params || {}; for (var i = 0; i < this.diff.length; i++) { - log(`save: ${this.diff[i]} = ${this.configs[this.diff[i]].value}`); - data[this.diff[i]] = this.configs[this.diff[i]].value; - this.configs[this.diff[i]].enforced = true; + var dkey = this.diff[i]; + var val = this.configs[dkey].value; + if (nonJSONExperimentalKeys.indexOf(dkey) !== -1) { + if (typeof val === 'string') { + var arr = this.csvToArray(val); + data[dkey] = arr; + } + else if (Array.isArray(val)) { + data[dkey] = val; + } + else { + data[dkey] = []; + } + } + else if (jsonExperimentalKeys.indexOf(dkey) !== -1) { + if (typeof val === 'string') { + try { + data[dkey] = JSON.parse(val || '{}'); + } + catch (ex) { + CountlyHelpers.notify({ message: (dkey === 'esb' ? 'Event Segmentation Blacklist' : 'Event Segmentation Whitelist') + ' contains invalid JSON', sticky: false, type: 'error' }); + return; + } + } + else if (typeof val === 'object') { + data[dkey] = val; + } + else { + data[dkey] = {}; + } + } + else { + data[dkey] = val; + } + log(`save: ${dkey} = ${JSON.stringify(data[dkey])}`); + this.configs[dkey].enforced = true; + } + if (this.diff.indexOf('filter_preset') !== -1) { + var presetValue = this.configs.filter_preset.value; + + if (!enforcement) { + enforcement = {}; + for (var ek in this.configs) { + enforcement[ek] = !!this.configs[ek].enforced; + } + } + + if (presetValue === 'Blacklisting') { + ['ew', 'upw', 'sw', 'esw'].forEach(function(k) { + enforcement[k] = false; + if (this.configs[k]) { + this.configs[k].enforced = false; + } + }, this); + } + else if (presetValue === 'Whitelisting') { + ['eb', 'upb', 'sb', 'esb'].forEach(function(k) { + enforcement[k] = false; + if (this.configs[k]) { + this.configs[k].enforced = false; + } + }, this); + } } if (!enforcement) { enforcement = {}; @@ -627,7 +1018,38 @@ log("unpatch", params); var data = params || {}; for (var key in this.configs) { - this.configs[key].value = typeof data[key] !== "undefined" ? data[key] : this.configs[key].default; + var stored = typeof data[key] !== "undefined" ? data[key] : this.configs[key].default; + if (nonJSONExperimentalKeys.indexOf(key) !== -1) { + if (Array.isArray(stored)) { + this.configs[key].value = this.arrayToCsv(stored); + } + else if (typeof stored === 'string') { + this.configs[key].value = stored; + } + else { + this.configs[key].value = ''; + } + } + else if (jsonExperimentalKeys.indexOf(key) !== -1) { + if (typeof stored === 'object') { + try { + this.configs[key].value = JSON.stringify(stored, null, 2); + } + catch (ex) { + this.configs[key].value = '{}'; + } + } + else if (typeof stored === 'string') { + this.configs[key].value = stored; + } + else { + this.configs[key].value = '{}'; + } + this.isJSONInputValid(key); // re-validate after discarding changes + } + else { + this.configs[key].value = stored; + } } }, semverToNumber: function(version) { @@ -679,7 +1101,12 @@ checkSdkSupport: function() { log("checkSdkSupport"); for (var key in this.configs) { + this.configs[key].experimental = false; this.configs[key].tooltipMessage = "No SDK data present. Please use the latest versions of Android, Web, iOS, Flutter or RN SDKs to use this option."; + if (key === 'upcl' || key === 'eb' || key === 'upb' || key === 'sb' || key === 'esb') { + this.configs[key].experimental = true; + this.configs[key].tooltipMessage = "This is an experimental option. SDK support for this option may be limited or unavailable."; + } this.configs[key].tooltipClass = 'tooltip-neutral'; } diff --git a/plugins/sdk/frontend/public/stylesheets/main.scss b/plugins/sdk/frontend/public/stylesheets/main.scss index 28d36cb8ea7..377d64e9f40 100644 --- a/plugins/sdk/frontend/public/stylesheets/main.scss +++ b/plugins/sdk/frontend/public/stylesheets/main.scss @@ -110,3 +110,61 @@ border-color: #c6e2ff; } } + +/* Some changes for making left column of config to stay top-aligned. */ +.bu-columns.config-section, +.config-section.bu-columns, +.config-section.bu-is-vcentered, +.bu-columns.config-section.bu-is-vcentered { + align-items: flex-start !important; +} + +/* SDK config validation styles */ +.config-invalid textarea, +.config-invalid input, +.config-invalid .el-textarea__inner { + border-color: var(--cly-danger, #e74c3c) !important; + box-shadow: 0 0 0 1px rgba(231, 76, 60, 0.12) !important; +} + +.validation-wrapper { + display: flex; + flex-direction: column; + width: 100%; + align-items: stretch; +} + +.config-validation-box { + margin-top: 6px; + font-size: 0.95em; + width: 100%; + box-sizing: border-box; +} +.config-validation-box .status-box { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; +} +.config-validation-box .status-box.valid { + color: var(--cly-success, #176f2c); + background: rgba(23, 111, 44, 0.06); + border-color: rgba(23, 111, 44, 0.18); +} +.config-validation-box .status-box.invalid { + color: var(--cly-danger, #c0392b); + background: rgba(192, 57, 43, 0.04); + border-color: rgba(192, 57, 43, 0.18); +} +.config-validation-box .dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} +.config-validation-box .dot.green { background: var(--cly-success, #2ecc71); } +.config-validation-box .dot.red { background: var(--cly-danger, #e74c3c); } diff --git a/plugins/sdk/frontend/public/templates/config.html b/plugins/sdk/frontend/public/templates/config.html index 14915847cb1..ebe4dd5c3e2 100644 --- a/plugins/sdk/frontend/public/templates/config.html +++ b/plugins/sdk/frontend/public/templates/config.html @@ -24,7 +24,7 @@
-
+

{{group.label}} @@ -32,128 +32,174 @@

+

- + + +
\ No newline at end of file diff --git a/plugins/sdk/tests/tests.js b/plugins/sdk/tests/tests.js index 7bd2ae8565a..f4a019a4e95 100644 --- a/plugins/sdk/tests/tests.js +++ b/plugins/sdk/tests/tests.js @@ -171,6 +171,19 @@ describe('SDK Plugin', function() { }); }); + it('1.1 uploads config via POST', function(done) { + request + .post('/o') + .send({ method: 'config-upload', api_key: API_KEY_ADMIN, app_id: APP_ID, config: JSON.stringify({}) }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.be.an.Object(); + res.body.should.have.property('result', 'Success'); + done(); + }); + }); + checkBadCredentials('/o', 'config-upload'); it('7. should reject invalid config format', function(done) { diff --git a/plugins/sdk/tests/validation_tests.js b/plugins/sdk/tests/validation_tests.js new file mode 100644 index 00000000000..5eae108df33 --- /dev/null +++ b/plugins/sdk/tests/validation_tests.js @@ -0,0 +1,250 @@ +const spt = require('supertest'); +const should = require('should'); +const testUtils = require('../../../test/testUtils'); + +const request = spt(testUtils.url); +// change these in local testing directly or set env vars (also COUNTLY_CONFIG_HOSTNAME should be set with port) +let API_KEY_ADMIN = testUtils.get("API_KEY_ADMIN"); +let APP_KEY = testUtils.get('APP_KEY'); +let APP_ID = testUtils.get("APP_ID"); + +describe('CSV/Array and JSON validation', function() { + function unescapeHtml(str) { + if (typeof str !== 'string') { + return str; + } + return str.replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'"); + } + before(function(done) { + const enforcement = { + eb: true, + upb: true, + sb: true, + esb: true + }; + + request + .post('/i/sdk-config/update-enforcement') + .query({ api_key: API_KEY_ADMIN, app_id: APP_ID, enforcement: JSON.stringify(enforcement) }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('result', 'Success'); + done(); + }); + }); + + it('1. should save arrays for eb/upb/sb and objects for esb when provided as proper types', function(done) { + const parameter = { + eb: ['a', 'b, c', ' d '], + upb: ['user_prop_1', 'user,prop,2'], + sb: ['seg1', 'seg2'], + esb: { 'event1': ['a', 'b'] } + }; + + request + .post('/i/sdk-config/update-parameter') + .send({ api_key: API_KEY_ADMIN, app_id: APP_ID, parameter: JSON.stringify(parameter) }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('result', 'Success'); + + request + .get('/o/sdk') + .query({ method: 'sc', app_key: APP_KEY, device_id: 'test' }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('c'); + const c = res.body.c; + c.should.have.property('eb'); + c.eb.should.be.an.Array(); + c.eb.should.have.length(3); + c.eb.should.containEql('b, c'); + c.eb.should.containEql(' d '); + c.eb.should.containEql('a'); + + c.should.have.property('upb'); + c.upb.should.be.an.Array(); + c.upb.should.containEql('user,prop,2'); + c.upb.should.containEql('user_prop_1'); + c.upb.should.have.length(2); + + c.should.have.property('sb'); + c.sb.should.be.an.Array(); + c.sb.should.have.length(2); + c.sb.should.containEql('seg1'); + c.sb.should.containEql('seg2'); + + c.should.have.property('esb'); + c.esb.should.be.an.Object(); + c.esb.should.have.property('event1'); + c.esb.event1.should.be.an.Array(); + c.esb.event1.should.have.length(2); + c.esb.event1.should.containEql('a'); + c.esb.event1.should.containEql('b'); + done(); + }); + }); + }); + + // TODO: in future we may want to auto-parse CSV strings to arrays when uploaded, but for now front-end does this + it('2. currently stores CSV strings as strings (server does not auto-parse CSV) and esb string stays string', function(done) { + const parameter = { + eb: 'one, "two, too", three', + esb: 'this is not json' + }; + + request + .post('/i/sdk-config/update-parameter') + .send({ api_key: API_KEY_ADMIN, app_id: APP_ID, parameter: JSON.stringify(parameter) }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('result', 'Success'); + + request + .get('/o/sdk') + .query({ method: 'sc', app_key: APP_KEY, device_id: 'test' }) + .expect(200) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('c'); + const c = res.body.c; + c.should.have.property('eb'); + c.eb.should.be.a.String(); + unescapeHtml(c.eb).should.be.exactly('one, "two, too", three'); + + c.should.have.property('esb'); + c.esb.should.be.a.String(); + c.esb.should.be.exactly('this is not json'); + done(); + }); + }); + }); + + it('3. should reject invalid top-level parameter JSON (string) with 400', function(done) { + request + .post('/i/sdk-config/update-parameter') + .send({ api_key: API_KEY_ADMIN, app_id: APP_ID, parameter: 'invalid json' }) + .expect(400) + .end(function(err, res) { + should.not.exist(err); + res.body.should.have.property('result', 'Error parsing parameter'); + done(); + }); + }); +}); + +// CSV unit tests +describe('CSV parse/serialize edge cases', function() { + // copy pasta methods + function csvToArray(str) { + if (typeof str !== 'string') { + return []; + } + return Array.from(str.matchAll(/(?:\s*("(?:[^"]|"")*"|[^,]*?)\s*)(?:,|$)/g)).map(function(m) { + var val = m[1]; + if (!val) { + return null; + } + if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') { + val = val.slice(1, -1).replace(/""/g, '"'); + } + else { + val = val.trim(); + } + return val.length ? val : null; + }).filter(function(v) { + return v !== null; + }); + } + + function arrayToCsv(arr) { + if (!Array.isArray(arr)) { + return ''; + } + return arr.filter(function(e) { + return e != null; + }).map(function(e) { + e = String(e); + // quote if contains comma, quote, newline or carriage return, or starts/ends with whitespace + if (/[,"\n\r]/.test(e) || /^\s|\s$/.test(e)) { + return '"' + e.replace(/"/g, '""') + '"'; + } + return e; + }).join(','); + } + + it('handles commas inside quoted fields', function() { + const s = 'one,"two, too",three'; + csvToArray(s).should.eql(['one', 'two, too', 'three']); + arrayToCsv(['one', 'two, too', 'three']).should.eql(s); + }); + + it('handles escaped quotes inside quoted fields', function() { + const s = 'a,"b""c",d'; + csvToArray(s).should.eql(['a', 'b"c', 'd']); + arrayToCsv(['a', 'b"c', 'd']).should.eql(s); + }); + + it('handles newlines inside quoted fields', function() { + const s = '"line1\nline2",simple'; + csvToArray(s).should.eql(['line1\nline2', 'simple']); + arrayToCsv(['line1\nline2', 'simple']).should.eql(s); + }); + + it('drops empty fields', function() { + const s = 'a,,b,,'; + csvToArray(s).should.eql(['a', 'b']); + arrayToCsv(['a', 'b']).should.eql('a,b'); + }); + + it('arrayToCsv quotes fields when necessary and roundtrips correctly', function() { + const arr = ['simple', 'has,comma', ' hasspace ', 'quotes"inside', 'multi\nline']; + const csv = arrayToCsv(arr); + csv.should.be.a.String(); + csv.should.eql('simple,"has,comma"," hasspace ","quotes""inside","multi\nline"'); + csvToArray(csv).should.eql(['simple', 'has,comma', ' hasspace ', 'quotes"inside', 'multi\nline']); + }); + + it('arrayToCsv produces empty entries for null/undefined which arrayToCsv will drop', function() { + const arr = ['a', null, undefined, 'b']; + const csv = arrayToCsv(arr); + csv.should.be.a.String(); + csv.should.eql('a,b'); + csvToArray(csv).should.eql(['a', 'b']); + }); + + it('handles unicode characters correctly', function() { + const arr = ['emoji 😊', 'accenté', '中文,文本']; + const csv = arrayToCsv(arr); + csv.should.be.a.String(); + csv.should.eql('emoji 😊,accenté,"中文,文本"'); + csvToArray(csv).should.eql(['emoji 😊', 'accenté', '中文,文本']); + }); + + it('handles extremely long fields', function() { + const long = 'x'.repeat(100000); // 100k chars + const arr = ['start', long, 'end']; + const csv = arrayToCsv(arr); + csvToArray(csv).should.eql(['start', long, 'end']); + }).timeout(5000); + + it('handles carriage returns inside quoted fields (CR)', function() { + const s = '"line1\rline2",after'; + csvToArray(s).should.eql(['line1\rline2', 'after']); + arrayToCsv(['line1\rline2', 'after']).should.eql(s); + }); + + it('handles CRLF inside quoted fields (CRLF)', function() { + const s = '"a\r\nb",c'; + csvToArray(s).should.eql(['a\r\nb', 'c']); + arrayToCsv(['a\r\nb', 'c']).should.eql(s); + }); +});