Commit 07be6a6f authored by Stan Hu's avatar Stan Hu

Merge branch 'arkose-submit-caveat' into 'master'

Trigger ArkoseLabs username check on submit

See merge request gitlab-org/gitlab!84078
parents 172af7fa 8c5d70e9
...@@ -51,29 +51,24 @@ export default { ...@@ -51,29 +51,24 @@ export default {
username: '', username: '',
isLoading: false, isLoading: false,
arkoseInitialized: false, arkoseInitialized: false,
submitOnSuppress: false,
arkoseToken: '', arkoseToken: '',
arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS), arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
arkoseChallengePassed: false, arkoseChallengePassed: false,
}; };
}, },
computed: { computed: {
isVisible() {
return this.arkoseLabsIframeShown || this.showErrorContainer;
},
showErrorContainer() { showErrorContainer() {
return this.showArkoseNeededError || this.showArkoseFailure; return (this.arkoseLabsIframeShown && this.showArkoseNeededError) || this.showArkoseFailure;
}, },
}, },
watch: { watch: {
username() {
this.checkIfNeedsChallenge();
},
isLoading(val) { isLoading(val) {
this.updateSubmitButtonLoading(val); this.updateSubmitButtonLoading(val);
}, },
}, },
mounted() { mounted() {
this.username = this.getUsernameValue(); this.checkIfNeedsChallenge();
}, },
methods: { methods: {
onArkoseLabsIframeShown() { onArkoseLabsIframeShown() {
...@@ -86,28 +81,45 @@ export default { ...@@ -86,28 +81,45 @@ export default {
getUsernameValue() { getUsernameValue() {
return document.querySelector(this.usernameSelector)?.value || ''; return document.querySelector(this.usernameSelector)?.value || '';
}, },
onUsernameBlur() {
this.username = this.getUsernameValue();
},
onSubmit(e) { onSubmit(e) {
if (!this.arkoseInitialized || this.arkoseChallengePassed) { if (this.arkoseChallengePassed) {
// If the challenge was solved already, proceed with the form's submission.
return; return;
} }
e.preventDefault(); e.preventDefault();
this.showArkoseNeededError = true; this.submitOnSuppress = true;
if (!this.arkoseInitialized) {
// If the challenge hasn't been initialized yet, we trigger a check now to make sure it
// wasn't skipped by submitting the form without the username field ever losing the focus.
this.checkAndSubmit(e.target);
} else {
// Otherwise, we show an error message as the form has been submitted without completing
// the challenge.
this.showArkoseNeededError = true;
}
},
async checkAndSubmit(form) {
await this.checkIfNeedsChallenge();
if (!this.arkoseInitialized) {
// If the challenge still hasn't been initialized, the user definitely doesn't need one and
// we can proceed with the form's submission.
form.submit();
}
}, },
async checkIfNeedsChallenge() { async checkIfNeedsChallenge() {
if (!this.username || this.arkoseInitialized) { const username = this.getUsernameValue();
if (!username || username === this.username || this.arkoseInitialized) {
return; return;
} }
this.username = username;
this.isLoading = true; this.isLoading = true;
try { try {
const { const {
data: { result }, data: { result },
} = await needsArkoseLabsChallenge(this.username); } = await needsArkoseLabsChallenge(this.username);
if (result) { if (result) {
await this.initArkoseLabs(); await this.initArkoseLabs();
} }
...@@ -136,6 +148,7 @@ export default { ...@@ -136,6 +148,7 @@ export default {
selector: `.${this.arkoseContainerClass}`, selector: `.${this.arkoseContainerClass}`,
onShown: this.onArkoseLabsIframeShown, onShown: this.onArkoseLabsIframeShown,
onCompleted: this.passArkoseLabsChallenge, onCompleted: this.passArkoseLabsChallenge,
onSuppress: this.onArkoseLabsSuppress,
onError: this.handleArkoseLabsFailure, onError: this.handleArkoseLabsFailure,
}); });
}, },
...@@ -144,6 +157,13 @@ export default { ...@@ -144,6 +157,13 @@ export default {
this.arkoseToken = response.token; this.arkoseToken = response.token;
this.hideErrors(); this.hideErrors();
}, },
onArkoseLabsSuppress() {
if (this.submitOnSuppress) {
// If the challenge was suppressed following the form's submission, we need to proceed with
// the submission.
document.querySelector(this.formSelector).submit();
}
},
handleArkoseLabsFailure(e) { handleArkoseLabsFailure(e) {
logError('ArkoseLabs initialization error', e); logError('ArkoseLabs initialization error', e);
this.showArkoseFailure = true; this.showArkoseFailure = true;
...@@ -172,16 +192,17 @@ export default { ...@@ -172,16 +192,17 @@ export default {
</script> </script>
<template> <template>
<div v-show="isVisible"> <div>
<dom-element-listener :selector="usernameSelector" @blur="checkIfNeedsChallenge" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<input <input
v-if="arkoseInitialized" v-if="arkoseInitialized"
:name="$options.VERIFICATION_TOKEN_INPUT_NAME" :name="$options.VERIFICATION_TOKEN_INPUT_NAME"
type="hidden" type="hidden"
:value="arkoseToken" :value="arkoseToken"
/> />
<dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<div <div
v-show="arkoseLabsIframeShown"
class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3" class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
:class="arkoseContainerClass" :class="arkoseContainerClass"
data-testid="arkose-labs-challenge" data-testid="arkose-labs-challenge"
......
...@@ -4,6 +4,7 @@ module Arkose ...@@ -4,6 +4,7 @@ module Arkose
attr_reader :url, :session_token, :userid attr_reader :url, :session_token, :userid
VERIFY_URL = 'http://verify-api.arkoselabs.com/api/v4/verify' VERIFY_URL = 'http://verify-api.arkoselabs.com/api/v4/verify'
ALLOWLIST_TELLTALE = 'gitlab1-whitelist-qa-team'
def initialize(session_token:, userid:) def initialize(session_token:, userid:)
@session_token = session_token @session_token = session_token
...@@ -16,7 +17,7 @@ module Arkose ...@@ -16,7 +17,7 @@ module Arkose
return false if invalid_token(response) return false if invalid_token(response)
challenge_solved?(response) && low_risk?(response) allowlisted?(response) || (challenge_solved?(response) && low_risk?(response))
rescue StandardError => error rescue StandardError => error
payload = { session_token: session_token, log_data: userid } payload = { session_token: session_token, log_data: userid }
Gitlab::ExceptionLogFormatter.format!(error, payload) Gitlab::ExceptionLogFormatter.format!(error, payload)
...@@ -57,5 +58,10 @@ module Arkose ...@@ -57,5 +58,10 @@ module Arkose
risk_band = response.parsed_response&.dig('session_risk', 'risk_band') risk_band = response.parsed_response&.dig('session_risk', 'risk_band')
risk_band.present? ? risk_band != 'High' : true risk_band.present? ? risk_band != 'High' : true
end end
def allowlisted?(response)
telltale_list = response.parsed_response&.dig('session_details', 'telltale_list') || []
telltale_list.include?(ALLOWLIST_TELLTALE)
end
end end
end end
...@@ -12,11 +12,18 @@ jest.mock('~/lib/logger'); ...@@ -12,11 +12,18 @@ jest.mock('~/lib/logger');
jest.mock('ee/arkose_labs/init_arkose_labs_script'); jest.mock('ee/arkose_labs/init_arkose_labs_script');
let onShown; let onShown;
let onCompleted; let onCompleted;
let onSuppress;
let onError; let onError;
initArkoseLabsScript.mockImplementation(() => ({ initArkoseLabsScript.mockImplementation(() => ({
setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => { setConfig: ({
onShown: shownHandler,
onCompleted: completedHandler,
onSuppress: suppressHandler,
onError: errorHandler,
}) => {
onShown = shownHandler; onShown = shownHandler;
onCompleted = completedHandler; onCompleted = completedHandler;
onSuppress = suppressHandler;
onError = errorHandler; onError = errorHandler;
}, },
})); }));
...@@ -34,6 +41,7 @@ describe('SignInArkoseApp', () => { ...@@ -34,6 +41,7 @@ describe('SignInArkoseApp', () => {
const findSignInForm = () => findByTestId('sign-in-form'); const findSignInForm = () => findByTestId('sign-in-form');
const findUsernameInput = () => findByTestId('username-field'); const findUsernameInput = () => findByTestId('username-field');
const findSignInButton = () => findByTestId('sign-in-button'); const findSignInButton = () => findByTestId('sign-in-button');
const findChallengeContainer = () => wrapper.findByTestId('arkose-labs-challenge');
const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message'); const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message');
const findArkoseLabsVerificationTokenInput = () => const findArkoseLabsVerificationTokenInput = () =>
wrapper.find('input[name="arkose_labs_token"]'); wrapper.find('input[name="arkose_labs_token"]');
...@@ -91,6 +99,7 @@ describe('SignInArkoseApp', () => { ...@@ -91,6 +99,7 @@ describe('SignInArkoseApp', () => {
afterEach(() => { afterEach(() => {
axiosMock.restore(); axiosMock.restore();
wrapper?.destroy(); wrapper?.destroy();
document.body.innerHTML = '';
}); });
describe('when the username field is pre-filled', () => { describe('when the username field is pre-filled', () => {
...@@ -134,7 +143,7 @@ describe('SignInArkoseApp', () => { ...@@ -134,7 +143,7 @@ describe('SignInArkoseApp', () => {
it('does not show ArkoseLabs error when submitting the form', async () => { it('does not show ArkoseLabs error when submitting the form', async () => {
submitForm(); submitForm();
await nextTick(); await waitForPromises();
expect(findArkoseLabsErrorMessage().exists()).toBe(false); expect(findArkoseLabsErrorMessage().exists()).toBe(false);
}); });
...@@ -150,6 +159,49 @@ describe('SignInArkoseApp', () => { ...@@ -150,6 +159,49 @@ describe('SignInArkoseApp', () => {
}); });
}); });
describe('when the form is submitted without the username field losing the focus', () => {
beforeEach(() => {
initArkoseLabs();
jest.spyOn(findSignInForm(), 'submit');
axiosMock.onGet().reply(200, { result: false });
findUsernameInput().value = `noblur-${MOCK_USERNAME}`;
});
it('triggers a username check', async () => {
expect(axiosMock.history.get).toHaveLength(0);
submitForm();
await waitForPromises();
expect(axiosMock.history.get).toHaveLength(1);
});
it("proceeds with the form's submission if the challenge still isn't needed", async () => {
submitForm();
await waitForPromises();
expect(findSignInForm().submit).toHaveBeenCalled();
});
describe('when the challenge becomes needed', () => {
beforeEach(() => {
axiosMock.onGet().reply(200, { result: true });
submitForm();
return waitForPromises();
});
it("blocks the form's submission if the challenge becomes needed", async () => {
expect(findSignInForm().submit).not.toHaveBeenCalled();
});
it("proceeds with the form's submission if the challenge is being suppressed", async () => {
onSuppress();
expect(findSignInForm().submit).toHaveBeenCalled();
});
});
});
describe('if the challenge is needed', () => { describe('if the challenge is needed', () => {
beforeEach(async () => { beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true }); axiosMock.onGet().reply(200, { result: true });
...@@ -160,6 +212,7 @@ describe('SignInArkoseApp', () => { ...@@ -160,6 +212,7 @@ describe('SignInArkoseApp', () => {
itInitializesArkoseLabs(); itInitializesArkoseLabs();
it('shows ArkoseLabs error when submitting the form', async () => { it('shows ArkoseLabs error when submitting the form', async () => {
onShown();
submitForm(); submitForm();
await nextTick(); await nextTick();
...@@ -168,12 +221,12 @@ describe('SignInArkoseApp', () => { ...@@ -168,12 +221,12 @@ describe('SignInArkoseApp', () => {
}); });
it('un-hides the challenge container once the iframe has been shown', async () => { it('un-hides the challenge container once the iframe has been shown', async () => {
expect(wrapper.isVisible()).toBe(false); expect(findChallengeContainer().isVisible()).toBe(false);
onShown(); onShown();
await nextTick(); await nextTick();
expect(wrapper.isVisible()).toBe(true); expect(findChallengeContainer().isVisible()).toBe(true);
}); });
it('shows an error alert if the challenge fails to load', async () => { it('shows an error alert if the challenge fails to load', async () => {
...@@ -189,6 +242,13 @@ describe('SignInArkoseApp', () => { ...@@ -189,6 +242,13 @@ describe('SignInArkoseApp', () => {
expectArkoseLabsInitError(); expectArkoseLabsInitError();
}); });
it('does not submit the form when the challenge is being suppressed', () => {
jest.spyOn(findSignInForm(), 'submit');
onSuppress();
expect(findSignInForm().submit).not.toHaveBeenCalled();
});
describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => { describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => {
const response = { token: 'verification-token' }; const response = { token: 'verification-token' };
......
...@@ -36,6 +36,17 @@ RSpec.describe Arkose::UserVerificationService do ...@@ -36,6 +36,17 @@ RSpec.describe Arkose::UserVerificationService do
allow(Gitlab::HTTP).to receive(:perform_request).and_return(response) allow(Gitlab::HTTP).to receive(:perform_request).and_return(response)
expect(subject).to be_falsey expect(subject).to be_falsey
end end
context 'when the session is allowlisted' do
before do
arkose_ec_response['session_details']['telltale_list'].push(Arkose::UserVerificationService::ALLOWLIST_TELLTALE)
end
it 'returns true' do
allow(Gitlab::HTTP).to receive(:perform_request).and_return(response)
expect(subject).to be_truthy
end
end
end end
end end
end end
......
...@@ -130,6 +130,10 @@ module QA ...@@ -130,6 +130,10 @@ module QA
has_css?(".active", text: 'Standard') has_css?(".active", text: 'Standard')
end end
def has_arkose_labs_token?
has_css?('[name="arkose_labs_token"][value]', visible: false)
end
def switch_to_sign_in_tab def switch_to_sign_in_tab
click_element :sign_in_tab click_element :sign_in_tab
end end
...@@ -174,6 +178,17 @@ module QA ...@@ -174,6 +178,17 @@ module QA
fill_element :login_field, user.username fill_element :login_field, user.username
fill_element :password_field, user.password fill_element :password_field, user.password
if Runtime::Env.running_on_dot_com?
# Arkose only appears in staging.gitlab.com, gitlab.com, etc...
# Wait until the ArkoseLabs challenge has initialized
Support::WaitForRequests.wait_for_requests
Support::Waiter.wait_until(max_duration: 5, reload_page: false, raise_on_failure: false) do
has_arkose_labs_token?
end
end
click_element :sign_in_button click_element :sign_in_button
Support::WaitForRequests.wait_for_requests Support::WaitForRequests.wait_for_requests
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment