diff --git a/src/Signup.js b/src/Signup.js index 18d338cc32..a76919f34e 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -54,6 +54,9 @@ class Signup { * This exists for the lifetime of a user's attempt to register an account, * so if their registration attempt fails for whatever reason and they * try again, call register() on the same instance again. + * + * TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It + * would be nice to make use of that rather than rolling our own version of it. */ class Register extends Signup { constructor(hsUrl, isUrl, opts) { @@ -130,6 +133,18 @@ class Register extends Signup { this.password = password; const client = this._createTemporaryClient(); this.activeStage = null; + + // If there hasn't been a client secret set by this point, + // generate one for this session. It will only be used if + // we do email verification, but far simpler to just make + // sure we have one. + // We re-use this same secret over multiple calls to register + // so that the identity server can honour the sendAttempt + // parameter and not re-send email unless we actually want + // another mail to be sent. + if (!this.params.clientSecret) { + this.params.clientSecret = client.generateClientSecret(); + } return this._tryRegister(client); } diff --git a/src/SignupStages.js b/src/SignupStages.js index 2b0d163a08..283b11afef 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -53,66 +53,24 @@ class RecaptchaStage extends Stage { constructor(matrixClient, signupInstance) { super(RecaptchaStage.TYPE, matrixClient, signupInstance); this.defer = q.defer(); // resolved with the captcha response - this.publicKey = null; // from the HS - this.divId = null; // from the UI component } - // called when the UI component has loaded the recaptcha
so we can - // render to it. + // called when the recaptcha has been completed. onReceiveData(data) { - if (!data || !data.divId) { + if (!data || !data.response) { return; } - this.divId = data.divId; - this._attemptRender(); + this.defer.resolve({ + auth: { + type: 'm.login.recaptcha', + response: data.response, + } + }); } complete() { - var publicKey; - var serverParams = this.signupInstance.getServerData().params; - if (serverParams && serverParams["m.login.recaptcha"]) { - publicKey = serverParams["m.login.recaptcha"].public_key; - } - if (!publicKey) { - return q.reject({ - message: "This server has not supplied enough information for Recaptcha " + - "authentication", - isFatal: true - }); - } - this.publicKey = publicKey; - this._attemptRender(); return this.defer.promise; } - - _attemptRender() { - if (!global.grecaptcha) { - console.error("grecaptcha not loaded!"); - return; - } - if (!this.publicKey) { - console.error("No public key for recaptcha!"); - return; - } - if (!this.divId) { - console.error("No div ID specified!"); - return; - } - console.log("Rendering to %s", this.divId); - var self = this; - global.grecaptcha.render(this.divId, { - sitekey: this.publicKey, - callback: function(response) { - console.log("Received captcha response"); - self.defer.resolve({ - auth: { - type: 'm.login.recaptcha', - response: response - } - }); - } - }); - } } RecaptchaStage.TYPE = "m.login.recaptcha"; @@ -158,7 +116,11 @@ class EmailIdentityStage extends Stage { return this._completeVerify(); } - this.clientSecret = this.client.generateClientSecret(); + this.clientSecret = this.signupInstance.params.clientSecret; + if (!this.clientSecret) { + return q.reject(new Error("No client secret specified by Signup class!")); + } + var nextLink = this.signupInstance.params.registrationUrl + '?client_secret=' + encodeURIComponent(this.clientSecret) + diff --git a/src/component-index.js b/src/component-index.js index 8454389e93..fd89a196fb 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -77,6 +77,8 @@ import views$dialogs$EncryptedEventDialog from './components/views/dialogs/Encry module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog; import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog'; module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog; +import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog'; +module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog; import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt'; module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt; import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog'; @@ -117,6 +119,8 @@ import views$login$CasLogin from './components/views/login/CasLogin'; module.exports.components['views.login.CasLogin'] = views$login$CasLogin; import views$login$CustomServerDialog from './components/views/login/CustomServerDialog'; module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog; +import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents'; +module.exports.components['views.login.InteractiveAuthEntryComponents'] = views$login$InteractiveAuthEntryComponents; import views$login$LoginFooter from './components/views/login/LoginFooter'; module.exports.components['views.login.LoginFooter'] = views$login$LoginFooter; import views$login$LoginHeader from './components/views/login/LoginHeader'; diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 0f1b6d331f..5071a6b4c6 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -28,6 +28,10 @@ var CaptchaForm = require("../../views/login/CaptchaForm"); var MIN_PASSWORD_LENGTH = 6; +/** + * TODO: It would be nice to make use of the InteractiveAuthEntryComponents + * here, rather than inventing our own. + */ module.exports = React.createClass({ displayName: 'Registration', @@ -228,12 +232,9 @@ module.exports = React.createClass({ }); }, - onCaptchaLoaded: function(divIdName) { + onCaptchaResponse: function(response) { this.registerLogic.tellStage("m.login.recaptcha", { - divId: divIdName - }); - this.setState({ - busy: false // requires user input + response: response }); }, @@ -267,8 +268,15 @@ module.exports = React.createClass({ ); break; case "Register.STEP_m.login.recaptcha": + var publicKey; + var serverParams = this.registerLogic.getServerData().params; + if (serverParams && serverParams["m.login.recaptcha"]) { + publicKey = serverParams["m.login.recaptcha"].public_key; + } registerStep = ( - + ); break; default: diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js new file mode 100644 index 0000000000..301bba0486 --- /dev/null +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -0,0 +1,219 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Matrix from 'matrix-js-sdk'; +const InteractiveAuth = Matrix.InteractiveAuth; + +import React from 'react'; + +import sdk from '../../../index'; + +import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents'; + +export default React.createClass({ + displayName: 'InteractiveAuthDialog', + + propTypes: { + // response from initial request. If not supplied, will do a request on + // mount. + authData: React.PropTypes.shape({ + flows: React.PropTypes.array, + params: React.PropTypes.object, + session: React.PropTypes.string, + }), + + // callback + makeRequest: React.PropTypes.func.isRequired, + + onFinished: React.PropTypes.func.isRequired, + + title: React.PropTypes.string, + submitButtonLabel: React.PropTypes.string, + }, + + getDefaultProps: function() { + return { + title: "Authentication", + submitButtonLabel: "Submit", + }; + }, + + getInitialState: function() { + return { + authStage: null, + busy: false, + errorText: null, + stageErrorText: null, + submitButtonEnabled: false, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this._authLogic = new InteractiveAuth({ + authData: this.props.authData, + doRequest: this._requestCallback, + startAuthStage: this._startAuthStage, + }); + + this._authLogic.attemptAuth().then((result) => { + this.props.onFinished(true, result); + }).catch((error) => { + console.error("Error during user-interactive auth:", error); + if (this._unmounted) { + return; + } + + const msg = error.message || error.toString(); + this.setState({ + errorText: msg + }); + }).done(); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _startAuthStage: function(stageType, error) { + this.setState({ + authStage: stageType, + errorText: error ? error.error : null, + }, this._setFocus); + }, + + _requestCallback: function(auth) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + return this.props.makeRequest(auth).finally(() => { + if (this._unmounted) { + return; + } + this.setState({ + busy: false, + }); + }); + }, + + _onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + if (!this.state.busy) { + this._onCancel(); + } + } + else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + if (this.state.submitButtonEnabled && !this.state.busy) { + this._onSubmit(); + } + } + }, + + _onSubmit: function() { + if (this.refs.stageComponent && this.refs.stageComponent.onSubmitClick) { + this.refs.stageComponent.onSubmitClick(); + } + }, + + _setFocus: function() { + if (this.refs.stageComponent && this.refs.stageComponent.focus) { + this.refs.stageComponent.focus(); + } + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + _setSubmitButtonEnabled: function(enabled) { + this.setState({ + submitButtonEnabled: enabled, + }); + }, + + _submitAuthDict: function(authData) { + this._authLogic.submitAuthDict(authData); + }, + + _renderCurrentStage: function() { + const stage = this.state.authStage; + var StageComponent = getEntryComponentForLoginType(stage); + return ( + + ); + }, + + render: function() { + const Loader = sdk.getComponent("elements.Spinner"); + + let error = null; + if (this.state.errorText) { + error = ( +
+ {this.state.errorText} +
+ ); + } + + const submitLabel = this.state.busy ? : this.props.submitButtonLabel; + const submitEnabled = this.state.submitButtonEnabled && !this.state.busy; + + const submitButton = ( + + ); + + const cancelButton = ( + + ); + + return ( +
+
+ {this.props.title} +
+
+

This operation requires additional authentication.

+ {this._renderCurrentStage()} + {error} +
+
+ {submitButton} + {cancelButton} +
+
+ ); + }, +}); diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index fdbe6f1db1..0e5922f464 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -26,28 +26,34 @@ module.exports = React.createClass({ displayName: 'CaptchaForm', propTypes: { - onCaptchaLoaded: React.PropTypes.func.isRequired // called with div id name + sitePublicKey: React.PropTypes.string, + + // called with the captcha response + onCaptchaResponse: React.PropTypes.func, }, getDefaultProps: function() { return { - onCaptchaLoaded: function() { - console.error("Unhandled onCaptchaLoaded"); - } + onCaptchaResponse: () => {}, + }; + }, + + getInitialState: function() { + return { + errorText: null, }; }, componentDidMount: function() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. - var self = this; - if (this.refs.recaptchaContainer) { + if (global.grecaptcha) { + // already loaded + this._onCaptchaLoaded(); + } else { console.log("Loading recaptcha script..."); var scriptTag = document.createElement('script'); - window.mx_on_recaptcha_loaded = function() { - console.log("Loaded recaptcha script."); - self.props.onCaptchaLoaded(DIV_ID); - }; + window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; scriptTag.setAttribute( 'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" ); @@ -55,13 +61,54 @@ module.exports = React.createClass({ } }, + _renderRecaptcha: function(divId) { + if (!global.grecaptcha) { + console.error("grecaptcha not loaded!"); + throw new Error("Recaptcha did not load successfully"); + } + + var publicKey = this.props.sitePublicKey; + if (!publicKey) { + console.error("No public key for recaptcha!"); + throw new Error( + "This server has not supplied enough information for Recaptcha " + + "authentication"); + } + + console.log("Rendering to %s", divId); + global.grecaptcha.render(divId, { + sitekey: publicKey, + callback: this.props.onCaptchaResponse, + }); + }, + + _onCaptchaLoaded: function() { + console.log("Loaded recaptcha script."); + try { + this._renderRecaptcha(DIV_ID); + } catch (e) { + this.setState({ + errorText: e.toString(), + }) + } + }, + render: function() { - // FIXME: Tight coupling with the div id and SignupStages.js + let error = null; + if (this.state.errorText) { + error = ( +
+ {this.state.errorText} +
+ ); + } + return (
This Home Server would like to make sure you are not a robot
+ {error}
); } -}); \ No newline at end of file +}); diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js new file mode 100644 index 0000000000..23e2b442ef --- /dev/null +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -0,0 +1,212 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; + +/* This file contains a collection of components which are used by the + * InteractiveAuthDialog to prompt the user to enter the information needed + * for an auth stage. (The intention is that they could also be used for other + * components, such as the registration flow). + * + * Call getEntryComponentForLoginType() to get a component suitable for a + * particular login type. Each component requires the same properties: + * + * loginType: the login type of the auth stage being attempted + * authSessionId: session id from the server + * stageParams: params from the server for the stage being attempted + * errorText: error message from a previous attempt to authenticate + * submitAuthDict: a function which will be called with the new auth dict + * setSubmitButtonEnabled: a function which will enable/disable the 'submit' button + * + * Each component may also provide the following functions (beyond the standard React ones): + * onSubmitClick: handle a 'submit' button click + * focus: set the input focus appropriately in the form. + */ + +export const PasswordAuthEntry = React.createClass({ + displayName: 'PasswordAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.password", + }, + + propTypes: { + submitAuthDict: React.PropTypes.func.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + this.props.setSubmitButtonEnabled(false); + }, + + focus: function() { + if (this.refs.passwordField) { + this.refs.passwordField.focus(); + } + }, + + onSubmitClick: function() { + this.props.submitAuthDict({ + type: PasswordAuthEntry.LOGIN_TYPE, + user: MatrixClientPeg.get().credentials.userId, + password: this.refs.passwordField.value, + }); + }, + + _onPasswordFieldChange: function (ev) { + // enable the submit button iff the password is non-empty + this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); + }, + + render: function() { + let passwordBoxClass = null; + + if (this.props.errorText) { + passwordBoxClass = 'error'; + } + + return ( +
+

To continue, please enter your password.

+

Password:

+ +
+ {this.props.errorText} +
+
+ ); + }, +}); + +export const RecaptchaAuthEntry = React.createClass({ + displayName: 'RecaptchaAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.recaptcha", + }, + + propTypes: { + submitAuthDict: React.PropTypes.func.isRequired, + stageParams: React.PropTypes.object.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + this.props.setSubmitButtonEnabled(false); + }, + + _onCaptchaResponse: function(response) { + this.props.submitAuthDict({ + type: RecaptchaAuthEntry.LOGIN_TYPE, + response: response, + }); + }, + + render: function() { + const CaptchaForm = sdk.getComponent("views.login.CaptchaForm"); + var sitePublicKey = this.props.stageParams.public_key; + return ( +
+ +
+ {this.props.errorText} +
+
+ ); + }, +}); + +export const FallbackAuthEntry = React.createClass({ + displayName: 'FallbackAuthEntry', + + propTypes: { + authSessionId: React.PropTypes.string.isRequired, + loginType: React.PropTypes.string.isRequired, + submitAuthDict: React.PropTypes.func.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + // we have to make the user click a button, as browsers will block + // the popup if we open it immediately. + this._popupWindow = null; + this.props.setSubmitButtonEnabled(true); + window.addEventListener("message", this._onReceiveMessage); + }, + + componentWillUnmount: function() { + window.removeEventListener("message", this._onReceiveMessage); + if (this._popupWindow) { + this._popupWindow.close(); + } + }, + + onSubmitClick: function() { + var url = MatrixClientPeg.get().getFallbackAuthUrl( + this.props.loginType, + this.props.authSessionId + ); + this._popupWindow = window.open(url); + this.props.setSubmitButtonEnabled(false); + }, + + _onReceiveMessage: function(event) { + if ( + event.data === "authDone" && + event.origin === MatrixClientPeg.get().getHomeserverUrl() + ) { + this.props.submitAuthDict({}); + } + }, + + render: function() { + return ( +
+ Click "Submit" to authenticate +
+ {this.props.errorText} +
+
+ ); + }, +}); + +const AuthEntryComponents = [ + PasswordAuthEntry, + RecaptchaAuthEntry, +]; + +export function getEntryComponentForLoginType(loginType) { + for (var c of AuthEntryComponents) { + if (c.LOGIN_TYPE == loginType) { + return c; + } + } + return FallbackAuthEntry; +}; diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index 16345edcce..f9f0e49a5e 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -21,6 +21,7 @@ import q from 'q'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import DateUtils from '../../../DateUtils'; +import Modal from '../../../Modal'; export default class DevicesPanelEntry extends React.Component { constructor(props, context) { @@ -35,6 +36,7 @@ export default class DevicesPanelEntry extends React.Component { this._onDeleteClick = this._onDeleteClick.bind(this); this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this); + this._makeDeleteRequest = this._makeDeleteRequest.bind(this); } componentWillUnmount() { @@ -52,22 +54,44 @@ export default class DevicesPanelEntry extends React.Component { } _onDeleteClick() { - const device = this.props.device; this.setState({deleting: true}); - MatrixClientPeg.get().deleteDevice(device.device_id).done( + // try without interactive auth to start off + this._makeDeleteRequest(null).catch((error) => { + if (this._unmounted) { return; } + if (error.httpStatus !== 401 || !error.data || !error.data.flows) { + // doesn't look like an interactive-auth failure + throw e; + } + + // pop up an interactive auth dialog + var InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + + Modal.createDialog(InteractiveAuthDialog, { + authData: error.data, + makeRequest: this._makeDeleteRequest, + }); + + this.setState({ + deleting: false, + }); + }).catch((e) => { + console.error("Error deleting device", e); + if (this._unmounted) { return; } + this.setState({ + deleting: false, + deleteError: "Failed to delete device", + }); + }).done(); + } + + _makeDeleteRequest(auth) { + const device = this.props.device; + return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then( () => { this.props.onDeleted(); if (this._unmounted) { return; } this.setState({ deleting: false }); - }, - (e) => { - console.error("Error deleting device", e); - if (this._unmounted) { return; } - this.setState({ - deleting: false, - deleteError: "Failed to delete device", - }); } ); } diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js new file mode 100644 index 0000000000..35daace0f8 --- /dev/null +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -0,0 +1,108 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import expect from 'expect'; +import q from 'q'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-addons-test-utils'; +import sinon from 'sinon'; + +import sdk from 'matrix-react-sdk'; +import MatrixClientPeg from 'MatrixClientPeg'; + +import * as test_utils from '../../../test-utils'; + +const InteractiveAuthDialog = sdk.getComponent( + 'views.dialogs.InteractiveAuthDialog' +); + +describe('InteractiveAuthDialog', function () { + var parentDiv; + var sandbox; + + beforeEach(function() { + test_utils.beforeEach(this); + sandbox = test_utils.stubClient(sandbox); + parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + }); + + afterEach(function() { + ReactDOM.unmountComponentAtNode(parentDiv); + parentDiv.remove(); + sandbox.restore(); + }); + + it('Should successfully complete a password flow', function(done) { + const onFinished = sinon.spy(); + const doRequest = sinon.stub().returns(q({a:1})); + + // tell the stub matrixclient to return a real userid + var client = MatrixClientPeg.get(); + client.credentials = {userId: "@user:id"}; + + const dlg = ReactDOM.render( + , parentDiv); + + // at this point there should be a password box + const passwordNode = ReactTestUtils.findRenderedDOMComponentWithTag( + dlg, "input" + ); + expect(passwordNode.type).toEqual("password"); + + // submit should be disabled + const submitNode = ReactTestUtils.findRenderedDOMComponentWithClass( + dlg, "mx_Dialog_primary" + ); + expect(submitNode.disabled).toBe(true); + + // put something in the password box, and hit enter; that should + // trigger a request + passwordNode.value = "s3kr3t"; + ReactTestUtils.Simulate.change(passwordNode); + expect(submitNode.disabled).toBe(false); + ReactTestUtils.Simulate.keyDown(passwordNode, { + key: "Enter", keyCode: 13, which: 13, + }); + + expect(doRequest.callCount).toEqual(1); + expect(doRequest.calledWithExactly({ + session: "sess", + type: "m.login.password", + password: "s3kr3t", + user: "@user:id", + })).toBe(true); + + // the submit button should now be disabled (and be a spinner) + expect(submitNode.disabled).toBe(true); + + // let the request complete + q.delay(1).then(() => { + expect(onFinished.callCount).toEqual(1); + expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); + }).done(done, done); + }); +});