const useSecureEnclave = require( 'ext.confirmEdit.hCaptcha/ext.confirmEdit.hCaptcha/secureEnclave.js' ); const config = require( 'ext.confirmEdit.hCaptcha/ext.confirmEdit.hCaptcha/config.json' ); QUnit.module( 'ext.confirmEdit.hCaptcha.secureEnclave', QUnit.newMwEnvironment( { beforeEach() { mw.config.set( 'wgDBname', 'testwiki' ); this.track = this.sandbox.stub( mw, 'track' ); this.logError = this.sandbox.stub( mw.errorLogger, 'logError' ); // Sinon fake timers as of v21 only return a static fake value from performance.measure(), // so use a regular stub instead. this.measure = this.sandbox.stub( performance, 'measure' ); this.measure.returns( { duration: 0 } ); // We do not want to add real script elements to the page or interact with the real // hcaptcha, so stub the code that does this for this test this.window = { hcaptcha: { render: this.sandbox.stub(), execute: this.sandbox.stub() }, document: { head: { appendChild: this.sandbox.stub() } } }; const form = document.createElement( 'form' ); this.submit = this.sandbox.stub( form, 'submit' ); this.$form = $( form ) .append( '' ) .append( '' ) .append( '' ); this.$form.appendTo( $( '#qunit-fixture' ) ); this.isLoadingIndicatorVisible = () => this.$form .find( '.ext-confirmEdit-hCaptchaLoadingIndicator' ) .css( 'display' ) !== 'none'; this.origUrl = config.HCaptchaApiUrl; this.origIntegrityHash = config.HCaptchaApiUrlIntegrityHash; config.HCaptchaApiUrl = 'https://example.com/hcaptcha.js'; config.HCaptchaApiUrlIntegrityHash = '1234abcef'; }, afterEach() { this.track.restore(); this.measure.restore(); this.logError.restore(); config.HCaptchaApiUrl = this.origUrl; config.HCaptchaApiUrlIntegrityHash = this.origIntegrityHash; } } ) ); QUnit.test( 'should not load hCaptcha before the form has been interacted with', async function ( assert ) { useSecureEnclave( this.window ); assert.true( this.window.document.head.appendChild.notCalled, 'should not load hCaptcha SDK' ); assert.true( this.window.hcaptcha.render.notCalled, 'should not render hCaptcha' ); assert.true( this.window.hcaptcha.execute.notCalled, 'should not execute hCaptcha' ); assert.true( this.track.notCalled, 'should not emit hCaptcha performance events' ); } ); QUnit.test.each( 'should load hCaptcha exactly once when the form is interacted with', { 'interaction with input element': { fieldName: 'some-textarea' }, 'interaction with textarea element': { fieldName: 'some-textarea' } }, async function ( assert, data ) { this.window.document.head.appendChild.callsFake( async () => { this.window.onHCaptchaSDKLoaded(); } ); useSecureEnclave( this.window ); const $field = this.$form.find( '[name=' + data.fieldName + ']' ); $field.trigger( 'focus' ); $field.trigger( 'input' ); $field.trigger( 'input' ); // Wait one tick for event handlers to run. await new Promise( ( resolve ) => { setTimeout( resolve ); } ); assert.true( this.window.document.head.appendChild.calledOnce, 'should load hCaptcha SDK once' ); assert.true( this.window.hcaptcha.render.calledOnce, 'should render hCaptcha widget once' ); assert.deepEqual( this.window.hcaptcha.render.firstCall.args[ 0 ], 'h-captcha', 'should render hCaptcha widget in correct element' ); assert.true( this.window.hcaptcha.execute.notCalled, 'should not execute hCaptcha before the form is submitted' ); } ); QUnit.test( 'should load hCaptcha on form submissions triggered before hCaptcha was setup', async function ( assert ) { this.window.document.head.appendChild.callsFake( async () => { this.window.onHCaptchaSDKLoaded(); } ); useSecureEnclave( this.window ); this.$form.trigger( 'submit' ); // Wait one tick for event handlers to run. await new Promise( ( resolve ) => { setTimeout( resolve ); } ); assert.true( this.window.document.head.appendChild.calledOnce, 'should load hCaptcha SDK once' ); assert.true( this.submit.notCalled, 'form submission should have been prevented' ); assert.true( this.window.hcaptcha.render.calledOnce, 'should render hCaptcha widget once' ); assert.deepEqual( this.window.hcaptcha.render.firstCall.args[ 0 ], 'h-captcha', 'should render hCaptcha widget in correct element' ); assert.true( this.window.hcaptcha.execute.notCalled, 'should not execute hCaptcha before the form is submitted' ); } ); QUnit.test( 'should intercept form submissions', function ( assert ) { this.window.document.head.appendChild.callsFake( async () => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute.callsFake( async () => { assert.true( this.isLoadingIndicatorVisible(), 'loading indicator should be visible during execute' ); return { response: 'some-token' }; } ); const result = useSecureEnclave( this.window ) .then( () => { assert.true( this.window.document.head.appendChild.calledOnce, 'should load hCaptcha SDK once' ); const actualScriptElement = this.window.document.head.appendChild.firstCall.args[ 0 ]; assert.deepEqual( actualScriptElement.src, 'https://example.com/hcaptcha.js?onload=onHCaptchaSDKLoaded', 'should load hCaptcha SDK from given URL' ); assert.deepEqual( actualScriptElement.integrity, '1234abcef', 'should load hCaptcha SDK from given URL' ); assert.false( this.isLoadingIndicatorVisible(), 'should hide loading indicator' ); assert.true( this.window.hcaptcha.render.calledOnce, 'should render hCaptcha widget once' ); assert.deepEqual( this.window.hcaptcha.render.firstCall.args[ 0 ], 'h-captcha', 'should render hCaptcha widget in correct element' ); assert.true( this.window.hcaptcha.execute.calledOnce, 'should run hCaptcha once' ); assert.deepEqual( this.window.hcaptcha.execute.firstCall.args, [ 'some-captcha-id', { async: true } ], 'should invoke hCaptcha with correct ID' ); assert.true( this.submit.calledOnce, 'should submit form once hCaptcha token is available' ); assert.strictEqual( this.$form.find( '#h-captcha-response' ).val(), 'some-token', 'should add hCaptcha response token to form' ); assert.strictEqual( this.$form.find( '.cdx-message' ).css( 'display' ), 'none', 'no error message should be shown' ); assert.strictEqual( this.$form.find( '.cdx-message' ).text(), '', 'no error message should be set' ); } ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.trigger( 'submit' ); return result; } ); QUnit.test( 'should measure hCaptcha load and execute timing for successful submission', function ( assert ) { mw.config.set( 'wgCanonicalSpecialPageName', 'CreateAccount' ); this.measure .onFirstCall().returns( { duration: 1718 } ) .onSecondCall().returns( { duration: 2314 } ); this.window.document.head.appendChild.callsFake( async () => { this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute.callsFake( async () => ( { response: 'some-token' } ) ); const result = useSecureEnclave( this.window ) .then( () => { assert.strictEqual( this.track.callCount, 8, 'should invoke mw.track() eight times' ); assert.deepEqual( this.track.getCall( 0 ).args, [ 'specialCreateAccount.performanceTiming', 'hcaptcha-load', 1.718 ], 'should emit event for load time' ); assert.deepEqual( this.track.getCall( 1 ).args, [ 'stats.mediawiki_special_createaccount_hcaptcha_load_duration_seconds', 1718, { wiki: 'testwiki' } ], 'should record account creation specific metric for load time' ); assert.deepEqual( this.track.getCall( 2 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_load_duration_seconds', 1718, { wiki: 'testwiki', interfaceName: 'createaccount' } ], 'should record metric for load time' ); assert.deepEqual( this.track.getCall( 3 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_execute_total', 1, { wiki: 'testwiki', interfaceName: 'createaccount' } ], 'should record event for execute' ); assert.deepEqual( this.track.getCall( 4 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_form_submit_total', 1, { wiki: 'testwiki', interfaceName: 'createaccount' } ], 'should record event for form submission' ); assert.deepEqual( this.track.getCall( 5 ).args, [ 'specialCreateAccount.performanceTiming', 'hcaptcha-execute', 2.314 ], 'should emit event for execution time' ); assert.deepEqual( this.track.getCall( 6 ).args, [ 'stats.mediawiki_special_createaccount_hcaptcha_execute_duration_seconds', 2314, { wiki: 'testwiki' } ], 'should record account creation specific metric for execution time' ); assert.deepEqual( this.track.getCall( 7 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_execute_duration_seconds', 2314, { wiki: 'testwiki', interfaceName: 'createaccount' } ], 'should record metric for execution time' ); } ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.trigger( 'submit' ); return result; } ); QUnit.test( 'should surface load errors as soon as possible', async function ( assert ) { mw.config.set( 'wgAction', 'edit' ); this.window.document.head.appendChild.callsFake( ( script ) => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); script.onerror(); } ); this.measure.onFirstCall().returns( { duration: 1718 } ); const hCaptchaResult = useSecureEnclave( this.window ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); await hCaptchaResult; assert.notStrictEqual( this.$form.find( '.cdx-message' ).css( 'display' ), 'none', 'error message container should be visible' ); assert.strictEqual( this.$form.find( '.cdx-message' ).text(), '(hcaptcha-generic-error)', 'load error message should be set' ); assert.strictEqual( this.track.callCount, 2, 'should invoke mw.track() two times' ); assert.deepEqual( this.track.getCall( 0 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_load_duration_seconds', 1718, { wiki: 'testwiki', interfaceName: 'edit' } ], 'should record metric for load time' ); assert.deepEqual( this.track.getCall( 1 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_script_error_total', 1, { wiki: 'testwiki', interfaceName: 'edit' } ], 'should emit event for load failure' ); assert.strictEqual( this.logError.callCount, 1, 'should invoke mw.errorLogger.logError() once' ); const logErrorArguments = this.logError.getCall( 0 ).args; assert.deepEqual( logErrorArguments[ 0 ].message, 'Unable to load hCaptcha script', 'should use correct channel for errors' ); assert.deepEqual( logErrorArguments[ 1 ], 'error.confirmedit', 'should use correct channel for errors' ); } ); QUnit.test( 'should surface irrecoverable workflow execution errors as soon as possible', async function ( assert ) { // Explicitly set an unknown value here to test the unknown interface handling mw.config.set( 'wgAction', 'unknown' ); this.window.document.head.appendChild.callsFake( async () => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute.callsFake( () => { assert.true( this.isLoadingIndicatorVisible(), 'loading indicator should be visible until hCaptcha finishes' ); return Promise.reject( 'generic-error' ); } ); this.measure .onFirstCall().returns( { duration: 1718 } ) .onSecondCall().returns( { duration: 2314 } ); const hCaptchaResult = useSecureEnclave( this.window ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.trigger( 'submit' ); await hCaptchaResult; assert.false( this.isLoadingIndicatorVisible(), 'should hide loading indicator' ); assert.notStrictEqual( this.$form.find( '.cdx-message' ).css( 'display' ), 'none', 'error message container should be visible' ); assert.strictEqual( this.$form.find( '.cdx-message' ).text(), '(hcaptcha-generic-error)', 'error message should be set' ); assert.strictEqual( this.track.callCount, 4, 'should invoke mw.track() three times' ); assert.deepEqual( this.track.getCall( 0 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_load_duration_seconds', 1718, { wiki: 'testwiki', interfaceName: 'unknown' } ], 'should record metric for load time' ); assert.deepEqual( this.track.getCall( 1 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_execute_total', 1, { wiki: 'testwiki', interfaceName: 'unknown' } ], 'should emit event for execution' ); assert.deepEqual( this.track.getCall( 2 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_execute_duration_seconds', 2314, { wiki: 'testwiki', interfaceName: 'unknown' } ], 'should record metric for load time' ); assert.deepEqual( this.track.getCall( 3 ).args, [ 'stats.mediawiki_confirmedit_hcaptcha_execute_workflow_error_total', 1, { wiki: 'testwiki', interfaceName: 'unknown', code: 'generic_error' } ], 'should emit event for execution failure' ); } ); QUnit.test( 'should surface recoverable workflow execution errors on submit', function ( assert ) { this.window.document.head.appendChild.callsFake( async () => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute.callsFake( () => { assert.true( this.isLoadingIndicatorVisible(), 'loading indicator should be visible during execute' ); return Promise.reject( 'challenge-closed' ); } ); useSecureEnclave( this.window ); const formSubmitted = new Promise( ( resolve ) => { this.$form.one( 'submit', () => setTimeout( resolve ) ); } ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.trigger( 'submit' ); return formSubmitted.then( () => { assert.false( this.isLoadingIndicatorVisible(), 'should hide loading indicator' ); assert.true( this.submit.notCalled, 'submit should have been prevented' ); assert.notStrictEqual( this.$form.find( '.cdx-message' ).css( 'display' ), 'none', 'error message container should be visible' ); assert.strictEqual( this.$form.find( '.cdx-message' ).text(), '(hcaptcha-challenge-closed)', 'error message should be set' ); } ); } ); QUnit.test( 'should allow recovering from a recoverable error by starting a new workflow', function ( assert ) { this.window.document.head.appendChild.callsFake( async () => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute .onFirstCall().returns( Promise.reject( 'challenge-closed' ) ) .onSecondCall().resolves( { response: 'some-token' } ); const result = useSecureEnclave( this.window ) .then( () => { assert.false( this.isLoadingIndicatorVisible(), 'should hide loading indicator' ); assert.true( this.window.hcaptcha.render.calledOnce, 'should render hCaptcha widget once' ); assert.deepEqual( this.window.hcaptcha.render.firstCall.args[ 0 ], 'h-captcha', 'should render hCaptcha widget in correct element' ); assert.true( this.window.hcaptcha.execute.calledTwice, 'should run hCaptcha twice' ); assert.deepEqual( this.window.hcaptcha.execute.firstCall.args, [ 'some-captcha-id', { async: true } ], 'should invoke hCaptcha with correct ID' ); assert.deepEqual( this.window.hcaptcha.execute.secondCall.args, [ 'some-captcha-id', { async: true } ], 'should invoke hCaptcha with correct ID' ); assert.true( this.submit.calledOnce, 'submit should have eventually succeeded' ); assert.strictEqual( this.$form.find( '#h-captcha-response' ).val(), 'some-token', 'should add hCaptcha response token to form' ); assert.strictEqual( this.$form.find( '.cdx-message' ).css( 'display' ), 'none', 'no error message should be shown' ); } ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.one( 'submit', () => setTimeout( () => this.$form.trigger( 'submit' ) ) ); this.$form.trigger( 'submit' ); return result; } ); QUnit.test( 'should fire the confirmEdit.hCaptcha.executed hook when executeHCaptcha succeeds', async function ( assert ) { this.window.document.head.appendChild.callsFake( async () => { assert.false( this.isLoadingIndicatorVisible(), 'should not show loading indicator prior to execute' ); this.window.onHCaptchaSDKLoaded(); } ); this.window.hcaptcha.render.returns( 'some-captcha-id' ); this.window.hcaptcha.execute.callsFake( async () => ( { response: 'some-token' } ) ); const hook = mw.hook( 'confirmEdit.hCaptcha.executionSuccess' ); const spy = this.sandbox.spy( hook, 'fire' ); // The promise returned by useSecureEnclave() won't resolve // until the form is submitted. const result = useSecureEnclave( this.window ); this.$form.find( '[name=some-input]' ).trigger( 'input' ); this.$form.trigger( 'submit' ); await result; assert.true( spy.calledOnce, 'Hook was fired once' ); assert.deepEqual( spy.firstCall.args[ 0 ], 'some-token', 'Hook was fired with expected arguments' ); // Clean up spy to avoid affecting later tests spy.restore(); } );