diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8c98236 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "javascript-templates"] + path = javascript-templates + url = https://github.com/51degrees/javascript-templates diff --git a/javascript-templates b/javascript-templates new file mode 160000 index 0000000..1230271 --- /dev/null +++ b/javascript-templates @@ -0,0 +1 @@ +Subproject commit 12302710e81ea268c70158f4482f8f668c6dd316 diff --git a/javascript-templates/JavaScriptResource.mustache b/javascript-templates/JavaScriptResource.mustache deleted file mode 100644 index fbddd60..0000000 --- a/javascript-templates/JavaScriptResource.mustache +++ /dev/null @@ -1,617 +0,0 @@ -fiftyoneDegreesManager = function() { - 'use-strict'; - var json = {{&_jsonObject}}; - var parameters = {{&_parameters}}; - var sessionId = "{{&_sessionId}}"; - var sessionKey = "{{_objName}}_" + sessionId; - this.sessionId = sessionId; - var sequence = {{&_sequence}}; - - // Log any errors returned in the JSON object. - if(json.error !== undefined){ - console.log(json.error); - } - - // Log any warnings returned in the JSON object. - if (json.warnings !== undefined) { - console.log(json.warnings); - } - - // Set to true when the JSON object is complete. - var completed = false; - - // changeFuncs is an array of functions. When onChange is called and passed - // a function, the function is registered and is called when processing is - // complete. - var changeFuncs = []; - - // Counter is used to count how many pieces of callbacks are expected. Every - // time the completedCallback method is called, the counter is decremented - // by 1. - var callbackCounter = 0; - - // Array of JavaScript properties that have started evaluation. - var jsPropertiesStarted = []; - - // startsWith polyfill. - var startsWith = function(source, searchValue) { - return source.lastIndexOf(searchValue, 0) === 0; - } - - // endsWith polyfill. - var endsWith = function(source, searchValue) { - return source.substring(source.length - searchValue.length, source.length) === searchValue; - } - - var clearCache = function() { - if (sessionStorage) { - for (i = 0; i < sessionStorage.length; i++) { - key = sessionStorage.key(i); - if (startsWith(key, sessionKey)) { - sessionStorage.removeItem(key); - } - } - } - } - - var loadParameters = function() { - if (sessionStorage) { - var parametersString = sessionStorage.getItem(sessionKey + "_parameters"); - if (parametersString) { - parameters = JSON.parse(parametersString); - } - } - return parameters; - } - - var saveParameters = function(sourceParams) { - if (sourceParams) { - parameters = sourceParams - } - - if (sessionStorage) { - var parametersString = JSON.stringify(parameters); - sessionStorage.setItem(sessionKey + "_parameters", parametersString); - } - } - - // Get stored values with the '51D_' prefix that have been added to the request - // and return the data as key value pairs. This method is needed to extract - // stored values for inclusion in the GET or POST request for situations - // where CORS will prevent them from being sent to third parties. - var getFodSavedValues = function(){ - let fodValues = {}; - {{#_enableCookies}} - { - let keyValuePairs = document.cookie.split(/; */); - for(let nextPair of keyValuePairs) { - let firstEqualsLocation = nextPair.indexOf('='); - let name = nextPair.substring(0, firstEqualsLocation); - if(startsWith(name, "51D_")){ - let value = nextPair.substring(firstEqualsLocation+1); - fodValues[name] = value; - } - } - }; - {{/_enableCookies}} - {{^_enableCookies}} - { - // Collect values from session storage - let session51DataPrefix = sessionKey + "_data_"; - for(let i = 0, n = window.sessionStorage.length; i < n; ++i) { - let nextKey = window.sessionStorage.key(i); - if(startsWith(nextKey, session51DataPrefix)){ - let value = window.sessionStorage[nextKey]; - fodValues[nextKey.substring(session51DataPrefix.length)] = value; - } - } - }; - {{/_enableCookies}} - return fodValues; - }; - - // Extract key value pairs from the '51D_' prefixed values and concatenates - // them to form a query string for the subsequent json refresh. - var getParametersFromStorage = function(){ - var fodValues = getFodSavedValues(); - var keyValuePairs = []; - for (var key in fodValues) { - if (fodValues.hasOwnProperty(key)) { - // Url encode the value. - // This is done to ensure that invalid characters (e.g. = chars at the end of - // base 64 encoded strings) reach the server intact. - // The server will automatically decode the value before passing it into the - // Pipeline API. - keyValuePairs.push(key+"="+encodeURIComponent(fodValues[key])); - } - } - return keyValuePairs; - }; - - // Fetch a value safely from the json object. If a key somewhere down the - // '.' separated hierarchy of keys is not present then 'undefined' is - // returned rather than letting an exception occur. - var getFromJson = function(key, allowObjects, allowBooleans) { - var result = undefined; - if(typeof allowObjects === 'undefined') { allowObjects = false; } - if(typeof allowBooleans === 'undefined') { allowBooleans = false; } - - if (typeof(key) === 'string') { - var functions = json; - var segments = key.split('.'); - var i = 0; - while (functions !== undefined && i < segments.length) { - functions = functions[segments[i++]]; - } - if (typeof(functions) === "string") { - result = functions; - } else if (allowBooleans && typeof(functions) === "boolean") { - result = functions; - } else if (allowObjects && typeof functions === 'object' && functions !== null) { - result = functions; - } - } - return result; - } - - // Executed at the end of the processJSproperties method or for each piece - // of JavaScript which has 51D code injected. When there are 0 pieces of - // JavaScript left to process then reload the JSON object. - var completedCallback = function(resolve, reject){ - callbackCounter--; - if (callbackCounter === 0) { -{{#_updateEnabled}} - processRequest(resolve, reject); -{{/_updateEnabled}} - } else if (callbackCounter < 0){ - reject('Too many callbacks.'); - } - } - - // Executes any JavaScript contained in the JSON data. Session storage is - // used to check the process state of the JavaScript property, if the name - // of the property exists as a key then it has been processed. If all the - // processed JavaScript properties have been flagged as processed already - // then session storage is checked again for a JSON payload. If it exists - // then this is loaded into the managers internal data store. If not or if - // JavaScript properties have been processed then the call-back is - // processed with any new evidence produced by the JavaScript properties. - // If JavaScript properties are processed then a key containing the name of - // the JavaScript property is added to session storage. The complete flag is - // set to true when there is no further JavaScript to be processed. - var processJsProperties = function(resolve, reject, jsProperties, ignoreDelayFlag) { - var executeCallback = true; - var started = 0; - var cached = 0; - var cachedResponse = undefined; - var toProcess = 0; - - // If there is no cached response and there are JavaScript code snippets - // then process them and perform any call-backs required. - if (jsProperties !== undefined && jsProperties.length > 0) { - - {{^_enableCookies}} - let valueSetPrefix = new RegExp('document\\.cookie\\s*=\\s*(("([A-Za-z0-9_]+)\\s*=\\s*"\\s*\\+\\s*([^\\s};]+))|(`([A-Za-z0-9_]+)\\s*=\\s*\\$\\{([^}]+)\\}`))', 'g'); - let session51DataPrefix = sessionKey + "_data_"; - let sessionSetPatch = 'window.sessionStorage["' + session51DataPrefix + '$3$6"]=$4$7'; - {{/_enableCookies}} - - // Execute each of the JavaScript property code snippets using the - // index of the value to access the value to avoid problems with - // JavaScript returning erroneous values. - for (var index = 0; index < jsProperties.length; index++) { - var name = jsProperties[index]; - if (jsPropertiesStarted.indexOf(name) === -1) { - var body = getFromJson(name); - - // If there is a body then this property should be processed. - if (body) { - toProcess++; - } - var isCached = sessionStorage && sessionStorage.getItem(sessionKey + "_property_" + name); - - if (!isCached) { - // Create new function bound to this instance and execute it. - // This is needed to ensure the scope of the function is - // associated with this instance if any members are altered or - // added. Avoids global scoped variables. - - var delay = getFromJson(name + 'delayexecution', false, true); - - if ((ignoreDelayFlag || (delay === undefined || delay === false)) && - body !== undefined) { - var func = undefined; - var searchString = '// 51D replace this comment with callback function.'; - completed = false; - jsPropertiesStarted.push(name); - started++; - - {{^_enableCookies}} - body = body.replaceAll(valueSetPrefix, sessionSetPatch); - {{/_enableCookies}} - - if (body.indexOf(searchString) !== -1){ - callbackCounter++; - body = body.replace(/\/\/ 51D replace this comment with callback function./g, 'callbackFunc(resolveFunc, rejectFunc);'); - func = new Function('callbackFunc', 'resolveFunc', 'rejectFunc', - "try {\n" + - body + "\n" + - "} catch (err) {\n" + - "console.log(err);" + - "}" - ); - func(completedCallback, resolve, reject); - executeCallback = false; - } else { - func = new Function( - "try {\n" + - body + "\n" + - "} catch (err) {\n" + - "console.log(err);" + - "}" - ); - func(); - } - if (sessionStorage) { - sessionStorage.setItem(sessionKey + "_property_" + name, true) - } - } - } else { - cached++; - } - } - } - } - if(cached === toProcess || started === 0) { - if (sessionStorage) { - var cachedResponse = sessionStorage.getItem(sessionKey); - if (cachedResponse) { - loadJSON(resolve, reject, cachedResponse); - executeCallback = false; - } - } - } - - if (started === 0) { - executeCallback = false; - completed = true; - } - if (executeCallback) { - callbackCounter = 1; - completedCallback(resolve, reject); - } - }; - -{{#_updateEnabled}} -{{^_supportsFetch}} - // Standard method to create a CORS HTTP request ready to send data. - var createCORSRequest = function(method, url) { - var xhr; - try { - xhr = new XMLHttpRequest(); - } catch(err){ - xhr = null; - } - if (xhr !== null && "withCredentials" in xhr) { - - // Check if the XMLHttpRequest object has a "withCredentials" - // property. - // "withCredentials" only exists on XMLHTTPRequest2 objects. - xhr.open(method, url, true); - } else if (typeof XDomainRequest != "undefined") { - - // Otherwise, check if XDomainRequest. - // XDomainRequest only exists in IE, and is IE's way of making CORS - // requests. - xhr = new XDomainRequest(); - xhr.open(method, url); - } else { - - // Otherwise, CORS is not supported by the browser. - xhr = null; - } - return xhr; - }; -{{/_supportsFetch}} -{{/_updateEnabled}} - - // Check if the JSON object still has any JavaScript snippets to run. - var hasJSFunctions = function() { - for (var i = i; i < json.javascriptProperties; i++) { - var body = getFromJson(json.javascriptProperties[i]); - if (body !== undefined && body.length > 0) { - return true; - } - } - return false; - } - - // Process the JavaScript properties. - var process = function(resolve, reject){ - processJsProperties(resolve, reject, json.javascriptProperties, false); - } - - var fireChangeFuncs = function(json) { - for (var i = 0; i < changeFuncs.length; i++) { - if (typeof changeFuncs[i] === 'function' && - changeFuncs[i].length === 1) { - changeFuncs[i](json); - } - } - } - -{{#_updateEnabled}} - // Process the response as json and call the resolve method. - var loadJSON = function(resolve, reject, responseText) { - json = JSON.parse(responseText); - - if (hasJSFunctions()) { - // json updated so fire 'on change' functions - // before executing any new JS properties that - // have come back. - fireChangeFuncs(json); - process(resolve, reject); - } else { - completed = true; - // json updated so fire 'on change' functions - // This must happen after completed = true in order - // for 'complete' functions to fire. - fireChangeFuncs(json); - resolve(json); - } - } - - // Sends a POST request to the call-back URL to retrieve and updated - // JSON payload from the cloud service. A POST request is used so that - // parameters can be passed in the request body, this is to get around - // the limitations on the length of query strings. As POST requests are not - // cached by browsers, the result of the POST request is stored in session - // storage on a successful response. This can then be checked before making - // repeat requests to the call-back URL. - // Any saved value parameters that have been set by the executed JavaScript - // properties are added to the list of parameters, this list is then - // serialized as Form Data and sent in the POST body to the call-back URL, - // refreshing the JSON data. The new JSON is then loaded if the request is - // returned with a success status code. If there was a problem then the - // session storage items are invalidated and reject is called. - var processRequest = function(resolve, reject){ - loadParameters(); - - // Get additional parameters in case they are not sent - // by the browser. - var savedValueParams = getParametersFromStorage(); - for(var savedValueIndex in savedValueParams) { - var parts = savedValueParams[savedValueIndex].split('='); - parameters[parts[0]] = parts[1]; - } - - saveParameters(); - - var params = []; - for (var param in parameters) { - if (parameters.hasOwnProperty(param)) { - params.push(param+"="+parameters[param]) - } - } - - // Add the session and sequence to the request - if (sessionId) { - params.push("session-id=" + sessionId); - } - if (sequence) { - params.push("sequence=" + sequence); - } - - var postBody = ""; - if (params.length > 0) { - postBody = params.join('&').replace(/%20/g, '+'); - } - -{{#_supportsFetch}} - fetch('{{{_url}}}', { - method: 'POST', - mode: 'cors', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: postBody - }) - .then(response => { - return response.text(); - }) - .then(responseText => { -{{/_supportsFetch}} -{{^_supportsFetch}} - // Request callback URL with additional parameters. - var xhr = createCORSRequest('POST', '{{{_url}}}'); - - // If there is no support for HTTP requests then call reject and throw - // a no CORS support error. - if (!xhr) { - reject(new Error('CORS not supported')); - return; - } - - // Add the HTTP header for POST form data. - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.setRequestHeader('Accept', 'application/json'); - - xhr.onload = function () { - - // Get the response body from the request. - var responseText = xhr.responseText; - -{{/_supportsFetch}} - // Cache the response text. - if (sessionStorage) { - sessionStorage.setItem(sessionKey, responseText); - } - // Load the JSON object from the response text - loadJSON(resolve, reject, responseText); - // Increment the sequence on a successful request - sequence++; -{{#_supportsFetch}} - }) - .catch(error => { - // Invalidate the cache on error. - clearCache(); - reject(error); - }); -{{/_supportsFetch}} -{{^_supportsFetch}} - }; - - xhr.onerror = function () { - // An error occurred with the request. Invalidate the cache and - // return the details in the call to reject method. - clearCache(); - reject(Error(xhr.statusText)); - }; - - // Send the GET request. - xhr.send(postBody); -{{/_supportsFetch}} - } -{{/_updateEnabled}} - - // Function logs errors, used to 'reject' a promise or for error callbacks. - var catchError = function(value) { - console.log(value.message || value); - } - - // Populate this instance of the FOD object with getters to access the - // properties. If the value is null then get the noValueMessage from the - // JSON object corresponding to the property. - var update = function(data){ - var self = this; - Object.getOwnPropertyNames(data).forEach(function(key) { - self[key] = {}; - for(var i in data[key]){ - var obj = self[key]; - (function(i) { - Object.defineProperty(obj, i, { - get: function (){ - if(data[key][i] === null && (i !== "javascriptProperties")){ - return data[key][i + "nullreason"]; - } else { - return data[key][i]; - } - } - }) - })(i); - } - }); - } - -{{#_hasDelayedProperties}} - // Get the JS property(s) that, when evaluated, will populate - // evidence that can be used to determine the value of the - // supplied property. - // The supplied name can either be a complete property name or a top level - // aspect name. - // Where the aspect name is given, ALL evidence properties under that - // key will be returned. - // Example property names are 'location.country' or 'devices.profiles.hardwarename' - // Example aspect names are 'location' or 'devices' - var getEvidenceProperties = function (name) { - var evidenceProperties = getFromJson(name + 'evidenceproperties'); - if(typeof evidenceProperties === "undefined") { - var item = getFromJson(name, true); - evidenceProperties = getEvidencePropertiesFromObject(item); - } - return evidenceProperties; - } - - // Get all values in any 'evidenceproperty' fields on this object - // or sub-objects. - var getEvidencePropertiesFromObject = function (dataObject) { - evidenceProperties = []; - - for (var prop in dataObject) { - if (dataObject.hasOwnProperty(prop)) { - var value = dataObject[prop]; - // Property name ends with 'evidenceproperties' so is - // what we're looking for. - // Add the values to the array if we don't already have it. - if (value !== null && Array.isArray(value) && endsWith(prop, 'evidenceproperties')) { - value.forEach(function(item, index) { - if(evidenceProperties.indexOf(item) === -1) { - evidenceProperties.push(item); - } - }); - } - // Item is an object so recursively call this method - // and add any resulting evidence properties to the list. - else if(typeof value === 'object' && value !== null) { - getEvidencePropertiesFromObject(value).forEach(function(item, index) { - if(evidenceProperties.indexOf(item) === -1) { - evidenceProperties.push(item); - } - }); - } - } - } - - return evidenceProperties; - } -{{/_hasDelayedProperties}} - -{{#_supportsPromises}} - this.promise = new Promise(function(resolve, reject) { - process(resolve,reject); - }); -{{/_supportsPromises}} - - this.onChange = function(resolve) { - changeFuncs.push(resolve); - } - - this.complete = function(resolve, properties) { -{{#_hasDelayedProperties}} - // If properties is set then check if we need to kick off - // processing of anything. - if(typeof properties !== "undefined") { - // If properties is a string then split on comma to produce - // an array of one or more key names. - if(typeof properties === "string") { - properties = properties.split(','); - } - if(Array.isArray(properties)) { - properties.forEach(function(key, i) { - // We pass an empty function rather than 'resolve' because we - // don't want to call resolve when a single evidence function - // evaluates but after all of them have completed. - // This is handled by the 'if(complete)' code below. - processJsProperties(function(json) {}, catchError, getEvidenceProperties(key), true); - }); - } - } - -{{/_hasDelayedProperties}} - if(completed){ - resolve(json); - }else{ - this.onChange(function(data) { - if(completed){ - resolve(data); - } - }) - } - }; - - // Update this instance with the initial JSON payload. - update.call(this, json); -{{#_supportsPromises}} - var parent = this; - this.promise.then(function(value) { - // JSON has been updated so replace the current instance. - update.call(parent, value); - completed = true; - }).catch(catchError); -{{/_supportsPromises}} -{{^_supportsPromises}} - process(function(json) {}, catchError); -{{/_supportsPromises}} -} - -var {{_objName}} = new fiftyoneDegreesManager(); \ No newline at end of file diff --git a/javascript-templates/README.md b/javascript-templates/README.md deleted file mode 100644 index 71f735a..0000000 --- a/javascript-templates/README.md +++ /dev/null @@ -1,8 +0,0 @@ -## Purpose - -Store shared (mustache) templates to be used by the implementation of `JavaScriptBuilderElement` and other components across the languages. - -## Background - -The [language-independent specification](https://github.com/51Degrees/specifications) describes how JavaScriptBuilderElement is used [here](https://github.com/51Degrees/specifications/blob/36ff732360acb49221dc81237281264dac4eb897/pipeline-specification/pipeline-elements/javascript-builder.md). The mechanics is: javascript file is requested from the server (on-premise web integration) and is created from this mustache template by the JavaScriptBuilderElement. It then collects more evidence, sends it to the server and upon response calls a callback function providing the client with more precise device data. - diff --git a/phpunit.xml b/phpunit.xml index 47eb87b..08da1c1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,6 +4,7 @@ tests/CoreTests.php tests/ExampleTests.php tests/JavaScriptBundlerTests.php + tests/EnableCookiesTests.php tests/FlowDataTests.php tests/SetHeaderTests.php diff --git a/src/JavascriptBuilderElement.php b/src/JavascriptBuilderElement.php index a15b9b4..4d93f8d 100644 --- a/src/JavascriptBuilderElement.php +++ b/src/JavascriptBuilderElement.php @@ -168,6 +168,11 @@ public function processInternal(FlowData $flowData): void $vars['_sessionId'] = $flowData->evidence->get('query.session-id'); $vars['_sequence'] = $flowData->evidence->get('query.sequence'); + $enableCookies = $flowData->evidence->get('query.fod-js-enable-cookies'); + if ($enableCookies !== null) { + $vars['_enableCookies'] = strtolower($enableCookies) === 'true'; + } + $jsParams = []; foreach ($params as $param => $paramValue) { $paramKey = explode('.', $param)[1]; diff --git a/tests/EnableCookiesTests.php b/tests/EnableCookiesTests.php new file mode 100644 index 0000000..9db41cb --- /dev/null +++ b/tests/EnableCookiesTests.php @@ -0,0 +1,102 @@ + [ + 'type' => 'javascript' + ] + ]; + + public function processInternal($flowData): void + { + $contents = []; + + $contents['javascript'] = "document.cookie = 'some cookie value'"; + $contents['normal'] = true; + + $data = new ElementDataDictionary($this, $contents); + + $flowData->setElementData($data); + } +} + +class EnableCookiesTests extends TestCase +{ + public static function provider_testJavaScriptCookies() + { + return [ + [false, false, false], + [true, false, false], + [false, true, true], + [true, true, true] + ]; + } + + /** + * Test that the cookie settings are respected correctly. + * @dataProvider provider_testJavaScriptCookies + */ + #[DataProvider("provider_testJavaScriptCookies")] + public function testJavaScriptCookies($enableInConfig, $enableInEvidence, $expectCookie) + { + $jsElement = new JavascriptBuilderElement([ + 'enableCookies' => $enableInConfig + ]); + + $pipeline = (new PipelineBuilder()) + ->add(new CookieElement()) + ->add(new SequenceElement()) + ->add(new JsonBundlerElement()) + ->add($jsElement) + ->build(); + + $flowData = $pipeline->createFlowData(); + $flowData->evidence->set('query.fod-js-enable-cookies', $enableInEvidence ? 'true' : 'false'); + $flowData->process(); + + $js = $flowData->javascriptbuilder->javascript; + $matches = substr_count($js, 'document.cookie'); + if ($expectCookie === true) { + $this->assertSame(2, $matches); + } + else { + $this->assertSame(1, $matches); + } + } +} diff --git a/tests/SetHeaderTests.php b/tests/SetHeaderTests.php index 7818a53..9461d48 100644 --- a/tests/SetHeaderTests.php +++ b/tests/SetHeaderTests.php @@ -31,6 +31,7 @@ use fiftyone\pipeline\core\tests\classes\TestPipeline; use fiftyone\pipeline\core\Utils; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class SetHeaderTests extends TestCase { @@ -80,11 +81,10 @@ public static function provider_testGetResponseHeaderValue() /** * Test response header value to be set for UACH. - * + * * @dataProvider provider_testGetResponseHeaderValue - * @param mixed $device - * @param mixed $expectedValue */ + #[DataProvider("provider_testGetResponseHeaderValue")] public function testGetResponseHeaderValue($device, $expectedValue) { $setHeaderPropertiesDict = [ @@ -142,11 +142,9 @@ public static function provider_testGetResponseHeaderName_Valid() /** * Test get response header function for valid formats. - * * @dataProvider provider_testGetResponseHeaderName_Valid - * @param mixed $data - * @param mixed $expectedValue */ + #[DataProvider("provider_testGetResponseHeaderName_Valid")] public function testGetResponseHeaderNameValid($data, $expectedValue) { $setHeaderElement = new SetHeaderElement(); @@ -166,11 +164,9 @@ public static function provider_testGetResponseHeaderName_InValid() /** * Test get response header function for valid formats. - * * @dataProvider provider_testGetResponseHeaderName_InValid - * @param mixed $data - * @param mixed $expectedValue */ + #[DataProvider("provider_testGetResponseHeaderName_InValid")] public function testGetResponseHeaderNameInValid($data, $expectedValue) { $setHeaderElement = new SetHeaderElement();