diff --git a/build.gradle b/build.gradle index ebe5c5b76..691470875 100644 --- a/build.gradle +++ b/build.gradle @@ -154,7 +154,7 @@ dependencies { if (!Boolean.valueOf(inplace)) { implementation "org.grails.plugins:ala-map-plugin:3.0.1" - implementation "org.grails.plugins:ecodata-client-plugin:6.1.3" + implementation "org.grails.plugins:ecodata-client-plugin:6.2" } testCompileOnly "org.grails:grails-test-mixins:3.3.0" diff --git a/gradle.properties b/gradle.properties index b16400354..f64e31ca3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -biocollectVersion=6.8-REFASSESS-SNAPSHOT +biocollectVersion=6.8-LOGIN-SNAPSHOT grailsVersion=5.1.9 grailsGradlePluginVersion=5.1.5 assetPipelineVersion=3.3.4 diff --git a/grails-app/assets/javascripts/document.js b/grails-app/assets/javascripts/document.js index c861bf545..8c6bb2659 100644 --- a/grails-app/assets/javascripts/document.js +++ b/grails-app/assets/javascripts/document.js @@ -283,6 +283,7 @@ function attachViewModelToFileUpload(uploadUrl, documentViewModel, uiSelector, p $(uiSelector).fileupload({ url:uploadUrl, pasteZone: null, + dropZone: null, formData:function(form) { return [{name:'document', value:documentViewModel.toJSONString()}] }, diff --git a/grails-app/assets/javascripts/i18n.js b/grails-app/assets/javascripts/i18n.js new file mode 100644 index 000000000..d4fad5d2f --- /dev/null +++ b/grails-app/assets/javascripts/i18n.js @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2019 Atlas of Living Australia + * All Rights Reserved. + * + * The contents of this file are subject to the Mozilla Public + * License Version 1.1 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of + * the License at http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS + * IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + * implied. See the License for the specific language governing + * rights and limitations under the License. + * + * Created by Temi on 15/11/19. + */ + +(function() { + var messages = {}, + deffer = $.Deferred(); + $.get({ + url: fcConfig.i18nURL, + cache: true + }).done(function (data) { + messages = data; + deffer.resolve(); + }).fail(function () { + deffer.reject(); + }); + + $i18n = function(key, defaultValue) { + if (messages[key] !== undefined) { + return messages[key]; + } else { + return defaultValue || key; + } + }; + + $i18nAsync = function(key, defaultValue, callback) { + if (callback) { + deffer.done(function () { + callback($i18n(key, defaultValue)); + }).fail(function () { + callback($i18n(key, defaultValue)); + }) + } + } + +})(); diff --git a/grails-app/assets/javascripts/knockout-custom-bindings.js b/grails-app/assets/javascripts/knockout-custom-bindings.js index 79cf95b83..05b91c8a7 100644 --- a/grails-app/assets/javascripts/knockout-custom-bindings.js +++ b/grails-app/assets/javascripts/knockout-custom-bindings.js @@ -281,6 +281,7 @@ ko.bindingHandlers.stagedImageUpload = { $(element).fileupload({ url: config.url, pasteZone: null, + dropZone: null, autoUpload: true }).on('fileuploadadd', function (e, data) { complete(false); @@ -703,11 +704,11 @@ ko.bindingHandlers.fileUploadNoImage = { var defaults = {autoUpload: true}; var settings = { - pasteZone: null + pasteZone: null, + dropZone: null }; $.extend(settings, defaults, options()); - $(element).fileupload(settings - ).on('fileuploadadd', function (e, data) { + $(element).fileupload(settings).on('fileuploadadd', function (e, data) { window.incrementAsyncCounter && window.incrementAsyncCounter(); }).on('fileuploaddone', function (e, data) { window.decreaseAsyncCounter && window.decreaseAsyncCounter(); @@ -1148,4 +1149,49 @@ ko.bindingHandlers.debug = { console.log(element); console.log(ko.toJS(valueAccessor())); } -}; \ No newline at end of file +}; + + +/** + * This binding requires i18n.js to be loaded. It also requires fcConfig.i18nURL to be set. + * Params can be a string or an object. If string, it is treated as key and translated to text. Object parameter has the + * following properties: + * @contentType can be 'text' or 'html' (default is 'text') + * @key is the key to be translated + * @defaultValue is the default value to be used if the key is not found + * + * Usage examples: + *
+ *
+ * + */ +ko.bindingHandlers.i18n = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + var value = valueAccessor(); + value = ko.unwrap(value); + var contentType = value && value.contentType || 'text' + + // $i18nAsync is required to be defined + if(typeof $i18nAsync === 'undefined') + return + + if( typeof value === 'string') { + $i18nAsync(value, '',function(text) { + $(element).text(text); + }); + } + else if (typeof value === 'object') { + $i18nAsync(value.key, value.defaultValue,function(text) { + switch (contentType) { + default: + case 'text': + $(element).text(text); + break; + case 'html': + $(element).html(text); + break; + } + }); + } + } +} \ No newline at end of file diff --git a/grails-app/assets/javascripts/outputs.js b/grails-app/assets/javascripts/outputs.js index 7c091cc87..7cefb6a54 100644 --- a/grails-app/assets/javascripts/outputs.js +++ b/grails-app/assets/javascripts/outputs.js @@ -152,6 +152,7 @@ ko.bindingHandlers.photoPointUpload = { $(element).fileupload({ url:config.url, pasteZone: null, + dropZone: null, autoUpload:true }).on('fileuploadadd', function(e, data) { complete(false); @@ -360,6 +361,7 @@ ko.bindingHandlers.fileUploadWithProgress = { $(element).fileupload({ url: config.url, pasteZone: null, + dropZone: null, autoUpload: true }).on('fileuploadadd', function (e, data) { complete(false); diff --git a/grails-app/conf/application.groovy b/grails-app/conf/application.groovy index 296b787ad..401ad2b13 100644 --- a/grails-app/conf/application.groovy +++ b/grails-app/conf/application.groovy @@ -27,7 +27,7 @@ environments { temp.dir = "/data/biocollect/temp" // system level config server.port = 8087 - serverURL = "http://devt.ala.org.au:8087" + serverURL = "http://localhost:8087" biocollect.system.email.replyTo = "biocollect-dev@ala.org.au" sender = "biocollect-dev@ala.org.au" debugUI = true @@ -36,11 +36,10 @@ environments { } test { - spring.autoconfigure.exclude="au.org.ala.ws.security.AlaWsSecurityConfiguration" debugUI = false loggerLevel = "DEBUG" server.port = "8087" - grails.host = "http://devt.ala.org.au" + grails.host = "http://localhost" serverName = "${grails.host}:${server.port}" grails.serverURL = serverName server.serverURL = serverName @@ -51,8 +50,8 @@ environments { grails.config.locations = [] security.oidc.discoveryUri = "http://localhost:${wiremock.port}/cas/oidc/.well-known" security.oidc.allowUnsignedIdTokens = true - def casBaseUrl = "http://devt.ala.org.au:${wiremock.port}" - + def casBaseUrl = "http://localhost:${wiremock.port}" + ehcache.directory = './ehcache' security.cas.appServerName=serverName security.cas.contextPath= security.cas.casServerName="${casBaseUrl}" @@ -61,17 +60,17 @@ environments { security.cas.loginUrl="${security.cas.casServerUrlPrefix}/login" security.cas.casLoginUrl="${security.cas.casServerUrlPrefix}/login" security.cas.logoutUrl="${security.cas.casServerUrlPrefix}/logout" + userDetails.api.url = "${casBaseUrl}/userdetails/userDetails/" security.jwt.discoveryUri="${casBaseUrl}/cas/oidc/.well-known" userDetails.url = "${casBaseUrl}/userdetails/userDetails/" - userDetailsSingleUrl = "${userDetails.Url}getUserDetails" - userDetailsUrl = "${userDetatails.url}getUserListFull" + userDetailsSingleUrl = "${userDetails.url}getUserDetails" logging.dir = '.' upload.images.path = '/tmp' upload.images.url = grails.serverURL+'/image/' - ecodata.baseUrl = 'http://devt.ala.org.au:8080/' - ecodata.baseURL = 'http://devt.ala.org.au:8080' - ecodata.service.url = 'http://devt.ala.org.au:8080/ws' - pdfgen.baseURL = "http://devt.ala.org.au:${wiremock.port}/" + ecodata.baseUrl = 'http://localhost:8080/' + ecodata.baseURL = 'http://localhost:8080' + ecodata.service.url = 'http://localhost:8080/ws' + pdfgen.baseURL = "http://localhost:${wiremock.port}/" api_key='testapikey' grails.cache.config = { diskStore { diff --git a/grails-app/controllers/au/org/ala/biocollect/merit/HomeController.groovy b/grails-app/controllers/au/org/ala/biocollect/merit/HomeController.groovy index 9062b447e..1bf80cd7a 100644 --- a/grails-app/controllers/au/org/ala/biocollect/merit/HomeController.groovy +++ b/grails-app/controllers/au/org/ala/biocollect/merit/HomeController.groovy @@ -14,6 +14,7 @@ class HomeController { def settingService def metadataService def userService + CommonService commonService @PreAuthorise(accessLevel = 'alaAdmin', redirectController = "admin") @SSO @@ -61,6 +62,13 @@ class HomeController { def works() { } + def i18n() { + if (request.isGet()) { + Map props = commonService.i18n(request.locale) + render props as JSON + } + } + /** * The purpose of this method is to enable the display of the spatial object corresponding to a selected * value from a geographic facet (e.g. to display the polygon representing NSW on the map if the user has diff --git a/grails-app/services/au/org/ala/biocollect/merit/ActivityService.groovy b/grails-app/services/au/org/ala/biocollect/merit/ActivityService.groovy index 7288aec23..ac29e5b1f 100644 --- a/grails-app/services/au/org/ala/biocollect/merit/ActivityService.groovy +++ b/grails-app/services/au/org/ala/biocollect/merit/ActivityService.groovy @@ -116,9 +116,9 @@ class ActivityService { webService.doDelete(grailsApplication.config.ecodata.service.url + '/activity/' + id) } - def bulkDelete(List ids, boolean destory) { + def bulkDelete(List ids, boolean destroy = false) { String url = grailsApplication.config.ecodata.service.url + '/activityBulkDelete' - if(destory) + if(destroy) url += '?destroy=true' webService.doPost(url, [ids: ids]) } diff --git a/grails-app/services/au/org/ala/biocollect/merit/CommonService.groovy b/grails-app/services/au/org/ala/biocollect/merit/CommonService.groovy index 286fac098..1ba6ddf75 100644 --- a/grails-app/services/au/org/ala/biocollect/merit/CommonService.groovy +++ b/grails-app/services/au/org/ala/biocollect/merit/CommonService.groovy @@ -3,6 +3,7 @@ package au.org.ala.biocollect.merit import grails.converters.JSON import grails.web.mapping.LinkGenerator import grails.web.servlet.mvc.GrailsParameterMap +import org.springframework.context.MessageSource import javax.servlet.http.HttpServletRequest import javax.xml.bind.DatatypeConverter @@ -13,6 +14,7 @@ class CommonService { UserService userService LinkGenerator grailsLinkGenerator + MessageSource messageSource List ignores = ["action","controller"] @@ -92,4 +94,7 @@ class CommonService { queryParams } + def i18n(Locale locale) { + messageSource.getMergedProperties(locale)?.properties + } } diff --git a/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy b/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy index de7103f33..1612eb9c9 100644 --- a/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy +++ b/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy @@ -100,9 +100,10 @@ class SettingService { } def loadHubConfig(hub) { - + def defaultHub = grailsApplication.config.getProperty('app.default.hub', String, 'default') if (!hub) { - hub = grailsApplication.config.app.default.hub?:'default' + hub = cookieService.getCookie(LAST_ACCESSED_HUB) + hub = hub ?: defaultHub } else { // Hub value in multiple places like url path and in parameter causes Array to be passed instead of String. @@ -131,7 +132,10 @@ class SettingService { ) } - cookieService.setCookie(LAST_ACCESSED_HUB, settings?.urlPath, -1 /* -1 means the cookie expires when the browser is closed */) + // Do not set cookie value to default hub since it overwrites genuine hub selection when calls are made with default hub. + // This usually happens when calls are made without hub parameter like downloading images. + if (settings?.urlPath != defaultHub) + cookieService.setCookie(LAST_ACCESSED_HUB, settings?.urlPath, -1 /* -1 means the cookie expires when the browser is closed */, '/') GrailsWebRequest.lookup().params.hub = settings?.urlPath SettingService.setHubConfig(settings) } diff --git a/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy b/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy index d60a4f04f..3d8abeaf7 100644 --- a/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy +++ b/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy @@ -2,6 +2,7 @@ package au.org.ala.biocollect import au.org.ala.biocollect.merit.SettingService import au.org.ala.biocollect.merit.UserService +import org.grails.web.servlet.mvc.GrailsWebRequest import org.springframework.context.MessageSource class TemplateTagLib { @@ -134,25 +135,39 @@ class TemplateTagLib { } break; case 'login': + // HubAwareLinkGenerator adds hub parameter to the link which causes subsequent URL parsing to return null in Pac4J filter. + // This causes the app to redirect to root page. And, BioCollect root page is redirected to Wordpress. + // Taking the user outside the application. This is a workaround to fix the issue. First, remove the + // hub parameter before link is generated and set it afterwards. + def hub = clearHubParameter() + def logoutReturnToUrl = getCurrentURL( attrs.hubConfig ) + if (grailsApplication.config.getProperty("security.oidc.logoutAction",String, "CAS") == "cognito") { + // cannot use createLink since it adds hub query parameter and cognito will not consider it valid + logoutReturnToUrl = grailsApplication.config.getProperty("grails.serverURL") + grailsApplication.config.getProperty("logoutReturnToUrl",String, "/hub/index") + } + if (bs4) { out << "
  • "; out << auth.loginLogout( ignoreCookie: "true", cssClass: "btn btn-primary btn-sm nav-button custom-header-login-logout", - logoutUrl: "${createLink(controller: 'logout', action: 'logout')}", +// cannot use createLink since it adds hub query parameter and eventually creates malformed URL with two ? characters + logoutUrl: "/logout", loginReturnToUrl: getCurrentURL( attrs.hubConfig ), - logoutReturnToUrl: getCurrentURL( attrs.hubConfig ) + logoutReturnToUrl: logoutReturnToUrl ) out << "
  • "; } else { out << "
  • "; out << auth.loginLogout( ignoreCookie: "true", - logoutUrl: "${createLink(controller: 'logout', action: 'logout')}", +// cannot use createLink since it adds hub query parameter and eventually creates malformed URL with two ? characters + logoutUrl: "/logout", loginReturnToUrl: getCurrentURL( attrs.hubConfig ), - logoutReturnToUrl: getCurrentURL( attrs.hubConfig ) + logoutReturnToUrl: logoutReturnToUrl ) out << "
  • "; } + setHubParameter(hub) break; case 'newproject': if (bs4) { @@ -319,6 +334,21 @@ class TemplateTagLib { } } + String getCurrentURLFromRequest() { + String requestURL = request.getRequestURL().toString() + // Construct the complete URL + StringBuilder url = new StringBuilder() + url.append(requestURL) + + String queryString = request.getQueryString() + // Include the query string if present + if (queryString != null) { + url.append("?").append(queryString) + } + + url.toString() + } + private String getLinkUrl (Map link){ String url; @@ -402,6 +432,23 @@ class TemplateTagLib { } private String getCurrentURL(Map hubConfig){ - g.createLink(absolute: true, uri: '/').toString() + getCurrentURLFromRequest() + } + + private String clearHubParameter(){ + def request = GrailsWebRequest.lookup() + def hub = request?.params?.hub + if(hub){ + request.params.remove('hub') + } + + hub + } + + private void setHubParameter(String hub) { + def request = GrailsWebRequest.lookup() + if (hub && request.params) { + request.params.hub = hub + } } } diff --git a/package-lock.json b/package-lock.json index b15fcf222..3e47a17b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "6.0", "devDependencies": { "@metahub/karma-jasmine-jquery": "^2.0.1", - "chromedriver": "^123.0.1", + "chromedriver": "^123.0.3", "jasmine-core": "^3.5.0", "jasmine-jquery": "^2.0.0", "jquery": "^3.4.1", @@ -180,13 +180,20 @@ } }, "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "dependencies": { - "ms": "^2.1.1" + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/@babel/traverse/node_modules/ms": { @@ -682,9 +689,9 @@ } }, "node_modules/chromedriver": { - "version": "123.0.1", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-123.0.1.tgz", - "integrity": "sha512-YQUIP/zdlzDIRCZNCv6rEVDSY4RAxo/tDL0OiGPPuai+z8unRNqJr/9V6XTBypVFyDheXNalKt9QxEqdMPuLAQ==", + "version": "123.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-123.0.3.tgz", + "integrity": "sha512-35IeTqDLcVR0htF9nD/Lh+g24EG088WHVKXBXiFyWq+2lelnoM0B3tKTBiUEjLng0GnELI4QyQPFK7i97Fz1fQ==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -1945,9 +1952,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -2005,13 +2012,20 @@ } }, "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "dependencies": { - "ms": "^2.1.1" + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/istanbul-lib-source-maps/node_modules/ms": { @@ -3059,9 +3073,9 @@ "dev": true }, "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "bin": { "semver": "bin/semver" @@ -3189,9 +3203,9 @@ "dev": true }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -3885,12 +3899,12 @@ }, "dependencies": { "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "requires": { - "ms": "^2.1.1" + "ms": "2.1.2" } }, "ms": { @@ -4285,9 +4299,9 @@ } }, "chromedriver": { - "version": "123.0.1", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-123.0.1.tgz", - "integrity": "sha512-YQUIP/zdlzDIRCZNCv6rEVDSY4RAxo/tDL0OiGPPuai+z8unRNqJr/9V6XTBypVFyDheXNalKt9QxEqdMPuLAQ==", + "version": "123.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-123.0.3.tgz", + "integrity": "sha512-35IeTqDLcVR0htF9nD/Lh+g24EG088WHVKXBXiFyWq+2lelnoM0B3tKTBiUEjLng0GnELI4QyQPFK7i97Fz1fQ==", "dev": true, "requires": { "@testim/chrome-version": "^1.1.4", @@ -5274,9 +5288,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -5323,12 +5337,12 @@ }, "dependencies": { "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "requires": { - "ms": "^2.1.1" + "ms": "2.1.2" } }, "ms": { @@ -6127,9 +6141,9 @@ "dev": true }, "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true }, "setprototypeof": { @@ -6229,9 +6243,9 @@ } }, "socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "requires": { "ip-address": "^9.0.5", diff --git a/package.json b/package.json index eaa797d6b..1b726a969 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@metahub/karma-jasmine-jquery": "^2.0.1", - "chromedriver": "^123.0.1", + "chromedriver": "^123.0.3", "jasmine-core": "^3.5.0", "jasmine-jquery": "^2.0.0", "jquery": "^3.4.1", diff --git a/src/integration-test/groovy/au/org/ala/biocollect/AddBioActivitySpec.groovy b/src/integration-test/groovy/au/org/ala/biocollect/AddBioActivitySpec.groovy index 7c7614ba6..0d480e23c 100644 --- a/src/integration-test/groovy/au/org/ala/biocollect/AddBioActivitySpec.groovy +++ b/src/integration-test/groovy/au/org/ala/biocollect/AddBioActivitySpec.groovy @@ -12,7 +12,7 @@ class AddBioActivitySpec extends StubbedCasSpec { } def cleanupSpec() { - logout(browser) + logout(browser, ViewBioActivityPage) } def projectId = "project_1" diff --git a/src/integration-test/groovy/au/org/ala/biocollect/BiocollectFunctionalTest.groovy b/src/integration-test/groovy/au/org/ala/biocollect/BiocollectFunctionalTest.groovy index ee6ba23cf..91035f434 100644 --- a/src/integration-test/groovy/au/org/ala/biocollect/BiocollectFunctionalTest.groovy +++ b/src/integration-test/groovy/au/org/ala/biocollect/BiocollectFunctionalTest.groovy @@ -134,7 +134,7 @@ class BiocollectFunctionalTest extends GebReportingSpec { def logoutViaUrl(browser) { String serverUrl = (testConfig.baseUrl instanceof String) ? testConfig.baseUrl : testConfig.grails.serverURL - String logoutUrl = "${serverUrl}/logout/logout?appUrl=${serverUrl}" + String logoutUrl = "${serverUrl}/logout?appUrl=${serverUrl}" browser.go logoutUrl } diff --git a/src/integration-test/groovy/au/org/ala/biocollect/StubbedCasSpec.groovy b/src/integration-test/groovy/au/org/ala/biocollect/StubbedCasSpec.groovy index d5eee7679..caca97341 100644 --- a/src/integration-test/groovy/au/org/ala/biocollect/StubbedCasSpec.groovy +++ b/src/integration-test/groovy/au/org/ala/biocollect/StubbedCasSpec.groovy @@ -2,21 +2,26 @@ package au.org.ala.biocollect import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer +import com.nimbusds.jose.jwk.RSAKey import geb.Browser import grails.converters.JSON import org.grails.web.converters.marshaller.json.CollectionMarshaller import org.grails.web.converters.marshaller.json.MapMarshaller import org.openqa.selenium.StaleElementReferenceException +import org.openqa.selenium.WebDriverException +import org.pac4j.jwt.config.signature.RSASignatureConfiguration import org.pac4j.jwt.profile.JwtGenerator -import spock.lang.Shared import wiremock.com.github.jknack.handlebars.EscapingStrategy import wiremock.com.github.jknack.handlebars.Handlebars import wiremock.com.github.jknack.handlebars.Helper import wiremock.com.github.jknack.handlebars.Options import wiremock.com.google.common.collect.ImmutableMap +import java.security.KeyPair + import static com.github.tomakehurst.wiremock.client.WireMock.* import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options + //import com.github.tomakehurst.wiremock.WireMockServer /** @@ -28,33 +33,64 @@ class StubbedCasSpec extends BiocollectFunctionalTest { static String GRANT_MANAGER_USER_ID = '1001' static String MERIT_ADMIN_USER_ID = '1002' static String ALA_ADMIN_USER_ID = '2000' + static KeyPair pair + static RSAKey signingKey + static { + // KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA") + // generator.initialize(2048) + // pair = generator.generateKeyPair() + // + // signingKey = new RSAKey.Builder(pair.getPublic()).privateKey(pair.getPrivate()).build() + // RSAKey publicKey = new RSAKey.Builder(pair.getPublic()).build() + // println signingKey.toJSONObject() - this is hardcoded below and is used to sign tokens + // println publicKey.toJSONObject() - this goes into wiremock as the response to the jwks request + + // It would be better to generate this at runtime, but the stub needs to be in wirework during bean initialisation of MERIT + // So we've generated a random key as above and coded it here and into the wiremock stub + Map key = ["p":"ybsLU3Xf05Nkmfthec3Gf5SCR1cMG4gHYTJh9dP575RavQO63oxS9G-srVmNTmCYsoi-KJs9RODO8Ive701DrpSBkeM7cYZ5_7J3Zt7kTtIPCavwJsb0yhyJQGcm6v7iuF8GIRukeKLT57LwjhSPPqZgdOu9elsc3T7-D9jPPBE", + "kty":"RSA", + "q":"rA9mDEolsAG9br7CJlgPIaNsAdpPmqwGTnaHZgJCeN3XwKLlTkLiBlH66OdvakvoFHuvoiaUXFx2xd0G-WrBdePnqZcAb0SdLMp0RxDCRyC4HwJSE9YwieOpIO8EvgzYZKL7liUKR0fz3HAciV9oW3lA6bnh1doSVadw18LMKZk", + "d":"GDawqqYZsTqZpR3WRyK1YoSI0qO4RS61jvW-l72SbpILYHl9M0cZewef95OrT9yl4-7SurBV15j7wkqLvLxmQNxpIs-yXRK5hpmvxa07SiXZOtwW7EvG_PMx9tyQ8LiyM8MnTr-qknh_Rtbjr9bH0mrMeUCQfVj5VSSF8SNHEVPGy_QxQoswIhRgzcq9tOaGP1W-FoVCaQjAxJG4boAselX_bvYe3IVuDwE_ZV8KPCQcZISEqrY8B8-b8AdTgecORODQB5lChElqRBeKUeoOtVMvD8rlwUgjV9ir_QK25Vq8rLcEkdPrRhV0EKKnr1eBir1WSpdLjzIUhAsF5HPUAQ", + "e":"AQAB", "qi":"kuYhMgrJR1fTuX0IJar-SjWhrn3EV96bfaObAkxMZDHes-tthLxcZ58PpKKbsMSK8kncT41JMb0Irb9HC12B6aSDy3Kzsns-gzcD38m9YcdOk80kyjCqLIceU2tmMIeIxSF54wGwVO_p4p94xeANf4si450ssqdPM_q1n1SRu88", + "dp":"Gx8Nj8P6OqzHSrh0S3bx5_ckaMj4NL9eFqA6cV11bdNpO55DwmXlRT26Xnf6un3cKayevEDaxObgi5CSgWPG5LLMlLuTI1ksD8eDrA3tbfdp1CgMmnoHMSETBtiXb-Kiwpzr6wmXXCywBqeVFdUHySl_MFj9WXTkdY5hg-nnOrE", + "dq":"DwhxZBV-YXhlcq2cDPmYqNm8cBUA64SoMGbOwazk3eaUGTKiUkopsV-sSnkeFO1444FDASnZwJAbmIINP_GB4aj97qVQ1mfqS6WMr0DZmJlVPPBY9365UvLfLg90HJ7GsVREIwQtd7jjp5jsBVyeo49eio1BHAwnmfA9Pby5VdE", + "n":"h5XN-_LL1yXb8oPWHOTNMby0Y6olpByVCNJGo1mjhk9PUoX8bfu6wNr4G7oR7O0NfIQVNLykqE7Q04RrP7JfexI97UuH5B0xBjHVo-S5SxeyUrVSQBpRu6EisQzxxF3a038a0GHYJpA5YUAZWD7Pux0yqJ5ly1y2Sn7uGb_JJ_bJ86EVWs3AxE1RZHmeY975A1kk470ylDAyfQuW_GU-gUzG5vdE1wAEIKe6GFtg5ulA_n_XVsrz9qio7ZtEyWZDAOCtk0jfg8iTJf5eLP2Q3D8ePy_6IvYvFDuQLmvKHn1jg5MnnDWZHV3GBRnfU8CtPu2ChFKhXedcrhQhhAWfKQ" + ] + + signingKey = RSAKey.parse(key) + pair = signingKey.toKeyPair() - @Shared WireMockServer wireMockServer + } + + // @Shared WireMockServer wireMockServer def setupSpec() { + JSON.registerObjectMarshaller(new MapMarshaller()) + JSON.registerObjectMarshaller(new CollectionMarshaller()) if (testConfig.wiremock.embedded){ + startWireMock() + } + + // Configure the client + configureFor("localhost", testConfig.wiremock.port) + } - Handlebars handlebars = new Handlebars() - handlebars.escapingStrategy = EscapingStrategy.NOOP + private void startWireMock() { + Handlebars handlebars = new Handlebars() + handlebars.escapingStrategy = EscapingStrategy.NOOP - // This is done so we can use a custom handlebars with a NOOP escaping strategy - the default escapes HTML - // which breaks the redirect URL returned by the PDF generation stub. - Helper noop = new Helper() { - Object apply(Object context, Options options) throws IOException { - return context[0] - } + // This is done so we can use a custom handlebars with a NOOP escaping strategy - the default escapes HTML + // which breaks the redirect URL returned by the PDF generation stub. + Helper noop = new Helper() { + Object apply(Object context, Options options) throws IOException { + return context[0] } - wireMockServer = new WireMockServer(options() - .port(testConfig.wiremock.port) - .usingFilesUnderDirectory(getMappingsPath()) - .extensions(new ResponseTemplateTransformer(false, handlebars, ImmutableMap.of("noop", noop), null, null))) - - wireMockServer.start() } + wireMockServer = new WireMockServer(options() + .port(testConfig.wiremock.port) + .usingFilesUnderDirectory(getMappingsPath()) + .extensions(new ResponseTemplateTransformer(false, handlebars, ImmutableMap.of("noop", noop), null, null))) - JSON.registerObjectMarshaller(new MapMarshaller()) - JSON.registerObjectMarshaller(new CollectionMarshaller()) - // Configure the client - configureFor("localhost", testConfig.wiremock.port) + wireMockServer.start() } def cleanupSpec() { @@ -113,6 +149,12 @@ class StubbedCasSpec extends BiocollectFunctionalTest { } catch (StaleElementReferenceException e) { // Do nothing, backdrop was already detached } + catch (NullPointerException e) { + // Do nothing, backdrop was already detached and destroyed before we selected it + } + catch (WebDriverException e) { + // We are now seeing WebDriverException instead of StateElementReferenceException in some cases + } } private String getMappingsPath() { @@ -133,7 +175,7 @@ class StubbedCasSpec extends BiocollectFunctionalTest { } /** Convenience method to stub the login of a user with the CAS ROLE_ALA_ADMIN role */ def loginAsAlaAdmin(Browser browser) { - login([userId:ALA_ADMIN_USER_ID, role:"ROLE_ADMIN", email: 'ala_admin@nowhere.com', firstName:"ALA", lastName:"Administrator"], browser) + login([userId:ALA_ADMIN_USER_ID, role:"ROLE_ADMIN", userName: 'ala_admin@nowhere.com', email: 'ala_admin@nowhere.com', firstName:"ALA", lastName:"Administrator"], browser) } /** Convenience method to stub the login of a user no special roles */ def loginAsUser(String userId, Browser browser) { @@ -143,6 +185,11 @@ class StubbedCasSpec extends BiocollectFunctionalTest { login([userId:userId, email: "user${userId}@nowhere.com", firstName:"MERIT", lastName:"User ${userId}"], browser) } + String tokenForUser(String userId) { + Map userDetails = [userId:userId, email: "user${userId}@nowhere.com", firstName:"MERIT", lastName:"User ${userId}"] + setupOidcAuthForUser(userDetails) + } + private String loggedInUser = null def login(Map userDetails, Browser browser) { @@ -154,7 +201,17 @@ class StubbedCasSpec extends BiocollectFunctionalTest { } def oidcLogin(Map userDetails, Browser browser) { + setupOidcAuthForUser(userDetails) + browser.go "${browser.getConfig().baseUrl}login" + } + /** + * Sets up stubs with wiremock to authenticate a user via OIDC. Also returns an idToken which can be used + * if an interactive login is not required. + * @param userDetails the details of the user to setup + * @return an idToken for the user. + */ + String setupOidcAuthForUser(Map userDetails) { // The test config isn't a normal grails config object (probably need to to into why) so getProperty doesn't work. String clientId = getTestConfig().security.oidc.clientId List roles = ["ROLE_USER"] @@ -182,16 +239,17 @@ class StubbedCasSpec extends BiocollectFunctionalTest { exp:com.nimbusds.jwt.util.DateUtils.toSecondsSinceEpoch(new Date().plus(365)), iat:com.nimbusds.jwt.util.DateUtils.toSecondsSinceEpoch(new Date()), jti:"id", - email:userDetails.email + email:userDetails.email, + scope : "openid profile ala roles email" ] - String idToken = new JwtGenerator(null).generate(idTokenClaims) + String idToken = new JwtGenerator(new RSASignatureConfiguration(pair)).generate(idTokenClaims) Map token = [:] token.access_token = idToken token.id_token = idToken token.refresh_token = null token.token_type = "bearer" token.expires_in = 86400 - token.scope = "user_defined email openid profile roles" + token.scope = "openid profile ala roles email" stubFor(post(urlPathEqualTo("/cas/oidc/oidcAccessToken")) .willReturn(aResponse() @@ -203,11 +261,12 @@ class StubbedCasSpec extends BiocollectFunctionalTest { Map profile = [ - sub:userDetails.userId, - name:userDetails.firstName+" "+userDetails.lastName, - given_name:userDetails.firstName, - family_name:userDetails.lastName, - email:userDetails.email + userid : userDetails.userId, + sub : userDetails.userId, + name : userDetails.firstName + " " + userDetails.lastName, + given_name : userDetails.firstName, + family_name: userDetails.lastName, + email : userDetails.email ] stubFor(get(urlPathEqualTo("/cas/oidc/oidcProfile")) @@ -216,8 +275,7 @@ class StubbedCasSpec extends BiocollectFunctionalTest { .withHeader("Content-Type", "application/json") .withBody((profile as JSON).toString()) )) - - browser.go "${getConfig().baseUrl}login" + idToken } /** Creates a wiremock configuration to stub a user login request and return the supplied user and role information */ @@ -272,14 +330,14 @@ class StubbedCasSpec extends BiocollectFunctionalTest { .willReturn(aResponse() .withStatus(302) - .withHeader("Location", "{{{request.requestLine.query.service}}}?ticket=aticket") + .withHeader("Location", "{{request.requestLine.query.service}}?ticket=aticket") .withHeader("Set-Cookie", "ALA-Auth=\"${email}\"; Domain=ala.org.au; Path=/; HttpOnly") .withTransformers("response-template"))) stubFor(get(urlMatching("/cas/login\\?service=.*\\?.*")) .willReturn(aResponse() .withStatus(302) - .withHeader("location", "{{{request.requestLine.query.service}}}&ticket=aticket") + .withHeader("location", "{{request.requestLine.query.service}}&ticket=aticket") .withHeader("Set-Cookie", "ALA-Auth=\"${email}\"; Domain=ala.org.au; Path=/; HttpOnly") .withTransformers("response-template"))) diff --git a/src/integration-test/resources/GebConfig.groovy b/src/integration-test/resources/GebConfig.groovy index f17c4eff0..11d470fe7 100644 --- a/src/integration-test/resources/GebConfig.groovy +++ b/src/integration-test/resources/GebConfig.groovy @@ -14,7 +14,7 @@ if (!System.getProperty("webdriver.chrome.driver")) { System.setProperty("webdriver.chrome.driver", "node_modules/chromedriver/bin/chromedriver") } driver = { new ChromeDriver() } -baseUrl = 'http://devt.ala.org.au:8087/' +baseUrl = 'http://localhost:8087/' atCheckWaiting = true waiting { timeout = 20 diff --git a/src/integration-test/resources/wiremock/mappings/jwkSource.json b/src/integration-test/resources/wiremock/mappings/jwkSource.json new file mode 100644 index 000000000..77c820a5a --- /dev/null +++ b/src/integration-test/resources/wiremock/mappings/jwkSource.json @@ -0,0 +1,25 @@ +{ + "request": { + "urlPath": "/cas/oidc/jwks", + "method": "GET" + }, + "response" : { + "status": 200, + "headers": { + "content-Type": "application/json" + }, + "jsonBody": { + "keys": [ + { + "kty":"RSA", + "e":"AQAB", + "use":"sig", + "alg": "RS256", + "kid":"localhost:8018", + "n": "h5XN-_LL1yXb8oPWHOTNMby0Y6olpByVCNJGo1mjhk9PUoX8bfu6wNr4G7oR7O0NfIQVNLykqE7Q04RrP7JfexI97UuH5B0xBjHVo-S5SxeyUrVSQBpRu6EisQzxxF3a038a0GHYJpA5YUAZWD7Pux0yqJ5ly1y2Sn7uGb_JJ_bJ86EVWs3AxE1RZHmeY975A1kk470ylDAyfQuW_GU-gUzG5vdE1wAEIKe6GFtg5ulA_n_XVsrz9qio7ZtEyWZDAOCtk0jfg8iTJf5eLP2Q3D8ePy_6IvYvFDuQLmvKHn1jg5MnnDWZHV3GBRnfU8CtPu2ChFKhXedcrhQhhAWfKQ" + } + ] + } + + } +} \ No newline at end of file diff --git a/src/integration-test/resources/wiremock/mappings/oidcDiscovery.json b/src/integration-test/resources/wiremock/mappings/oidcDiscovery.json index b5f27fb99..5b27ec350 100644 --- a/src/integration-test/resources/wiremock/mappings/oidcDiscovery.json +++ b/src/integration-test/resources/wiremock/mappings/oidcDiscovery.json @@ -64,7 +64,7 @@ "refresh_token" ], "id_token_signing_alg_values_supported": [ - "none" + "RS256", "none" ], "id_token_encryption_alg_values_supported": [ "none" @@ -73,7 +73,7 @@ "none" ], "userinfo_signing_alg_values_supported": [ - "none" + "RS256", "none" ], "userinfo_encryption_alg_values_supported": [ "none" @@ -82,7 +82,7 @@ "none" ], "request_object_signing_alg_values_supported": [ - "none" + "RS256", "none" ], "request_object_encryption_alg_values_supported": [ "none" diff --git a/src/integration-test/resources/wiremock/mappings/oidcLogout.json b/src/integration-test/resources/wiremock/mappings/oidcLogout.json new file mode 100644 index 000000000..f5d98af7a --- /dev/null +++ b/src/integration-test/resources/wiremock/mappings/oidcLogout.json @@ -0,0 +1,15 @@ +{ + "request": { + "urlPath": "/cas/oidc/oidcLogout", + "method": "GET" + }, + "response": { + "status": 302, + "headers": { + "location": "{{{request.requestLine.query.post_logout_redirect_uri}}}" + }, + "transformers": [ + "response-template" + ] + } +} \ No newline at end of file diff --git a/src/integration-test/resources/wiremock/mappings/userdetailsFromIDslookup.json b/src/integration-test/resources/wiremock/mappings/userdetailsFromIDslookup.json index bc8773ca1..63ab4c129 100644 --- a/src/integration-test/resources/wiremock/mappings/userdetailsFromIDslookup.json +++ b/src/integration-test/resources/wiremock/mappings/userdetailsFromIDslookup.json @@ -14,21 +14,25 @@ "users": { "1": { "userName":"user1@user.com", + "email":"user1@user.com", "firstName":"test1", "lastName": "user1" }, "10": { "userName": "user10@user.com", + "email": "user10@user.com", "firstName":"test10", "lastName": "user10" }, "30": { "userName":"user30@user.com", + "email":"user30@user.com", "firstName":"test30", "lastName": "user30" }, "1000": { "userName":"user1000@user.com", + "email":"user1000@user.com", "firstName":"test1000", "lastName": "user1000" }, @@ -39,11 +43,13 @@ }, "1002": { "userName":"user1002@user.com", + "email":"user1002@user.com", "firstName":"test1002", "lastName": "user1002" }, "2": { "userName":"user2@user.com", + "email":"user2@user.com", "firstName":"test2", "lastName": "user2" } diff --git a/src/integration-test/resources/wiremock/mappings/userdetailslookup.json b/src/integration-test/resources/wiremock/mappings/userdetailslookup.json index 71a5cda45..d0bc9790e 100644 --- a/src/integration-test/resources/wiremock/mappings/userdetailslookup.json +++ b/src/integration-test/resources/wiremock/mappings/userdetailslookup.json @@ -9,6 +9,12 @@ "headers": { "Content-Type": "application/json" }, - "body": "{\"userName\":\"user.{{request.query.userName}}@nowhere.com.au\", \"userId\":\"{{request.query.userName}}\"}" + "jsonBody": { + "userName":"user.{{request.query.userName}}@nowhere.com.au", + "email":"user.{{request.query.userName}}@nowhere.com.au", + "userId":"{{request.query.userName}}", + "firstName": "First {{request.query.userName}}", + "lastName": "Last {{request.query.userName}}" + } } }