浏览代码

Merge pull request #5 from ckeditor/t/4

Feature: Added mechanism to refresh the token. Closes #4.
Bartosz Czerwonka 8 年之前
父节点
当前提交
d4793877ea

+ 1 - 0
packages/ckeditor-cloud-services-core/.gitignore

@@ -1,4 +1,5 @@
 node_modules
 coverage
+.idea
 
 package-lock.json

+ 155 - 0
packages/ckeditor-cloud-services-core/src/token/token.js

@@ -0,0 +1,155 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* eslint-env browser */
+
+'use strict';
+
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+
+const DEFAULT_OPTIONS = { refreshInterval: 3600000, autoRefresh: true };
+
+/**
+ * Class representing the token used for communication with CKEditor Cloud Services.
+ * Value of the token is retrieving from the specified URL and is refreshed every 1 hour by default.
+ *
+ * @mixes ObservableMixin
+ */
+class Token {
+	/**
+	 * Creates `Token` instance.
+	 * Method `init` should be called after using the constructor or use `create` method instead.
+	 *
+	 * @param {String} tokenUrl Endpoint address to download the token.
+	 * @param {Object} options
+	 * @param {String} [options.initValue] Initial value of the token.
+	 * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour.
+	 * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
+	 */
+	constructor( tokenUrl, options = DEFAULT_OPTIONS ) {
+		if ( !tokenUrl ) {
+			throw new Error( '`tokenUrl` must be provided' );
+		}
+
+		/**
+		 * Value of the token.
+		 * The value of the token is null if `initValue` is not provided or `init` method was not called.
+		 * `create` method creates token with initialized value from url.
+		 *
+		 * @name value
+		 * @type {String}
+		 * @observable
+		 * @readonly
+		 * @memberOf Token#
+		 */
+		this.set( 'value', options.initValue );
+
+		/**
+		 * @type {String}
+		 * @private
+		 */
+		this._tokenUrl = tokenUrl;
+
+		/**
+		 * @type {Object}
+		 * @private
+		 */
+		this._options = Object.assign( {}, DEFAULT_OPTIONS, options );
+	}
+
+	/**
+	 * Initializes the token.
+	 *
+	 * @returns {Promise.<Token>}
+	 */
+	init() {
+		return new Promise( ( resolve, reject ) => {
+			if ( this._options.autoRefresh ) {
+				this._startRefreshing();
+			}
+
+			if ( !this.value ) {
+				this._refreshToken()
+					.then( resolve )
+					.catch( reject );
+
+				return;
+			}
+
+			resolve( this );
+		} );
+	}
+
+	/**
+	 * Gets the new token.
+	 *
+	 * @protected
+	 * @returns {Promise.<Token>}
+	 */
+	_refreshToken() {
+		return new Promise( ( resolve, reject ) => {
+			const xhr = new XMLHttpRequest();
+
+			xhr.open( 'GET', this._tokenUrl );
+
+			xhr.addEventListener( 'load', () => {
+				const statusCode = xhr.status;
+				const xhrResponse = xhr.response;
+
+				if ( statusCode < 200 || statusCode > 299 ) {
+					return reject( 'Cannot download new token!' );
+				}
+
+				this.set( 'value', xhrResponse );
+
+				return resolve( this );
+			} );
+
+			xhr.addEventListener( 'error', () => reject( 'Network Error' ) );
+			xhr.addEventListener( 'abort', () => reject( 'Abort' ) );
+
+			xhr.send();
+		} );
+	}
+
+	/**
+	 * Starts value refreshing every `refreshInterval` time.
+	 *
+	 * @protected
+	 */
+	_startRefreshing() {
+		this._refreshInterval = setInterval( this._refreshToken.bind( this ), this._options.refreshInterval );
+	}
+
+	/**
+	 * Stops value refreshing.
+	 *
+	 * @protected
+	 */
+	_stopRefreshing() {
+		clearInterval( this._refreshInterval );
+	}
+
+	/**
+	 * Creates a initialized {@link Token} instance.
+	 *
+	 * @param {String} tokenUrl Endpoint address to download the token.
+	 * @param {Object} options
+	 * @param {String} [options.initValue] Initial value of the token.
+	 * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour.
+	 * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
+	 * @returns {Promise.<Token>}
+	 */
+	static create( tokenUrl, options = DEFAULT_OPTIONS ) {
+		const token = new Token( tokenUrl, options );
+
+		return token.init();
+	}
+}
+
+mix( Token, ObservableMixin );
+
+export default Token;

+ 3 - 3
packages/ckeditor-cloud-services-core/src/uploadgateway/fileuploader.js

@@ -20,7 +20,7 @@ class FileUploader {
 	 * Creates `FileUploader` instance.
 	 *
 	 * @param {Blob|String} fileOrData A blob object or a data string encoded with Base64.
-	 * @param {String} token Token used for authentication.
+	 * @param {Token} token Token used for authentication.
 	 * @param {String} apiAddress API address.
 	 */
 	constructor( fileOrData, token, apiAddress ) {
@@ -46,7 +46,7 @@ class FileUploader {
 		/**
 		 * CKEditor Cloud Services access token.
 		 *
-		 * @type {String}
+		 * @type {Token}
 		 * @private
 		 */
 		this._token = token;
@@ -115,7 +115,7 @@ class FileUploader {
 		const xhr = new XMLHttpRequest();
 
 		xhr.open( 'POST', this._apiAddress );
-		xhr.setRequestHeader( 'Authorization', this._token );
+		xhr.setRequestHeader( 'Authorization', this._token.value );
 		xhr.responseType = 'json';
 
 		this.xhr = xhr;

+ 6 - 4
packages/ckeditor-cloud-services-core/src/uploadgateway/uploadgateway.js

@@ -14,7 +14,7 @@ export default class UploadGateway {
 	/**
 	 * Creates `UploadGateway` instance.
 	 *
-	 * @param {String} token Token used for authentication.
+	 * @param {Token} token Token used for authentication.
 	 * @param {String} apiAddress API address.
 	 */
 	constructor( token, apiAddress ) {
@@ -29,7 +29,7 @@ export default class UploadGateway {
 		/**
 		 * CKEditor Cloud Services access token.
 		 *
-		 * @type {String}
+		 * @type {Token}
 		 * @private
 		 */
 		this._token = token;
@@ -48,7 +48,8 @@ export default class UploadGateway {
 	 * The file is being sent at a time when the method {@link FileUploader#then then} is called
 	 * or when {@link FileUploader#send send} method is called.
 	 *
-	 *     new UploadGateway( 'JSON_WEB_TOKEN_FROM_SERVER', 'https://example.org' )
+	 *     const token = await Token.create( 'https://token-endpoint' );
+	 *     new UploadGateway( token, 'https://example.org' )
 	 *        .upload( 'FILE' )
 	 *        .onProgress( ( data ) => console.log( data ) )
 	 *        .send()
@@ -56,7 +57,8 @@ export default class UploadGateway {
 	 *
 	 *     // OR
 	 *
-	 *     new UploadGateway( 'JSON_WEB_TOKEN_FROM_SERVER', 'https://example.org' )
+	 *     const token = await Token.create( 'https://token-endpoint' );
+	 *     new UploadGateway( token, 'https://example.org' )
 	 *         .upload( 'FILE' )
 	 *         .onProgress( ( data ) => console.log( data ) )
 	 *         .send()

+ 201 - 0
packages/ckeditor-cloud-services-core/tests/token/token.js

@@ -0,0 +1,201 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* eslint-env commonjs, browser */
+
+'use strict';
+
+import Token from '../../src/token/token';
+
+describe( 'Token', () => {
+	let requests;
+
+	beforeEach( () => {
+		requests = [];
+
+		global.xhr = sinon.useFakeXMLHttpRequest();
+
+		global.xhr.onCreate = xhr => {
+			requests.push( xhr );
+		};
+	} );
+
+	afterEach( () => global.xhr.restore() );
+
+	describe( 'constructor()', () => {
+		it( 'should throw error when no tokenUrl provided', () => {
+			expect( () => new Token() ).to.throw( '`tokenUrl` must be provided' );
+		} );
+
+		it( 'should set a init token value', () => {
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			expect( token.value ).to.equal( 'initValue' );
+		} );
+
+		it( 'should fire `change:value` event if the value of the token has changed', done => {
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.on( 'change:value', ( event, name, newValue ) => {
+				expect( newValue ).to.equal( 'token-value' );
+
+				done();
+			} );
+
+			token.init();
+
+			requests[ 0 ].respond( 200, '', 'token-value' );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'should get a token value from endpoint', done => {
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.init()
+				.then( () => {
+					expect( token.value ).to.equal( 'token-value' );
+
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', 'token-value' );
+		} );
+
+		it( 'should start token refresh every 1 hour', done => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } );
+
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+
+			token.init()
+				.then( () => {
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+
+					expect( requests.length ).to.equal( 5 );
+
+					clock.restore();
+
+					done();
+				} );
+		} );
+	} );
+
+	describe( '_refreshToken()', () => {
+		it( 'should get a token from the specified address', done => {
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			token._refreshToken()
+				.then( newToken => {
+					expect( newToken.value ).to.equal( 'token-value' );
+
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', 'token-value' );
+		} );
+
+		it( 'should throw error when cannot download new token ', done => {
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			token._refreshToken()
+				.catch( error => {
+					expect( error ).to.equal( 'Cannot download new token!' );
+
+					done();
+				} );
+
+			requests[ 0 ].respond( 401 );
+		} );
+
+		it( 'should throw error when response is aborted', done => {
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			token._refreshToken()
+				.catch( error => {
+					expect( error ).to.equal( 'Abort' );
+
+					done();
+				} );
+
+			requests[ 0 ].abort();
+		} );
+
+		it( 'should throw error event when network error occurs', done => {
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			token._refreshToken()
+				.catch( error => {
+					expect( error ).to.equal( 'Network Error' );
+
+					done();
+				} );
+
+			requests[ 0 ].error();
+		} );
+	} );
+
+	describe( '_startRefreshing()', () => {
+		it( 'should start refreshing', () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } );
+
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+
+			token._startRefreshing();
+
+			clock.tick( 3600000 );
+			clock.tick( 3600000 );
+			clock.tick( 3600000 );
+			clock.tick( 3600000 );
+			clock.tick( 3600000 );
+
+			expect( requests.length ).to.equal( 5 );
+
+			clock.restore();
+		} );
+	} );
+
+	describe( '_stopRefreshing()', () => {
+		it( 'should stop refreshing', done => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } );
+
+			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+
+			token.init()
+				.then( () => {
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+
+					token._stopRefreshing();
+
+					clock.tick( 3600000 );
+					clock.tick( 3600000 );
+
+					expect( requests.length ).to.equal( 3 );
+
+					clock.restore();
+
+					done();
+				} );
+		} );
+	} );
+
+	describe( 'static create()', () => {
+		it( 'should return a initialized token', done => {
+			Token.create( 'http://token-endpoint', { autoRefresh: false } )
+				.then( token => {
+					expect( token.value ).to.equal( 'token-value' );
+
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', 'token-value' );
+		} );
+	} );
+} );

+ 7 - 5
packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js

@@ -8,17 +8,19 @@
 'use strict';
 
 import FileUploader from '../../src/uploadgateway/fileuploader';
+import Token from '../../src/token/token';
 
 const API_ADDRESS = 'https://example.dev';
-const TOKEN = 'token';
 const BASE_64_FILE = 'data:image/gif;base64,R0lGODlhCQAJAPIAAGFhYZXK/1FRUf///' +
 	'9ra2gD/AAAAAAAAACH5BAEAAAUALAAAAAAJAAkAAAMYWFqwru2xERcYJLSNNWNBVimC5wjfaTkJADs=';
 
 describe( 'FileUploader', () => {
+	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+
 	let fileUploader;
 
 	beforeEach( () => {
-		fileUploader = new FileUploader( BASE_64_FILE, TOKEN, API_ADDRESS );
+		fileUploader = new FileUploader( BASE_64_FILE, token, API_ADDRESS );
 	} );
 
 	describe( 'constructor()', () => {
@@ -31,11 +33,11 @@ describe( 'FileUploader', () => {
 		} );
 
 		it( 'should throw error when no api address provided', () => {
-			expect( () => new FileUploader( 'file', TOKEN ) ).to.throw( 'Api address must be provided' );
+			expect( () => new FileUploader( 'file', token ) ).to.throw( 'Api address must be provided' );
 		} );
 
 		it( 'should throw error when wrong Base64 file is provided', () => {
-			expect( () => new FileUploader( 'data:image/gif;base64,R', TOKEN, API_ADDRESS ) )
+			expect( () => new FileUploader( 'data:image/gif;base64,R', token, API_ADDRESS ) )
 				.to.throw( 'Problem with decoding Base64 image data.' );
 		} );
 
@@ -53,7 +55,7 @@ describe( 'FileUploader', () => {
 		it( 'should set `file` field', () => {
 			const file = new File( [], 'test.jpg' );
 
-			const fileUploader = new FileUploader( file, TOKEN, API_ADDRESS );
+			const fileUploader = new FileUploader( file, token, API_ADDRESS );
 
 			expect( fileUploader.file.name ).to.be.equal( 'test.jpg' );
 		} );

+ 5 - 2
packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js

@@ -7,21 +7,24 @@
 
 import FileUploader from '../../src/uploadgateway/fileuploader';
 import UploadGateway from '../../src/uploadgateway/uploadgateway';
+import Token from '../../src/token/token';
 
 describe( 'UploadGateway', () => {
+	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+
 	describe( 'constructor()', () => {
 		it( 'should throw error when no token provided', () => {
 			expect( () => new UploadGateway( undefined, 'test' ) ).to.throw( 'Token must be provided' );
 		} );
 
 		it( 'should throw error when no apiAddress provided', () => {
-			expect( () => new UploadGateway( 'token' ) ).to.throw( 'Api address must be provided' );
+			expect( () => new UploadGateway( token ) ).to.throw( 'Api address must be provided' );
 		} );
 	} );
 
 	describe( 'upload()', () => {
 		it( 'should return `FileUploader` instance', () => {
-			const uploader = new UploadGateway( 'token', 'test' );
+			const uploader = new UploadGateway( token, 'test' );
 
 			expect( uploader.upload( 'file' ) ).to.be.instanceOf( FileUploader );
 		} );