8
0
Просмотр исходного кода

Merge branch 'master' into i/8034

Piotrek Koszuliński 5 лет назад
Родитель
Сommit
ff1b532e2f

+ 1 - 1
.github/workflows/merge-stable.yml

@@ -22,7 +22,7 @@ jobs:
       - uses: rtCamp/action-slack-notify@v2.0.2
         id: error_message_slack
         name: Slack notification
-        if: (steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)
+        if: ((steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)) || failure()
         env:
           SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
           SLACK_CHANNEL: "cke5-ci"

+ 2 - 2
docs/framework/guides/contributing/code-style.md

@@ -334,8 +334,8 @@ CKEditor 5 development environment uses [ESLint](https://eslint.org) and [stylel
 A couple of useful links:
 
 * [Disabling ESLint with inline comments](https://eslint.org/docs/2.13.1/user-guide/configuring#disabling-rules-with-inline-comments).
-* [CKEditor 5 ESLint preset](https://github.com/ckeditor/ckeditor5-dev/blob/master/packages/eslint-config-ckeditor5/.eslintrc.js) (npm: [`eslint-config-ckeditor5`](http://npmjs.com/package/eslint-config-ckeditor5)).
-* [CKEditor 5 stylelint preset](https://github.com/ckeditor/ckeditor5-dev/blob/master/packages/stylelint-config-ckeditor5/.stylelintrc) (npm: [`stylelint-config-ckeditor5`](https://www.npmjs.com/package/stylelint-config-ckeditor5)).
+* [CKEditor 5 ESLint preset](https://github.com/ckeditor/eslint-config-ckeditor5/blob/master/.eslintrc.js) (npm: [`eslint-config-ckeditor5`](http://npmjs.com/package/eslint-config-ckeditor5)).
+* [CKEditor 5 stylelint preset](https://github.com/ckeditor/stylelint-config-ckeditor5/blob/master/.stylelintrc) (npm: [`stylelint-config-ckeditor5`](https://www.npmjs.com/package/stylelint-config-ckeditor5)).
 
 <info-box>
 	Avoid using automatic code formatters on existing code. It is fine to automatically format code that you are editing, but you should not be changing the formatting of the code that is already written to not pollute your PRs. You should also not rely solely on automatic corrections.

+ 70 - 16
packages/ckeditor-cloud-services-core/src/token/token.js

@@ -7,13 +7,14 @@
  * @module cloud-services-core/token
  */
 
-/* globals XMLHttpRequest, setInterval, clearInterval */
+/* globals XMLHttpRequest, setTimeout, clearTimeout, atob */
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
-const DEFAULT_OPTIONS = { refreshInterval: 3600000, autoRefresh: true };
+const DEFAULT_OPTIONS = { autoRefresh: true };
+const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000;
 
 /**
  * Class representing the token used for communication with CKEditor Cloud Services.
@@ -30,7 +31,6 @@ class Token {
 	 * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
 	 * @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( tokenUrlOrRefreshToken, options = DEFAULT_OPTIONS ) {
@@ -46,6 +46,10 @@ class Token {
 			);
 		}
 
+		if ( options.initValue ) {
+			this._validateTokenValue( options.initValue );
+		}
+
 		/**
 		 * Value of the token.
 		 * The value of the token is null if `initValue` is not provided or `init` method was not called.
@@ -84,10 +88,6 @@ class Token {
 	 */
 	init() {
 		return new Promise( ( resolve, reject ) => {
-			if ( this._options.autoRefresh ) {
-				this._startRefreshing();
-			}
-
 			if ( !this.value ) {
 				this.refreshToken()
 					.then( resolve )
@@ -96,6 +96,10 @@ class Token {
 				return;
 			}
 
+			if ( this._options.autoRefresh ) {
+				this._registerRefreshTokenTimeout();
+			}
+
 			resolve( this );
 		} );
 	}
@@ -106,7 +110,14 @@ class Token {
 	 */
 	refreshToken() {
 		return this._refresh()
-			.then( value => this.set( 'value', value ) )
+			.then( value => {
+				this._validateTokenValue( value );
+				this.set( 'value', value );
+
+				if ( this._options.autoRefresh ) {
+					this._registerRefreshTokenTimeout();
+				}
+			} )
 			.then( () => this );
 	}
 
@@ -114,25 +125,69 @@ class Token {
 	 * Destroys token instance. Stops refreshing.
 	 */
 	destroy() {
-		this._stopRefreshing();
+		clearTimeout( this._tokenRefreshTimeout );
 	}
 
 	/**
-	 * Starts value refreshing every `refreshInterval` time.
+	 * Checks whether the provided token follows the JSON Web Tokens (JWT) format.
 	 *
 	 * @protected
+	 * @param {String} tokenValue The token to validate.
 	 */
-	_startRefreshing() {
-		this._refreshInterval = setInterval( () => this.refreshToken(), this._options.refreshInterval );
+	_validateTokenValue( tokenValue ) {
+		// The token must be a string.
+		const isString = typeof tokenValue === 'string';
+
+		// The token must be a plain string without quotes ("").
+		const isPlainString = !/^".*"$/.test( tokenValue );
+
+		// JWT token contains 3 parts: header, payload, and signature.
+		// Each part is separated by a dot.
+		const isJWTFormat = isString && tokenValue.split( '.' ).length === 3;
+
+		if ( !( isPlainString && isJWTFormat ) ) {
+			/**
+			 * The provided token must follow the [JSON Web Tokens](https://jwt.io/introduction/) format.
+			 *
+			 * @error token-not-in-jwt-format
+			 */
+			throw new CKEditorError( 'token-not-in-jwt-format', this );
+		}
 	}
 
 	/**
-	 * Stops value refreshing.
+	 * Registers a refresh token timeout for the time taken from token.
 	 *
 	 * @protected
 	 */
-	_stopRefreshing() {
-		clearInterval( this._refreshInterval );
+	_registerRefreshTokenTimeout() {
+		const tokenRefreshTimeoutTime = this._getTokenRefreshTimeoutTime();
+
+		clearTimeout( this._tokenRefreshTimeout );
+
+		this._tokenRefreshTimeout = setTimeout( () => {
+			this.refreshToken();
+		}, tokenRefreshTimeoutTime );
+	}
+
+	/**
+	 * Returns token refresh timeout time calculated from expire time in the token payload.
+	 *
+	 * If the token parse fails, the default DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME is returned.
+	 *
+	 * @protected
+	 * @returns {Number}
+	 */
+	_getTokenRefreshTimeoutTime() {
+		try {
+			const [ , binaryTokenPayload ] = this.value.split( '.' );
+			const { exp: tokenExpireTime } = JSON.parse( atob( binaryTokenPayload ) );
+			const tokenRefreshTimeoutTime = Math.floor( ( tokenExpireTime - Date.now() ) / 2 );
+
+			return tokenRefreshTimeoutTime;
+		} catch ( err ) {
+			return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME;
+		}
 	}
 
 	/**
@@ -142,7 +197,6 @@ class Token {
 	 * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
 	 * @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.<module:cloud-services-core/token~Token>}
 	 */

+ 180 - 103
packages/ckeditor-cloud-services-core/tests/token/token.js

@@ -26,31 +26,56 @@ describe( 'Token', () => {
 	} );
 
 	describe( 'constructor()', () => {
-		it( 'should throw error when no tokenUrl provided', () => {
+		it( 'should throw an error when no tokenUrl provided', () => {
 			expect( () => new Token() ).to.throw(
 				CKEditorError,
 				'token-missing-token-url'
 			);
 		} );
 
-		it( 'should set a init token value', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error if the token passed in options is not a string', () => {
+			expect( () => new Token( 'http://token-endpoint', { initValue: 123456 } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
+
+		it( 'should throw an error if the token passed in options is wrapped in additional quotes', () => {
+			const tokenInitValue = getTestTokenValue();
+
+			expect( () => new Token( 'http://token-endpoint', { initValue: `"${ tokenInitValue }"` } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
+
+		it( 'should throw an error if the token passed in options is not a valid JWT token', () => {
+			expect( () => new Token( 'http://token-endpoint', { initValue: 'token' } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
 
-			expect( token.value ).to.equal( 'initValue' );
+		it( 'should set token value if the token passed in options is valid', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
+
+			expect( token.value ).to.equal( tokenInitValue );
 		} );
 
 		it( 'should fire `change:value` event if the value of the token has changed', done => {
+			const tokenValue = getTestTokenValue();
 			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.on( 'change:value', ( event, name, newValue ) => {
-				expect( newValue ).to.equal( 'token-value' );
+				expect( newValue ).to.equal( tokenValue );
 
 				done();
 			} );
 
 			token.init();
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should accept the callback in the constructor', () => {
@@ -62,98 +87,183 @@ describe( 'Token', () => {
 	} );
 
 	describe( 'init()', () => {
-		it( 'should get a token value from endpoint', done => {
+		it( 'should get a token value from the endpoint', done => {
+			const tokenValue = getTestTokenValue();
 			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.init()
 				.then( () => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should get a token from the refreshToken function when is provided', () => {
-			const token = new Token( () => Promise.resolve( 'token-value' ), { autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } );
 
 			return token.init()
 				.then( () => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 				} );
 		} );
 
-		it( 'should start token refresh every 1 hour', done => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } );
+		it( 'should not refresh token if autoRefresh is disabled in options', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
 
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 
-			token.init()
-				.then( () => {
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			await token.init();
 
-					expect( requests.length ).to.equal( 5 );
+			await clock.tickAsync( 3600000 );
 
-					clock.restore();
+			expect( requests ).to.be.empty;
 
-					done();
-				} );
+			clock.restore();
+		} );
+
+		it( 'should refresh token with the time specified in token `exp` payload property', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
+
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
+
+			await token.init();
+
+			await clock.tickAsync( 1800000 );
+			requests[ 0 ].respond( 200, '', getTestTokenValue( 150000 ) );
+
+			await clock.tickAsync( 75000 );
+			requests[ 1 ].respond( 200, '', getTestTokenValue( 10000 ) );
+
+			await clock.tickAsync( 5000 );
+			requests[ 2 ].respond( 200, '', getTestTokenValue( 2000 ) );
+
+			await clock.tickAsync( 1000 );
+			requests[ 3 ].respond( 200, '', getTestTokenValue( 300 ) );
+
+			await clock.tickAsync( 150 );
+			requests[ 4 ].respond( 200, '', getTestTokenValue( 300 ) );
+
+			expect( requests.length ).to.equal( 5 );
+
+			clock.restore();
+		} );
+
+		it( 'should refresh the token with the default time if getting token expiration time failed', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenValue = 'header.test.signature';
+
+			const token = new Token( 'http://token-endpoint', { initValue: tokenValue } );
+
+			await token.init();
+
+			await clock.tickAsync( 3600000 );
+			requests[ 0 ].respond( 200, '', tokenValue );
+
+			await clock.tickAsync( 3600000 );
+			requests[ 1 ].respond( 200, '', tokenValue );
+
+			expect( requests.length ).to.equal( 2 );
+
+			clock.restore();
 		} );
 	} );
 
 	describe( 'destroy', () => {
-		it( 'should stop refreshing the token', () => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } );
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+		it( 'should stop refreshing the token', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout', 'clearTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
 
-			return token.init()
-				.then( () => {
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
 
-					expect( requests.length ).to.equal( 2 );
+			await token.init();
 
-					token.destroy();
+			await clock.tickAsync( 1800000 );
+			requests[ 0 ].respond( 200, '', getTestTokenValue( 150000 ) );
+			await clock.tickAsync( 100 );
 
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			await clock.tickAsync( 75000 );
+			requests[ 1 ].respond( 200, '', getTestTokenValue( 10000 ) );
+			await clock.tickAsync( 100 );
 
-					expect( requests.length ).to.equal( 2 );
-				} );
+			token.destroy();
+
+			await clock.tickAsync( 3600000 );
+			await clock.tickAsync( 3600000 );
+			await clock.tickAsync( 3600000 );
+
+			expect( requests.length ).to.equal( 2 );
+
+			clock.restore();
 		} );
 	} );
 
 	describe( 'refreshToken()', () => {
 		it( 'should get a token from the specified address', done => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.refreshToken()
 				.then( newToken => {
-					expect( newToken.value ).to.equal( 'token-value' );
+					expect( newToken.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
+		} );
+
+		it( 'should throw an error if the returned token is wrapped in additional quotes', done => {
+			const tokenValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.refreshToken()
+				.then( () => {
+					done( new Error( 'Promise should be rejected' ) );
+				} )
+				.catch( error => {
+					expect( error.constructor ).to.equal( CKEditorError );
+					expect( error ).to.match( /token-not-in-jwt-format/ );
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', `"${ tokenValue }"` );
+		} );
+
+		it( 'should throw an error if the returned token is not a valid JWT token', done => {
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.refreshToken()
+				.then( () => {
+					done( new Error( 'Promise should be rejected' ) );
+				} )
+				.catch( error => {
+					expect( error.constructor ).to.equal( CKEditorError );
+					expect( error ).to.match( /token-not-in-jwt-format/ );
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', 'token' );
 		} );
 
 		it( 'should get a token from the specified callback function', () => {
-			const token = new Token( () => Promise.resolve( 'token-value' ), { initValue: 'initValue', autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } );
 
 			return token.refreshToken()
 				.then( newToken => {
-					expect( newToken.value ).to.equal( 'token-value' );
+					expect( newToken.value ).to.equal( tokenValue );
 				} );
 		} );
 
-		it( 'should throw an error when cannot download new token', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error when cannot download a new token', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].respond( 401 );
@@ -167,7 +277,8 @@ describe( 'Token', () => {
 		} );
 
 		it( 'should throw an error when the response is aborted', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].abort();
@@ -180,7 +291,8 @@ describe( 'Token', () => {
 		} );
 
 		it( 'should throw an error when network error occurs', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].error();
@@ -192,8 +304,9 @@ describe( 'Token', () => {
 			} );
 		} );
 
-		it( 'should throw an error when the callback throws error', () => {
-			const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error when the callback throws an error', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: tokenInitValue, autoRefresh: false } );
 
 			token.refreshToken()
 				.catch( error => {
@@ -202,75 +315,39 @@ describe( 'Token', () => {
 		} );
 	} );
 
-	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 => {
+		it( 'should return an initialized token', done => {
+			const tokenValue = getTestTokenValue();
+
 			Token.create( 'http://token-endpoint', { autoRefresh: false } )
 				.then( token => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should use default options when none passed', done => {
-			const intervalSpy = sinon.spy( window, 'setInterval' );
+			const tokenValue = getTestTokenValue();
 
 			Token.create( 'http://token-endpoint' )
-				.then( () => {
-					expect( intervalSpy.args[ 0 ][ 1 ] ).to.equal( 3600000 );
+				.then( token => {
+					expect( token._options ).to.eql( { autoRefresh: true } );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 	} );
 } );
+
+// Returns valid token for tests with given expiration time offset.
+//
+// @param {Number} [timeOffset=3600000]
+// @returns {String}
+function getTestTokenValue( timeOffset = 3600000 ) {
+	return `header.${ btoa( JSON.stringify( { exp: Date.now() + timeOffset } ) ) }.signature`;
+}

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

@@ -14,7 +14,8 @@ const BASE_64_FILE = 'data:image/gif;base64,R0lGODlhCQAJAPIAAGFhYZXK/1FRUf///' +
 	'9ra2gD/AAAAAAAAACH5BAEAAAUALAAAAAAJAAkAAAMYWFqwru2xERcYJLSNNWNBVimC5wjfaTkJADs=';
 
 describe( 'FileUploader', () => {
-	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+	const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`;
+	const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } );
 
 	let fileUploader;
 
@@ -116,9 +117,12 @@ describe( 'FileUploader', () => {
 					expect( request.url ).to.equal( API_ADDRESS );
 					expect( request.method ).to.equal( 'POST' );
 					expect( request.responseType ).to.equal( 'json' );
-					expect( request.requestHeaders ).to.be.deep.equal( { Authorization: 'token' } );
+					expect( request.requestHeaders ).to.be.deep.equal( { Authorization: tokenInitValue } );
 
 					done();
+				} )
+				.catch( err => {
+					console.log( err );
 				} );
 
 			request.respond( 200, { 'Content-Type': 'application/json' },

+ 4 - 1
packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js

@@ -3,13 +3,16 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* eslint-env browser */
+
 import FileUploader from '../../src/uploadgateway/fileuploader';
 import UploadGateway from '../../src/uploadgateway/uploadgateway';
 import Token from '../../src/token/token';
 import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'UploadGateway', () => {
-	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+	const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`;
+	const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } );
 
 	describe( 'constructor()', () => {
 		it( 'should throw error when no token provided', () => {

+ 43 - 8
packages/ckeditor5-link/src/linkui.js

@@ -662,16 +662,27 @@ export default class LinkUI extends Plugin {
 		const model = this.editor.model;
 
 		model.change( writer => {
+			const range = model.document.selection.getFirstRange();
+
 			if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) {
-				writer.updateMarker( VISUAL_SELECTION_MARKER_NAME, {
-					range: model.document.selection.getFirstRange()
-				} );
+				writer.updateMarker( VISUAL_SELECTION_MARKER_NAME, { range } );
 			} else {
-				writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
-					usingOperation: false,
-					affectsData: false,
-					range: model.document.selection.getFirstRange()
-				} );
+				if ( range.start.isAtEnd ) {
+					const focus = model.document.selection.focus;
+					const nextValidRange = getNextValidRange( range, focus, writer );
+
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range: nextValidRange
+					} );
+				} else {
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range
+					} );
+				}
 			}
 		} );
 	}
@@ -700,3 +711,27 @@ export default class LinkUI extends Plugin {
 function findLinkElementAncestor( position ) {
 	return position.getAncestors().find( ancestor => isLinkElement( ancestor ) );
 }
+
+// Returns next valid range for the fake visual selection marker.
+//
+// @private
+// @param {module:engine/model/range~Range} range Current range.
+// @param {module:engine/model/position~Position} focus Selection focus.
+// @param {module:engine/model/writer~Writer} writer Writer.
+// @returns {module:engine/model/range~Range} New valid range for the fake visual selection marker.
+function getNextValidRange( range, focus, writer ) {
+	const nextStartPath = [ range.start.path[ 0 ] + 1, 0 ];
+	const nextStartPosition = writer.createPositionFromPath( range.start.root, nextStartPath, 'toNext' );
+	const nextRange = writer.createRange( nextStartPosition, range.end );
+
+	// Block creating a potential next valid range over the current range end.
+	if ( nextRange.start.path[ 0 ] > range.end.path[ 0 ] ) {
+		return writer.createRange( focus );
+	}
+
+	if ( nextStartPosition.isAtStart && nextStartPosition.isAtEnd ) {
+		return getNextValidRange( nextRange, focus, writer );
+	}
+
+	return nextRange;
+}

+ 212 - 29
packages/ckeditor5-link/tests/linkui.js

@@ -477,46 +477,229 @@ describe( 'LinkUI', () => {
 			} );
 		} );
 
-		it( 'should display a fake visual selection when a text fragment is selected', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+		describe( 'fake visual selection', () => {
+			describe( 'non-collapsed', () => {
+				it( 'should be displayed when a text fragment is selected', () => {
+					setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
 
-			linkUIFeature._showUI();
+					linkUIFeature._showUI();
 
-			expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			const paragraph = editor.model.document.getRoot().getChild( 0 );
-			const expectedRange = editor.model.createRange(
-				editor.model.createPositionAt( paragraph, 1 ),
-				editor.model.createPositionAt( paragraph, 2 )
-			);
-			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+					const paragraph = editor.model.document.getRoot().getChild( 0 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( paragraph, 1 ),
+						editor.model.createPositionAt( paragraph, 2 )
+					);
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
 
-			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
 
-			expect( getViewData( editor.editing.view ) ).to.equal( '<p>f{<span class="ck-fake-link-selection">o</span>}o</p>' );
-			expect( editor.getData() ).to.equal( '<p>foo</p>' );
-		} );
+					expect( getViewData( editor.editing.view ) ).to.equal( '<p>f{<span class="ck-fake-link-selection">o</span>}o</p>' );
+					expect( editor.getData() ).to.equal( '<p>foo</p>' );
+				} );
 
-		it( 'should display a fake visual selection on a collapsed selection', () => {
-			setModelData( editor.model, '<paragraph>f[]o</paragraph>' );
+				it( 'should display a fake visual selection on the next non-empty text node when selection starts at the end ' +
+					'of the empty block in the multiline selection', () => {
+					setModelData( editor.model, '<paragraph>[</paragraph><paragraph>foo]</paragraph>' );
 
-			linkUIFeature._showUI();
+					linkUIFeature._showUI();
 
-			expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const secondParagraph = editor.model.document.getRoot().getChild( 1 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( secondParagraph, 0 ),
+						editor.model.createPositionAt( secondParagraph, 3 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>[</p>' +
+						'<p><span class="ck-fake-link-selection">foo</span>]</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>&nbsp;</p><p>foo</p>' );
+				} );
+
+				it( 'should display a fake visual selection on the next non-empty text node when selection starts at the end ' +
+					'of the first block in the multiline selection', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph><paragraph>bar]</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			const paragraph = editor.model.document.getRoot().getChild( 0 );
-			const expectedRange = editor.model.createRange(
-				editor.model.createPositionAt( paragraph, 1 ),
-				editor.model.createPositionAt( paragraph, 1 )
-			);
-			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+					const secondParagraph = editor.model.document.getRoot().getChild( 1 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( secondParagraph, 0 ),
+						editor.model.createPositionAt( secondParagraph, 3 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>foo{</p>' +
+						'<p><span class="ck-fake-link-selection">bar</span>]</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on first text node in non-empty element when selection contains few empty elements', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]baz</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+					const firstNonEmptyElementInTheSelection = editor.model.document.getRoot().getChild( 3 );
+					const rangeEnd = editor.model.document.selection.getFirstRange().end;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( firstNonEmptyElementInTheSelection, 0 ),
+						editor.model.createPositionAt( rangeEnd, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p><span class="ck-fake-link-selection">bar</span></p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p>}baz</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal(
+						'<p>foo</p>' +
+						'<p>&nbsp;</p><p>&nbsp;</p>' +
+						'<p>bar</p>' +
+						'<p>&nbsp;</p><p>&nbsp;</p>' +
+						'<p>baz</p>'
+					);
+				} );
+			} );
 
-			expect( getViewData( editor.editing.view ) ).to.equal(
-				'<p>f{}<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>o</p>'
-			);
-			expect( editor.getData() ).to.equal( '<p>fo</p>' );
+			describe( 'collapsed', () => {
+				it( 'should be displayed on a collapsed selection', () => {
+					setModelData( editor.model, '<paragraph>f[]o</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const paragraph = editor.model.document.getRoot().getChild( 0 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( paragraph, 1 ),
+						editor.model.createPositionAt( paragraph, 1 )
+					);
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>f{}<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>o</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>fo</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains only one empty element ' +
+					'(selection focus is at the beginning of the first non-empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains few empty elements ' +
+					'(selection focus is at the beginning of the first non-empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains few empty elements ' +
+					'(selection focus is inside an empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]</paragraph>' +
+						'<paragraph>bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span></p>' +
+						'<p>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+			} );
 		} );
 
 		function getMarkersRange( editor ) {

+ 32 - 12
packages/ckeditor5-list/src/liststyleediting.js

@@ -141,11 +141,29 @@ export default class ListStyleEditing extends Plugin {
 				return;
 			}
 
+			// Find the outermost list item based on the `listIndent` attribute. We can't assume that `listIndent=0`
+			// because the selection can be hooked in nested lists.
+			//
+			// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List [item 1.1</listItem>
+			// <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1.</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="circle">UL List ]item 1.1</listItem>
+			//
+			// After deleting the content, we would like to inherit the "square" attribute for the last element:
+			//
+			// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List []item 1.1</listItem>
 			const mostOuterItemList = getSiblingListItem( firstPosition.parent, {
 				sameIndent: true,
-				listIndent: 0
+				listIndent: nextSibling.getAttribute( 'listIndent' )
 			} );
 
+			// The outermost list item may not exist while removing elements between lists with different value
+			// of the `listIndent` attribute. In such a case we don't want to update anything. See: #8073.
+			if ( !mostOuterItemList ) {
+				return;
+			}
+
 			if ( mostOuterItemList.getAttribute( 'listType' ) === nextSibling.getAttribute( 'listType' ) ) {
 				firstMostOuterItem = mostOuterItemList;
 			}
@@ -167,7 +185,7 @@ export default class ListStyleEditing extends Plugin {
 				// <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
 				const secondListMostOuterItem = getSiblingListItem( firstMostOuterItem.nextSibling, {
 					sameIndent: true,
-					listIndent: 0,
+					listIndent: firstMostOuterItem.getAttribute( 'listIndent' ),
 					direction: 'forward'
 				} );
 
@@ -211,7 +229,6 @@ function downcastListStyleAttribute() {
 		dispatcher.on( 'attribute:listStyle:listItem', ( evt, data, conversionApi ) => {
 			const viewWriter = conversionApi.writer;
 			const currentElement = data.item;
-			const listStyle = data.attributeNewValue;
 
 			const previousElement = getSiblingListItem( currentElement.previousSibling, {
 				sameIndent: true,
@@ -221,25 +238,23 @@ function downcastListStyleAttribute() {
 
 			const viewItem = conversionApi.mapper.toViewElement( currentElement );
 
-			// Single item list.
-			if ( !previousElement ) {
-				setListStyle( viewWriter, listStyle, viewItem.parent );
-			} else if ( !areRepresentingSameList( previousElement, currentElement ) ) {
+			// A case when elements represent different lists. We need to separate their container.
+			if ( !areRepresentingSameList( currentElement, previousElement ) ) {
 				viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
-				viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
-
-				setListStyle( viewWriter, listStyle, viewItem.parent );
 			}
+
+			setListStyle( viewWriter, data.attributeNewValue, viewItem.parent );
 		}, { priority: 'low' } );
 	};
 
 	// Checks whether specified list items belong to the same list.
 	//
 	// @param {module:engine/model/element~Element} listItem1 The first list item to check.
-	// @param {module:engine/model/element~Element} listItem2 The second list item to check.
+	// @param {module:engine/model/element~Element|null} listItem2 The second list item to check.
 	// @returns {Boolean}
 	function areRepresentingSameList( listItem1, listItem2 ) {
-		return listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
+		return listItem2 &&
+			listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
 			listItem1.getAttribute( 'listIndent' ) === listItem2.getAttribute( 'listIndent' ) &&
 			listItem1.getAttribute( 'listStyle' ) === listItem2.getAttribute( 'listStyle' );
 	}
@@ -459,6 +474,11 @@ function fixListStyleAttributeOnListItemElements( editor ) {
 				// ■ Paragraph[]  // <-- The inserted item.
 				while ( existingListItem.is( 'element', 'listItem' ) && existingListItem.getAttribute( 'listIndent' ) !== indent ) {
 					existingListItem = existingListItem.previousSibling;
+
+					// If the item does not exist, most probably there is no other content in the editor. See: #8072.
+					if ( !existingListItem ) {
+						break;
+					}
 				}
 			}
 		}

+ 184 - 4
packages/ckeditor5-list/tests/liststyleediting.js

@@ -384,6 +384,41 @@ describe( 'ListStyleEditing', () => {
 					'</ul>'
 				);
 			} );
+
+			// See: #8081.
+			it( 'should convert properly nested list styles', () => {
+				// ■ Level 0
+				//     ▶ Level 0.1
+				//         ○ Level 0.1.1
+				//     ▶ Level 0.2
+				//         ○ Level 0.2.1
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Level 0</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="default">Level 0.1</listItem>' +
+					'<listItem listIndent="2" listType="bulleted" listStyle="circle">Level 0.1.1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="default">Level 0.2</listItem>' +
+					'<listItem listIndent="2" listType="bulleted" listStyle="circle">Level 0.2.1</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul>' +
+						'<li>Level 0' +
+							'<ul>' +
+								'<li>Level 0.1' +
+									'<ul style="list-style-type:circle">' +
+										'<li>Level 0.1.1</li>' +
+									'</ul>' +
+								'</li>' +
+								'<li>Level 0.2' +
+									'<ul style="list-style-type:circle">' +
+										'<li>Level 0.2.1</li>' +
+									'</ul>' +
+								'</li>' +
+							'</ul>' +
+						'</li>' +
+					'</ul>'
+				);
+			} );
 		} );
 	} );
 
@@ -725,6 +760,21 @@ describe( 'ListStyleEditing', () => {
 					'<listItem listIndent="1" listStyle="decimal" listType="numbered">3.[]</listItem>'
 				);
 			} );
+
+			// See: #8072.
+			it( 'should not throw when indenting a list without any other content in the editor', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">[]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[]</listItem>'
+				);
+			} );
 		} );
 
 		describe( 'outdenting lists', () => {
@@ -923,6 +973,97 @@ describe( 'ListStyleEditing', () => {
 			} );
 		} );
 
+		describe( 'delete + undo', () => {
+			let editor, model, view;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, ListStyleEditing, Typing, UndoEditing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						view = editor.editing.view;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			// See: #7930.
+			it( 'should restore proper list style attribute after undo merging lists', () => {
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// <paragraph>
+				// ■ 1.
+				// ■ 2.
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>' +
+					'<paragraph>[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ), 'initial data' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+					'</ul>' +
+					'<p></p>' +
+					'<ul style="list-style-type:square">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+
+				// After removing the paragraph.
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// ○ 1.
+				// ○ 2.
+				editor.execute( 'delete' );
+
+				expect( getViewData( view, { withoutSelection: true } ), 'executing delete' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+
+				// After undo.
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// <paragraph>
+				// ■ 1.
+				// ■ 2.
+				editor.execute( 'undo' );
+
+				expect( getViewData( view, { withoutSelection: true } ), 'initial data' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+					'</ul>' +
+					'<p></p>' +
+					'<ul style="list-style-type:square">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+			} );
+		} );
+
 		describe( 'todo list', () => {
 			let editor, model;
 
@@ -1096,8 +1237,8 @@ describe( 'ListStyleEditing', () => {
 				setModelData( model,
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
-					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
-					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="numbered">2.1.1</listItem>' +
 					'<paragraph>[]</paragraph>' +
 					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
@@ -1108,8 +1249,8 @@ describe( 'ListStyleEditing', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
-					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
-					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1[]</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="numbered">2.1.1[]</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
 				);
@@ -1233,6 +1374,45 @@ describe( 'ListStyleEditing', () => {
 				);
 			} );
 
+			// See: #8073.
+			it( 'should not crash when removing a content between intended lists', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">aaaa</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">bb[bb</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="bulleted">cc]cc</listItem>' +
+					'<listItem listIndent="3" listStyle="default" listType="bulleted">dddd</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">aaaa</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">bb[]cc</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="bulleted">dddd</listItem>'
+				);
+			} );
+
+			it( 'should read the `listStyle` attribute from the most outer selected list while removing content between lists', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="lower-latin" listType="numbered">2.1.1[foo</listItem>' +
+					'<paragraph>Foo</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">bar]2.</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="lower-latin" listType="numbered">2.1.1[]2.</listItem>'
+				);
+			} );
+
 			function simulateTyping( text ) {
 				// While typing, every character is an atomic change.
 				text.split( '' ).forEach( character => {

+ 1 - 1
packages/ckeditor5-upload/docs/features/simple-upload-adapter.md

@@ -67,7 +67,7 @@ ClassicEditor
 
 			// Headers sent along with the XMLHttpRequest to the upload server.
 			headers: {
-				'X-CSRF-TOKEN': 'CSFR-Token',
+				'X-CSRF-TOKEN': 'CSRF-Token',
 				Authorization: 'Bearer <JSON Web Token>'
 			}
 		}