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 {
username: '',
isLoading: false,
arkoseInitialized: false,
submitOnSuppress: false,
arkoseToken: '',
arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
arkoseChallengePassed: false,
};
},
computed: {
isVisible() {
return this.arkoseLabsIframeShown || this.showErrorContainer;
},
showErrorContainer() {
return this.showArkoseNeededError || this.showArkoseFailure;
return (this.arkoseLabsIframeShown && this.showArkoseNeededError) || this.showArkoseFailure;
},
},
watch: {
username() {
this.checkIfNeedsChallenge();
},
isLoading(val) {
this.updateSubmitButtonLoading(val);
},
},
mounted() {
this.username = this.getUsernameValue();
this.checkIfNeedsChallenge();
},
methods: {
onArkoseLabsIframeShown() {
......@@ -86,28 +81,45 @@ export default {
getUsernameValue() {
return document.querySelector(this.usernameSelector)?.value || '';
},
onUsernameBlur() {
this.username = this.getUsernameValue();
},
onSubmit(e) {
if (!this.arkoseInitialized || this.arkoseChallengePassed) {
if (this.arkoseChallengePassed) {
// If the challenge was solved already, proceed with the form's submission.
return;
}
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() {
if (!this.username || this.arkoseInitialized) {
const username = this.getUsernameValue();
if (!username || username === this.username || this.arkoseInitialized) {
return;
}
this.username = username;
this.isLoading = true;
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
await this.initArkoseLabs();
}
......@@ -136,6 +148,7 @@ export default {
selector: `.${this.arkoseContainerClass}`,
onShown: this.onArkoseLabsIframeShown,
onCompleted: this.passArkoseLabsChallenge,
onSuppress: this.onArkoseLabsSuppress,
onError: this.handleArkoseLabsFailure,
});
},
......@@ -144,6 +157,13 @@ export default {
this.arkoseToken = response.token;
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) {
logError('ArkoseLabs initialization error', e);
this.showArkoseFailure = true;
......@@ -172,16 +192,17 @@ export default {
</script>
<template>
<div v-show="isVisible">
<div>
<dom-element-listener :selector="usernameSelector" @blur="checkIfNeedsChallenge" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<input
v-if="arkoseInitialized"
:name="$options.VERIFICATION_TOKEN_INPUT_NAME"
type="hidden"
:value="arkoseToken"
/>
<dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<div
v-show="arkoseLabsIframeShown"
class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
:class="arkoseContainerClass"
data-testid="arkose-labs-challenge"
......
......@@ -4,6 +4,7 @@ module Arkose
attr_reader :url, :session_token, :userid
VERIFY_URL = 'http://verify-api.arkoselabs.com/api/v4/verify'
ALLOWLIST_TELLTALE = 'gitlab1-whitelist-qa-team'
def initialize(session_token:, userid:)
@session_token = session_token
......@@ -16,7 +17,7 @@ module Arkose
return false if invalid_token(response)
challenge_solved?(response) && low_risk?(response)
allowlisted?(response) || (challenge_solved?(response) && low_risk?(response))
rescue StandardError => error
payload = { session_token: session_token, log_data: userid }
Gitlab::ExceptionLogFormatter.format!(error, payload)
......@@ -57,5 +58,10 @@ module Arkose
risk_band = response.parsed_response&.dig('session_risk', 'risk_band')
risk_band.present? ? risk_band != 'High' : true
end
def allowlisted?(response)
telltale_list = response.parsed_response&.dig('session_details', 'telltale_list') || []
telltale_list.include?(ALLOWLIST_TELLTALE)
end
end
end
......@@ -12,11 +12,18 @@ jest.mock('~/lib/logger');
jest.mock('ee/arkose_labs/init_arkose_labs_script');
let onShown;
let onCompleted;
let onSuppress;
let onError;
initArkoseLabsScript.mockImplementation(() => ({
setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => {
setConfig: ({
onShown: shownHandler,
onCompleted: completedHandler,
onSuppress: suppressHandler,
onError: errorHandler,
}) => {
onShown = shownHandler;
onCompleted = completedHandler;
onSuppress = suppressHandler;
onError = errorHandler;
},
}));
......@@ -34,6 +41,7 @@ describe('SignInArkoseApp', () => {
const findSignInForm = () => findByTestId('sign-in-form');
const findUsernameInput = () => findByTestId('username-field');
const findSignInButton = () => findByTestId('sign-in-button');
const findChallengeContainer = () => wrapper.findByTestId('arkose-labs-challenge');
const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message');
const findArkoseLabsVerificationTokenInput = () =>
wrapper.find('input[name="arkose_labs_token"]');
......@@ -91,6 +99,7 @@ describe('SignInArkoseApp', () => {
afterEach(() => {
axiosMock.restore();
wrapper?.destroy();
document.body.innerHTML = '';
});
describe('when the username field is pre-filled', () => {
......@@ -134,7 +143,7 @@ describe('SignInArkoseApp', () => {
it('does not show ArkoseLabs error when submitting the form', async () => {
submitForm();
await nextTick();
await waitForPromises();
expect(findArkoseLabsErrorMessage().exists()).toBe(false);
});
......@@ -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', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
......@@ -160,6 +212,7 @@ describe('SignInArkoseApp', () => {
itInitializesArkoseLabs();
it('shows ArkoseLabs error when submitting the form', async () => {
onShown();
submitForm();
await nextTick();
......@@ -168,12 +221,12 @@ describe('SignInArkoseApp', () => {
});
it('un-hides the challenge container once the iframe has been shown', async () => {
expect(wrapper.isVisible()).toBe(false);
expect(findChallengeContainer().isVisible()).toBe(false);
onShown();
await nextTick();
expect(wrapper.isVisible()).toBe(true);
expect(findChallengeContainer().isVisible()).toBe(true);
});
it('shows an error alert if the challenge fails to load', async () => {
......@@ -189,6 +242,13 @@ describe('SignInArkoseApp', () => {
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', () => {
const response = { token: 'verification-token' };
......
......@@ -36,6 +36,17 @@ RSpec.describe Arkose::UserVerificationService do
allow(Gitlab::HTTP).to receive(:perform_request).and_return(response)
expect(subject).to be_falsey
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
......
......@@ -130,6 +130,10 @@ module QA
has_css?(".active", text: 'Standard')
end
def has_arkose_labs_token?
has_css?('[name="arkose_labs_token"][value]', visible: false)
end
def switch_to_sign_in_tab
click_element :sign_in_tab
end
......@@ -174,6 +178,17 @@ module QA
fill_element :login_field, user.username
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
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