diff --git a/.eslintrc.js b/.eslintrc.js index a63e3267..81bd7bd6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,8 @@ module.exports = { "extends": "google", + "parserOptions": { + "ecmaVersion": 6, + }, "globals": { "HtmlService": false, "Logger": false, @@ -8,6 +11,7 @@ module.exports = { }, "rules": { "comma-dangle": "off", - "no-var": "off" + "no-var": "off", + "generator-star-spacing": ["error", {"anonymous": "neither"}], } }; diff --git a/gulpfile.js b/gulpfile.js index 60fbf3b3..f152821e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,8 +3,6 @@ const concat = require('gulp-concat'); const expose = require('gulp-expose'); const del = require('del'); const rename = require("gulp-rename"); -const jshint = require('gulp-jshint'); -const stylish = require('jshint-stylish'); const eslint = require('gulp-eslint'); gulp.task('dist', ['clean'], function() { diff --git a/package-lock.json b/package-lock.json index cce9ab97..73ea283f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -468,32 +468,6 @@ } } }, - "cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=", - "dev": true, - "requires": { - "exit": "0.1.2", - "glob": "7.1.2" - }, - "dependencies": { - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" - } - } - } - }, "cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -661,15 +635,6 @@ } } }, - "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "0.1.4" - } - }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -720,12 +685,6 @@ "integrity": "sha1-+v1Ej3IRXvHitzkVWukvK+bCjdE=", "dev": true }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, "dateformat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", @@ -827,55 +786,6 @@ "esutils": "2.0.2" } }, - "dom-serializer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", - "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", - "dev": true, - "requires": { - "domelementtype": "1.1.3", - "entities": "1.1.1" - }, - "dependencies": { - "domelementtype": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", - "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", - "dev": true - }, - "entities": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", - "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", - "dev": true - } - } - }, - "domelementtype": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", - "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", - "dev": true - }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", - "dev": true, - "requires": { - "domelementtype": "1.3.0" - } - }, - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "dev": true, - "requires": { - "dom-serializer": "0.1.0", - "domelementtype": "1.3.0" - } - }, "dot-object": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-0.6.0.tgz", @@ -924,12 +834,6 @@ } } }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", - "dev": true - }, "error-ex": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", @@ -1160,12 +1064,6 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -1329,6 +1227,12 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fibers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fibers/-/fibers-2.0.0.tgz", + "integrity": "sha512-sLxo4rZVk7xLgAjb/6zEzHJfSALx6u6coN1z61XCOF7i6CyTdJawF4+RdpjCSeS8AP66eR2InScbYAz9RAVOgA==", + "dev": true + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -2123,59 +2027,6 @@ } } }, - "gulp-jshint": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/gulp-jshint/-/gulp-jshint-1.12.0.tgz", - "integrity": "sha1-I/vRuv3W+/5h6mRmenSAmpYdA94=", - "dev": true, - "requires": { - "gulp-util": "3.0.8", - "jshint": "2.9.5", - "lodash": "3.10.1", - "minimatch": "2.0.10", - "rcloader": "0.1.2", - "through2": "0.6.5" - }, - "dependencies": { - "lodash": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", - "dev": true - }, - "minimatch": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", - "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", - "dev": true, - "requires": { - "brace-expansion": "1.1.11" - } - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "dev": true, - "requires": { - "core-util-is": "1.0.2", - "inherits": "2.0.3", - "isarray": "0.0.1", - "string_decoder": "0.10.31" - } - }, - "through2": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", - "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=", - "dev": true, - "requires": { - "readable-stream": "1.0.34", - "xtend": "4.0.1" - } - } - } - }, "gulp-rename": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-1.2.2.tgz", @@ -2294,19 +2145,6 @@ "integrity": "sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg==", "dev": true }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", - "dev": true, - "requires": { - "domelementtype": "1.3.0", - "domhandler": "2.3.0", - "domutils": "1.5.1", - "entities": "1.0.0", - "readable-stream": "1.1.14" - } - }, "iconv-lite": { "version": "0.4.19", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", @@ -2442,12 +2280,6 @@ "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", "dev": true }, - "irregular-plurals": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-1.4.0.tgz", - "integrity": "sha1-LKmwM2UREYVUEvFr5dd8YqRYp2Y=", - "dev": true - }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -2719,44 +2551,6 @@ } } }, - "jshint": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.5.tgz", - "integrity": "sha1-HnJSkVzmgbQIJ+4UJIxG006apiw=", - "dev": true, - "requires": { - "cli": "1.0.1", - "console-browserify": "1.1.0", - "exit": "0.1.2", - "htmlparser2": "3.8.3", - "lodash": "3.7.0", - "minimatch": "3.0.4", - "shelljs": "0.3.0", - "strip-json-comments": "1.0.4" - }, - "dependencies": { - "lodash": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz", - "integrity": "sha1-Nni9irmVBXwHreg27S7wh9qBHUU=", - "dev": true - } - } - }, - "jshint-stylish": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/jshint-stylish/-/jshint-stylish-2.2.1.tgz", - "integrity": "sha1-JCCCosA1rgP9gQROBXDMQgjPbmE=", - "dev": true, - "requires": { - "beeper": "1.1.1", - "chalk": "1.1.3", - "log-symbols": "1.0.2", - "plur": "2.1.2", - "string-length": "1.0.1", - "text-table": "0.2.0" - } - }, "json-schema-traverse": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", @@ -2982,12 +2776,6 @@ "lodash._objecttypes": "2.4.1" } }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", - "dev": true - }, "lodash.defaults": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-2.4.1.tgz", @@ -3107,15 +2895,6 @@ } } }, - "log-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", - "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", - "dev": true, - "requires": { - "chalk": "1.1.3" - } - }, "loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", @@ -3752,15 +3531,6 @@ } } }, - "plur": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/plur/-/plur-2.1.2.tgz", - "integrity": "sha1-dIJFLBoPUI4+NE6uwxLJHCncZVo=", - "dev": true, - "requires": { - "irregular-plurals": "1.4.0" - } - }, "pluralize": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", @@ -3803,33 +3573,6 @@ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, - "rcfinder": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/rcfinder/-/rcfinder-0.1.9.tgz", - "integrity": "sha1-8+gPOH3fmugK4wpBADKWQuroERU=", - "dev": true, - "requires": { - "lodash.clonedeep": "4.5.0" - } - }, - "rcloader": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/rcloader/-/rcloader-0.1.2.tgz", - "integrity": "sha1-oJY6ZDfQnvjLktky0trUl7DRc2w=", - "dev": true, - "requires": { - "lodash": "2.4.2", - "rcfinder": "0.1.9" - }, - "dependencies": { - "lodash": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz", - "integrity": "sha1-+t2DS5aDBz2hebPq5tnA0VBT9z4=", - "dev": true - } - } - }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -4112,12 +3855,6 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, - "shelljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", - "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", - "dev": true - }, "sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", @@ -4432,15 +4169,6 @@ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true }, - "string-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-1.0.1.tgz", - "integrity": "sha1-VpcPscOFWOnnC3KL894mmsRa36w=", - "dev": true, - "requires": { - "strip-ansi": "3.0.1" - } - }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -4496,12 +4224,6 @@ "get-stdin": "4.0.1" } }, - "strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", - "dev": true - }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", diff --git a/package.json b/package.json index b6035d7d..38d6a3f9 100644 --- a/package.json +++ b/package.json @@ -15,20 +15,18 @@ "devDependencies": { "chai": "^4.1.2", "del": "^1.2.1", + "fibers": "^2.0.0", "eslint": "^4.17.0", "eslint-config-google": "^0.9.1", "gas-local": "^1.3.0", - "gulp": "^3.9.0", - "gulp-bump": "^0.3.1", - "gulp-clean": "^0.3.1", - "gulp-concat": "^2.6.0", + "gulp": "^3.9.1", + "gulp-clean": "^0.3.2", + "gulp-concat": "^2.6.1", "gulp-eslint": "^4.0.2", "gulp-expose": "0.0.7", - "gulp-jshint": "^1.12.0", "gulp-rename": "^1.2.2", "jsdoc": "^3.5.5", - "jshint-stylish": "^2.0.1", - "mocha": "^4.0.1" + "mocha": "^4.1.0" }, "scripts": { "postversion": "gulp dist", diff --git a/src/Service.gs b/src/Service.gs index 5f0fbd87..8fbcd213 100644 --- a/src/Service.gs +++ b/src/Service.gs @@ -45,6 +45,13 @@ var Service_ = function(serviceName) { */ Service_.EXPIRATION_BUFFER_SECONDS_ = 60; +/** + * The number of milliseconds that a token should remain in the cache. + * @type {number} + * @private + */ +Service_.LOCK_EXPIRATION_MILLISECONDS_ = 30 * 1000; + /** * Sets the service's authorization base URL (required). For Google services * this URL should be @@ -174,6 +181,7 @@ Service_.prototype.setClientSecret = function(clientSecret) { * @param {PropertiesService.Properties} propertyStore The property store to use * when persisting credentials. * @return {Service_} This service, for chaining. + * @see https://developers.google.com/apps-script/reference/properties/ */ Service_.prototype.setPropertyStore = function(propertyStore) { this.propertyStore_ = propertyStore; @@ -188,12 +196,27 @@ Service_.prototype.setPropertyStore = function(propertyStore) { * @param {CacheService.Cache} cache The cache to use when persisting * credentials. * @return {Service_} This service, for chaining. + * @see https://developers.google.com/apps-script/reference/cache/ */ Service_.prototype.setCache = function(cache) { this.cache_ = cache; return this; }; +/** + * Sets the lock to use when checking and refreshing credentials (optional). + * Using a lock will ensure that only one execution will be able to access the + * stored credentials at a time. This can prevent race conditions that arise + * when two executions attempt to refresh an expired token. + * @param {LockService.Lock} lock The lock to use when accessing credentials. + * @return {Service_} This service, for chaining. + * @see https://developers.google.com/apps-script/reference/lock/ + */ +Service_.prototype.setLock = function(lock) { + this.lock_ = lock; + return this; +}; + /** * Sets the scope or scopes to request during the authorization flow (optional). * If the scope value is an array it will be joined using the separator before @@ -354,27 +377,29 @@ Service_.prototype.handleCallback = function(callbackRequest) { * otherwise. */ Service_.prototype.hasAccess = function() { - var token = this.getToken(); - if (!token || this.isExpired_(token)) { - if (token && token.refresh_token) { - try { - this.refresh(); - } catch (e) { - this.lastError_ = e; + return this.lockable_(function() { + var token = this.getToken(); + if (!token || this.isExpired_(token)) { + if (token && token.refresh_token) { + try { + this.refresh(); + } catch (e) { + this.lastError_ = e; + return false; + } + } else if (this.privateKey_) { + try { + this.exchangeJwt_(); + } catch (e) { + this.lastError_ = e; + return false; + } + } else { return false; } - } else if (this.privateKey_) { - try { - this.exchangeJwt_(); - } catch (e) { - this.lastError_ = e; - return false; - } - } else { - return false; } - } - return true; + return true; + }); }; /** @@ -481,38 +506,41 @@ Service_.prototype.refresh = function() { 'Client Secret': this.clientSecret_, 'Token URL': this.tokenUrl_ }); - var token = this.getToken(); - if (!token.refresh_token) { - throw new Error('Offline access is required.'); - } - var headers = { - 'Accept': this.tokenFormat_ - }; - if (this.tokenHeaders_) { - headers = extend_(headers, this.tokenHeaders_); - } - var tokenPayload = { - refresh_token: token.refresh_token, - client_id: this.clientId_, - client_secret: this.clientSecret_, - grant_type: 'refresh_token' - }; - if (this.tokenPayloadHandler_) { - tokenPayload = this.tokenPayloadHandler_(tokenPayload); - } - // Use the refresh URL if specified, otherwise fallback to the token URL. - var url = this.refreshUrl_ || this.tokenUrl_; - var response = UrlFetchApp.fetch(url, { - method: 'post', - headers: headers, - payload: tokenPayload, - muteHttpExceptions: true + + this.lockable_(function() { + var token = this.getToken(); + if (!token.refresh_token) { + throw new Error('Offline access is required.'); + } + var headers = { + Accept: this.tokenFormat_ + }; + if (this.tokenHeaders_) { + headers = extend_(headers, this.tokenHeaders_); + } + var tokenPayload = { + refresh_token: token.refresh_token, + client_id: this.clientId_, + client_secret: this.clientSecret_, + grant_type: 'refresh_token' + }; + if (this.tokenPayloadHandler_) { + tokenPayload = this.tokenPayloadHandler_(tokenPayload); + } + // Use the refresh URL if specified, otherwise fallback to the token URL. + var url = this.refreshUrl_ || this.tokenUrl_; + var response = UrlFetchApp.fetch(url, { + method: 'post', + headers: headers, + payload: tokenPayload, + muteHttpExceptions: true + }); + var newToken = this.getTokenFromResponse_(response); + if (!newToken.refresh_token) { + newToken.refresh_token = token.refresh_token; + } + this.saveToken_(newToken); }); - var newToken = this.getTokenFromResponse_(response); - if (!newToken.refresh_token) { - newToken.refresh_token = token.refresh_token; - } - this.saveToken_(newToken); }; /** @@ -634,3 +662,22 @@ Service_.prototype.createJwt_ = function() { var signature = Utilities.base64EncodeWebSafe(signatureBytes); return toSign + '.' + signature; }; + +/** + * Locks access to a block of code if a lock has been set on this service. + * @param {function} func The code to execute. + * @return {*} The result of the code block. + * @private + */ +Service_.prototype.lockable_ = function(func) { + var releaseLock = false; + if (this.lock_ && !this.lock_.hasLock()) { + this.lock_.waitLock(Service_.LOCK_EXPIRATION_MILLISECONDS_); + releaseLock = true; + } + var result = func.apply(this); + if (this.lock_ && releaseLock) { + this.lock_.releaseLock(); + } + return result; +}; diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 00000000..b7305192 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + "rules": { + "require-jsdoc": "off" + } +}; diff --git a/test/mocks/cache.js b/test/mocks/cache.js index c55a734d..25dbc100 100644 --- a/test/mocks/cache.js +++ b/test/mocks/cache.js @@ -1,5 +1,5 @@ -var MockCache = function() { - this.store = {}; +var MockCache = function(optCache) { + this.store = optCache || {}; this.counter = 0; }; diff --git a/test/mocks/lock.js b/test/mocks/lock.js new file mode 100644 index 00000000..8921b40e --- /dev/null +++ b/test/mocks/lock.js @@ -0,0 +1,44 @@ +/** + * @file Mocks out Apps Script's LockService.Lock, using the fibers library to + * emulate concurrent executions. Like Apps Script's locks, only one execution + * can hold the lock at a time. + */ + +var Fiber = require('fibers'); + +var locked = false; +var waitingFibers = []; + +var MockLock = function() { + this.hasLock_ = false; + this.id = Math.random(); +}; + +MockLock.prototype.waitLock = function(timeoutInMillis) { + var start = new Date(); + do { + if (!locked || this.hasLock_) { + locked = true; + this.hasLock_ = true; + return; + } else { + waitingFibers.push(Fiber.current); + Fiber.yield(); + } + } while (new Date().getTime() - start.getTime() < timeoutInMillis); + throw new Error('Unable to get lock'); +}; + +MockLock.prototype.releaseLock = function() { + locked = false; + this.hasLock_ = false; + if (waitingFibers.length) { + waitingFibers.pop().run(); + } +}; + +MockLock.prototype.hasLock = function() { + return this.hasLock_; +}; + +module.exports = MockLock; diff --git a/test/mocks/properties.js b/test/mocks/properties.js index 66dd3e8f..ff3f6ff1 100644 --- a/test/mocks/properties.js +++ b/test/mocks/properties.js @@ -1,5 +1,5 @@ -var MockProperties = function() { - this.store = {}; +var MockProperties = function(optStore) { + this.store = optStore || {}; this.counter = 0; }; diff --git a/test/mocks/urlfetchapp.js b/test/mocks/urlfetchapp.js new file mode 100644 index 00000000..999ac636 --- /dev/null +++ b/test/mocks/urlfetchapp.js @@ -0,0 +1,33 @@ +/** + * @file Mocks out Apps Script's UrlFetchApp, using the fibers library to + * emulate concurrent executions. + */ + +var Future = require('fibers/future'); + +var MockUrlFetchApp = function() { + this.delayFunction = () => 0; + this.resultFunction = () => ''; +}; + +MockUrlFetchApp.prototype.fetch = function(url, optOptions) { + var delay = this.delayFunction(); + var result = this.resultFunction(); + if (delay) { + sleep(delay).wait(); + } + return { + getContentText: () => result, + getResponseCode: () => 200 + }; +}; + +function sleep(ms) { + var future = new Future(); + setTimeout(function() { + future.return(); + }, ms); + return future; +} + +module.exports = MockUrlFetchApp; diff --git a/test/test.js b/test/test.js index 5c5eb129..0b65510d 100644 --- a/test/test.js +++ b/test/test.js @@ -1,14 +1,19 @@ var assert = require('chai').assert; var gas = require('gas-local'); +var MockUrlFetchApp = require('./mocks/urlfetchapp'); var MockProperties = require('./mocks/properties'); var MockCache = require('./mocks/cache'); +var MockLock = require('./mocks/lock'); +var Future = require('fibers/future'); var mocks = { ScriptApp: { getScriptId: function() { return '12345'; } - } + }, + UrlFetchApp: new MockUrlFetchApp(), + __proto__: gas.globalMockDefault }; var options = { filter: function(f) { @@ -42,44 +47,45 @@ describe('Service', function() { }); it('should load from the cache', function() { - var cache = new MockCache(); - var service = OAuth2.createService('test') - .setPropertyStore(new MockProperties()) - .setCache(cache); var token = { access_token: 'foo' }; - cache.put('oauth2.test', JSON.stringify(token)); + var cache = new MockCache({ + 'oauth2.test': JSON.stringify(token) + }); + var service = OAuth2.createService('test') + .setPropertyStore(new MockProperties()) + .setCache(cache); assert.deepEqual(service.getToken(), token); }); it('should load from the properties and set the cache', function() { + var token = { + access_token: 'foo' + }; var cache = new MockCache(); - var properties = new MockProperties(); + var properties = new MockProperties({ + 'oauth2.test': JSON.stringify(token) + }); var service = OAuth2.createService('test') .setPropertyStore(properties) .setCache(cache); - var key = 'oauth2.test'; - var token = { - access_token: 'foo' - }; - properties.setProperty(key, JSON.stringify(token)); + assert.deepEqual(service.getToken(), token); - assert.deepEqual(JSON.parse(cache.get(key)), token); + assert.deepEqual(JSON.parse(cache.get('oauth2.test')), token); }); it('should not hit the cache or properties on subsequent calls', function() { var cache = new MockCache(); - var properties = new MockProperties(); + var properties = new MockProperties({ + 'oauth2.test': JSON.stringify({ + access_token: 'foo' + }) + }); var service = OAuth2.createService('test') .setPropertyStore(properties) .setCache(cache); - var key = 'oauth2.test'; - var token = { - access_token: 'foo' - }; - properties.setProperty(key, JSON.stringify(token)); service.getToken(); var cacheStart = cache.counter; @@ -131,6 +137,110 @@ describe('Service', function() { assert.notExists(properties.getProperty(key)); }); }); + + describe('#hasAccess()', function() { + it('should use the lock to prevent concurrent access', function(done) { + var token = { + granted_time: 100, + expires_in: 100, + refresh_token: 'bar' + }; + var properties = new MockProperties({ + 'oauth2.test': JSON.stringify(token) + }); + + mocks.UrlFetchApp.delayFunction = () => 100; + mocks.UrlFetchApp.resultFunction = () => JSON.stringify({ + access_token: Math.random().toString(36) + }); + + var getAccessToken = function() { + var service = OAuth2.createService('test') + .setClientId('abc') + .setClientSecret('def') + .setTokenUrl('http://www.example.com') + .setPropertyStore(properties) + .setLock(new MockLock()); + if (service.hasAccess()) { + return service.getAccessToken(); + } else { + throw new Error('No access: ' + service.getLastError()); + }; + }.future(); + + Future.task(function() { + var first = getAccessToken(); + var second = getAccessToken(); + Future.wait(first, second); + return [first.get(), second.get()]; + }).resolve(function(err, accessTokens) { + if (err) { + done(err); + } + assert.equal(accessTokens[0], accessTokens[1]); + done(); + }); + }); + }); + + describe('#refresh()', function() { + /* + A race condition can occur when two executions attempt to refresh the + token at the same time. Some OAuth implementations only allow one + valid access token at a time, so we need to ensure that the last access + token granted is the one that is persisted. To replicate this, we have the + first exeuction wait longer for it's response to return through the + "network" and have the second execution get it's response back sooner. + */ + it('should use the lock to prevent race conditions', function(done) { + var token = { + granted_time: 100, + expires_in: 100, + refresh_token: 'bar' + }; + var properties = new MockProperties({ + 'oauth2.test': JSON.stringify(token) + }); + + var count = 0; + mocks.UrlFetchApp.resultFunction = function() { + return JSON.stringify({ + access_token: 'token' + count++ + }); + }; + var delayGenerator = function*() { + yield 100; + yield 10; + }(); + mocks.UrlFetchApp.delayFunction = function() { + return delayGenerator.next().value; + }; + + var refreshToken = function() { + OAuth2.createService('test') + .setClientId('abc') + .setClientSecret('def') + .setTokenUrl('http://www.example.com') + .setPropertyStore(properties) + .setLock(new MockLock()) + .refresh(); + }.future(); + + Future.task(function() { + var first = refreshToken(); + var second = refreshToken(); + Future.wait(first, second); + return [first.get(), second.get()]; + }).resolve(function(err) { + if (err) { + done(err); + } + var storedToken = JSON.parse(properties.getProperty('oauth2.test')); + assert.equal(storedToken.access_token, 'token1'); + done(); + }); + }); + }); }); describe('Utilities', function() {