diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index b40373ecc37ad24d8065e68e9a73d9fc73488258..96ba06a8323f6bb741240577569ff560b43bb075 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ -= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| += form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| .form-group = f.label _('Username or email'), for: 'user_login', class: 'label-bold' - = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } + = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } .form-group = f.label :password, class: 'label-bold' = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } @@ -16,8 +16,10 @@ - else = link_to _('Forgot your password?'), new_password_path(:user) %div - - if captcha_enabled? || captcha_on_login_required? + - if Feature.enabled?(:arkose_labs_login_challenge) + = render_if_exists 'devise/sessions/arkose_labs' + - elsif captcha_enabled? || captcha_on_login_required? = recaptcha_tags nonce: content_security_policy_nonce .submit-container.move-submit-down - = f.submit _('Sign in'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'sign_in_button' } + = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } diff --git a/ee/app/assets/javascripts/api/arkose_labs_api.js b/ee/app/assets/javascripts/api/arkose_labs_api.js new file mode 100644 index 0000000000000000000000000000000000000000..2f276639c03937accfd4394a9bb069d558150fe6 --- /dev/null +++ b/ee/app/assets/javascripts/api/arkose_labs_api.js @@ -0,0 +1,8 @@ +import axios from '~/lib/utils/axios_utils'; +import { buildApiUrl } from '~/api/api_utils'; + +const USERNAME_PLACEHOLDER = ':username'; +const ENDPOINT = `/api/:version/users/${USERNAME_PLACEHOLDER}/captcha_check`; + +export const needsArkoseLabsChallenge = (username = '') => + axios.get(buildApiUrl(ENDPOINT).replace(USERNAME_PLACEHOLDER, encodeURIComponent(username))); diff --git a/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue b/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..0e3a063488ec1e26d73248a6ba543d3efbd9a028 --- /dev/null +++ b/ee/app/assets/javascripts/arkose_labs/components/sign_in_arkose_app.vue @@ -0,0 +1,203 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlAlert } from '@gitlab/ui'; +import { needsArkoseLabsChallenge } from 'ee/rest_api'; +import { logError } from '~/lib/logger'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; +import { __ } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import { initArkoseLabsScript } from '../init_arkose_labs_script'; + +const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] }); + +const MSG_ARKOSE_NEEDED = __('Complete verification to sign in.'); +const MSG_ARKOSE_FAILURE_TITLE = __('Unable to verify the user'); +const MSG_ARKOSE_FAILURE_BODY = __( + 'An error occurred when loading the user verification challenge. Refresh to try again.', +); + +const ARKOSE_CONTAINER_CLASS = 'js-arkose-labs-container-'; + +const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token'; + +export default { + components: { + DomElementListener, + GlAlert, + }, + props: { + publicKey: { + type: String, + required: true, + }, + formSelector: { + type: String, + required: true, + }, + usernameSelector: { + type: String, + required: true, + }, + submitSelector: { + type: String, + required: true, + }, + }, + data() { + return { + arkoseLabsIframeShown: false, + showArkoseNeededError: false, + showArkoseFailure: false, + username: '', + isLoading: false, + arkoseInitialized: false, + arkoseToken: '', + arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS), + arkoseChallengePassed: false, + }; + }, + computed: { + isVisible() { + return this.arkoseLabsIframeShown || this.showErrorContainer; + }, + showErrorContainer() { + return this.showArkoseNeededError || this.showArkoseFailure; + }, + }, + watch: { + username() { + this.checkIfNeedsChallenge(); + }, + isLoading(val) { + this.updateSubmitButtonLoading(val); + }, + }, + mounted() { + this.username = this.getUsernameValue(); + }, + methods: { + onArkoseLabsIframeShown() { + this.arkoseLabsIframeShown = true; + }, + hideErrors() { + this.showArkoseNeededError = false; + this.showArkoseFailure = false; + }, + getUsernameValue() { + return document.querySelector(this.usernameSelector)?.value || ''; + }, + onUsernameBlur() { + this.username = this.getUsernameValue(); + }, + onSubmit(e) { + if (!this.arkoseInitialized || this.arkoseChallengePassed) { + return; + } + e.preventDefault(); + this.showArkoseNeededError = true; + }, + async checkIfNeedsChallenge() { + if (!this.username || this.arkoseInitialized) { + return; + } + + this.isLoading = true; + + try { + const { + data: { result }, + } = await needsArkoseLabsChallenge(this.username); + + if (result) { + await this.initArkoseLabs(); + } + } catch (e) { + if (e.response?.status === 404) { + // We ignore 404 errors as it just means the username does not exist. + } else if (e.response?.status) { + // If the request failed with any other error code, we initialize the challenge to make + // sure it isn't being bypassed by purposefully making the endpoint fail. + this.initArkoseLabs(); + } else { + // For any other failure, we show the initialization error message. + this.handleArkoseLabsFailure(e); + } + } finally { + this.isLoading = false; + } + }, + async initArkoseLabs() { + this.arkoseInitialized = true; + + const enforcement = await initArkoseLabsScript({ publicKey: this.publicKey }); + + enforcement.setConfig({ + mode: 'inline', + selector: `.${this.arkoseContainerClass}`, + onShown: this.onArkoseLabsIframeShown, + onCompleted: this.passArkoseLabsChallenge, + onError: this.handleArkoseLabsFailure, + }); + }, + passArkoseLabsChallenge(response) { + this.arkoseChallengePassed = true; + this.arkoseToken = response.token; + this.hideErrors(); + }, + handleArkoseLabsFailure(e) { + logError('ArkoseLabs initialization error', e); + this.showArkoseFailure = true; + }, + updateSubmitButtonLoading(val) { + const button = document.querySelector(this.submitSelector); + + if (val) { + const label = __('Loading'); + button.innerHTML = ` + ${LOADING_ICON.outerHTML} + ${label} + `; + button.setAttribute('disabled', true); + } else { + button.innerText = __('Sign in'); + button.removeAttribute('disabled'); + } + }, + }, + MSG_ARKOSE_NEEDED, + MSG_ARKOSE_FAILURE_TITLE, + MSG_ARKOSE_FAILURE_BODY, + VERIFICATION_TOKEN_INPUT_NAME, +}; +</script> + +<template> + <div v-show="isVisible"> + <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 + class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3" + :class="arkoseContainerClass" + data-testid="arkose-labs-challenge" + ></div> + <div v-if="showErrorContainer" class="gl-mb-3" data-testid="arkose-labs-error-message"> + <gl-alert + v-if="showArkoseFailure" + :title="$options.MSG_ARKOSE_FAILURE_TITLE" + variant="danger" + :dismissible="false" + > + {{ $options.MSG_ARKOSE_FAILURE_BODY }} + </gl-alert> + <span v-else-if="showArkoseNeededError" class="gl-text-red-500"> + {{ $options.MSG_ARKOSE_NEEDED }} + </span> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/arkose_labs/index.js b/ee/app/assets/javascripts/arkose_labs/index.js new file mode 100644 index 0000000000000000000000000000000000000000..0dab272df9c4e7d8b80ac695762065715b450d43 --- /dev/null +++ b/ee/app/assets/javascripts/arkose_labs/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import SignInArkoseApp from './components/sign_in_arkose_app.vue'; + +const FORM_SELECTOR = '.js-sign-in-form'; +const USERNAME_SELECTOR = `${FORM_SELECTOR} .js-username-field`; +const SUBMIT_SELECTOR = `${FORM_SELECTOR} .js-sign-in-button`; + +export const setupArkoseLabs = () => { + const signInForm = document.querySelector(FORM_SELECTOR); + const el = signInForm?.querySelector('.js-arkose-labs-challenge'); + + if (!el) { + return null; + } + + const publicKey = el.dataset.apiKey; + + return new Vue({ + el, + render(h) { + return h(SignInArkoseApp, { + props: { + publicKey, + formSelector: FORM_SELECTOR, + usernameSelector: USERNAME_SELECTOR, + submitSelector: SUBMIT_SELECTOR, + }, + }); + }, + }); +}; diff --git a/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js b/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js new file mode 100644 index 0000000000000000000000000000000000000000..9beb9c3ce244cca595aa9eb0da1676350cacec0f --- /dev/null +++ b/ee/app/assets/javascripts/arkose_labs/init_arkose_labs_script.js @@ -0,0 +1,26 @@ +import { uniqueId } from 'lodash'; + +const CALLBACK_NAME = '_initArkoseLabsScript_callback_'; + +const getCallbackName = () => uniqueId(CALLBACK_NAME); + +export const initArkoseLabsScript = ({ publicKey }) => { + const callbackFunctionName = getCallbackName(); + + return new Promise((resolve) => { + window[callbackFunctionName] = (enforcement) => { + delete window[callbackFunctionName]; + resolve(enforcement); + }; + + const tag = document.createElement('script'); + [ + ['type', 'text/javascript'], + ['src', `https://client-api.arkoselabs.com/v2/${publicKey}/api.js`], + ['data-callback', callbackFunctionName], + ].forEach(([attr, value]) => { + tag.setAttribute(attr, value); + }); + document.head.appendChild(tag); + }); +}; diff --git a/ee/app/assets/javascripts/pages/sessions/new/index.js b/ee/app/assets/javascripts/pages/sessions/new/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c3dfc113cc6eaf22dae59ce0478cd2c8db4119ed --- /dev/null +++ b/ee/app/assets/javascripts/pages/sessions/new/index.js @@ -0,0 +1,11 @@ +import '~/pages/sessions/new/index'; + +if (gon.features.arkoseLabsLoginChallenge) { + import('ee/arkose_labs') + .then(({ setupArkoseLabs }) => { + setupArkoseLabs(); + }) + .catch((e) => { + throw e; + }); +} diff --git a/ee/app/assets/javascripts/rest_api.js b/ee/app/assets/javascripts/rest_api.js index 9f2f956617ea9813528982097182b76cb36d05f0..fc5f856e2bd10bbf85aa22ca956ab668344ba819 100644 --- a/ee/app/assets/javascripts/rest_api.js +++ b/ee/app/assets/javascripts/rest_api.js @@ -1,3 +1,4 @@ export * from './api/groups_api'; export * from './api/subscriptions_api'; export * from './api/dora_api'; +export * from './api/arkose_labs_api'; diff --git a/ee/app/controllers/concerns/arkose_labs_csp.rb b/ee/app/controllers/concerns/arkose_labs_csp.rb new file mode 100644 index 0000000000000000000000000000000000000000..27209283536b26b51283d1db0bb836f8d96cbf01 --- /dev/null +++ b/ee/app/controllers/concerns/arkose_labs_csp.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ArkoseLabsCSP + extend ActiveSupport::Concern + + included do + content_security_policy do |policy| + next unless Feature.enabled?(:arkose_labs_login_challenge) + + default_script_src = policy.directives['script-src'] || policy.directives['default-src'] + script_src_values = Array.wrap(default_script_src) | ["https://client-api.arkoselabs.com"] + policy.script_src(*script_src_values) + + default_frame_src = policy.directives['frame-src'] || policy.directives['default-src'] + frame_src_values = Array.wrap(default_frame_src) | ['https://client-api.arkoselabs.com'] + policy.frame_src(*frame_src_values) + end + end +end diff --git a/ee/app/controllers/ee/sessions_controller.rb b/ee/app/controllers/ee/sessions_controller.rb index a49e3bf99afb5922b530877ff96eb129c1aa91ab..4e44ec6755c926f89c90700cb68da2b551c73921 100644 --- a/ee/app/controllers/ee/sessions_controller.rb +++ b/ee/app/controllers/ee/sessions_controller.rb @@ -6,7 +6,12 @@ module SessionsController extend ::Gitlab::Utils::Override prepended do + include ArkoseLabsCSP + before_action :gitlab_geo_logout, only: [:destroy] + before_action only: [:new] do + push_frontend_feature_flag(:arkose_labs_login_challenge, default_enabled: :yaml) + end end override :new @@ -18,6 +23,10 @@ def new state = geo_login_state.encode redirect_to oauth_geo_auth_url(host: current_node_uri.host, port: current_node_uri.port, state: state) else + if ::Feature.enabled?(:arkose_labs_login_challenge) + @arkose_labs_public_key ||= ENV['ARKOSE_LABS_PUBLIC_KEY'] # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + super end end diff --git a/ee/app/views/devise/sessions/_arkose_labs.html.haml b/ee/app/views/devise/sessions/_arkose_labs.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..1486452f526f10bf5514df9f049242e2b4c738ab --- /dev/null +++ b/ee/app/views/devise/sessions/_arkose_labs.html.haml @@ -0,0 +1 @@ +.js-arkose-labs-challenge{ data: { api_key: @arkose_labs_public_key } } diff --git a/ee/spec/features/users/arkose_labs_csp_spec.rb b/ee/spec/features/users/arkose_labs_csp_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..16d23c94953bbf25f1b2b6748f3794c2ac0cc0aa --- /dev/null +++ b/ee/spec/features/users/arkose_labs_csp_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ArkoseLabs content security policy' do + let(:user) { create(:user) } + + it 'has proper Content Security Policy headers' do + visit root_path + + expect(response_headers['Content-Security-Policy']).to include('https://client-api.arkoselabs.com') + end +end diff --git a/ee/spec/frontend/api/arkose_labs_api_spec.js b/ee/spec/frontend/api/arkose_labs_api_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1b8635309d0c83d64cdf06133a2646a1ebba554f --- /dev/null +++ b/ee/spec/frontend/api/arkose_labs_api_spec.js @@ -0,0 +1,41 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as arkoseLabsApi from 'ee/api/arkose_labs_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('ArkoseLabs API', () => { + let axiosMock; + + beforeEach(() => { + window.gon = { api_version: 'v4' }; + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('needsArkoseLabsChallenge', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + axiosMock.onGet().reply(200); + }); + + it.each` + username | expectedUrlFragment + ${undefined} | ${''} + ${''} | ${''} + ${'foo'} | ${'foo'} + ${'éøà '} | ${'%C3%A9%C3%B8%C3%A0'} + ${'dot.slash/'} | ${'dot.slash%2F'} + `( + 'calls the API with $expectedUrlFragment in the URL when given $username as the username', + ({ username, expectedUrlFragment }) => { + arkoseLabsApi.needsArkoseLabsChallenge(username); + + expect(axios.get).toHaveBeenCalledWith( + `/api/v4/users/${expectedUrlFragment}/captcha_check`, + ); + }, + ); + }); +}); diff --git a/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js b/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..36de43cf9ac6c4c8ae8950a7954582790a0d2999 --- /dev/null +++ b/ee/spec/frontend/arkose_labs/components/sign_in_arkose_app_spec.js @@ -0,0 +1,250 @@ +import { nextTick } from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SignInArkoseApp from 'ee/arkose_labs/components/sign_in_arkose_app.vue'; +import axios from '~/lib/utils/axios_utils'; +import { logError } from '~/lib/logger'; +import waitForPromises from 'helpers/wait_for_promises'; +import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script'; + +jest.mock('~/lib/logger'); +// ArkoseLabs enforcement mocks +jest.mock('ee/arkose_labs/init_arkose_labs_script'); +let onShown; +let onCompleted; +let onError; +initArkoseLabsScript.mockImplementation(() => ({ + setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => { + onShown = shownHandler; + onCompleted = completedHandler; + onError = errorHandler; + }, +})); + +const MOCK_USERNAME = 'cassiopeia'; +const MOCK_PUBLIC_KEY = 'arkose-labs-public-api-key'; + +describe('SignInArkoseApp', () => { + let wrapper; + let axiosMock; + + // Finders + const makeTestIdSelector = (testId) => `[data-testid="${testId}"]`; + const findByTestId = (testId) => document.querySelector(makeTestIdSelector(testId)); + const findSignInForm = () => findByTestId('sign-in-form'); + const findUsernameInput = () => findByTestId('username-field'); + const findSignInButton = () => findByTestId('sign-in-button'); + const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message'); + const findArkoseLabsVerificationTokenInput = () => + wrapper.find('input[name="arkose_labs_token"]'); + + // Helpers + const createForm = (username = '') => { + loadFixtures('sessions/new.html'); + findUsernameInput().value = username; + }; + const initArkoseLabs = (username) => { + createForm(username); + wrapper = mountExtended(SignInArkoseApp, { + propsData: { + publicKey: MOCK_PUBLIC_KEY, + formSelector: makeTestIdSelector('sign-in-form'), + usernameSelector: makeTestIdSelector('username-field'), + submitSelector: makeTestIdSelector('sign-in-button'), + }, + }); + }; + const setUsername = (username) => { + const input = findUsernameInput(); + input.focus(); + input.value = username; + input.blur(); + }; + const submitForm = () => { + findSignInForm().dispatchEvent(new Event('submit')); + }; + + // Assertions + const itInitializesArkoseLabs = () => { + it("includes ArkoseLabs' script", () => { + expect(initArkoseLabsScript).toHaveBeenCalledWith({ publicKey: MOCK_PUBLIC_KEY }); + }); + + it('creates a hidden input for the verification token', () => { + const input = findArkoseLabsVerificationTokenInput(); + + expect(input.exists()).toBe(true); + expect(input.element.value).toBe(''); + }); + }; + const expectHiddenArkoseLabsError = () => { + expect(findArkoseLabsErrorMessage().exists()).toBe(false); + }; + const expectArkoseLabsInitError = () => { + expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + wrapper?.destroy(); + }); + + describe('when the username field is pre-filled', () => { + it("does not include ArkoseLabs' script initially", () => { + expect(initArkoseLabsScript).not.toHaveBeenCalled(); + }); + + it('puts the sign-in button in the loading state', async () => { + initArkoseLabs(MOCK_USERNAME); + await nextTick(); + const signInButton = findSignInButton(); + + expect(signInButton.innerText).toMatchInterpolatedText('Loading'); + expect(signInButton.disabled).toBe(true); + }); + + it('triggers a request to the captcha_check API', async () => { + initArkoseLabs(MOCK_USERNAME); + + expect(axiosMock.history.get).toHaveLength(0); + + await waitForPromises(); + + expect(axiosMock.history.get).toHaveLength(1); + expect(axiosMock.history.get[0].url).toMatch(`/users/${MOCK_USERNAME}/captcha_check`); + }); + + describe('if the challenge is not needed', () => { + beforeEach(async () => { + axiosMock.onGet().reply(200, { result: false }); + initArkoseLabs(MOCK_USERNAME); + await waitForPromises(); + }); + + it('resets the loading button', () => { + const signInButton = findSignInButton(); + + expect(signInButton.innerText).toMatchInterpolatedText('Sign in'); + expect(signInButton.disabled).toBe(false); + }); + + it('does not show ArkoseLabs error when submitting the form', async () => { + submitForm(); + await nextTick(); + + expect(findArkoseLabsErrorMessage().exists()).toBe(false); + }); + + describe('if the challenge becomes needed', () => { + beforeEach(async () => { + axiosMock.onGet().reply(200, { result: true }); + setUsername(`malicious-${MOCK_USERNAME}`); + await waitForPromises(); + }); + + itInitializesArkoseLabs(); + }); + }); + + describe('if the challenge is needed', () => { + beforeEach(async () => { + axiosMock.onGet().reply(200, { result: true }); + initArkoseLabs(MOCK_USERNAME); + await waitForPromises(); + }); + + itInitializesArkoseLabs(); + + it('shows ArkoseLabs error when submitting the form', async () => { + submitForm(); + await nextTick(); + + expect(findArkoseLabsErrorMessage().exists()).toBe(true); + expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_NEEDED); + }); + + it('un-hides the challenge container once the iframe has been shown', async () => { + expect(wrapper.isVisible()).toBe(false); + + onShown(); + await nextTick(); + + expect(wrapper.isVisible()).toBe(true); + }); + + it('shows an error alert if the challenge fails to load', async () => { + expect(wrapper.text()).not.toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY); + + const error = new Error(); + onError(error); + + expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error); + + await nextTick(); + + expectArkoseLabsInitError(); + }); + + describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => { + const response = { token: 'verification-token' }; + + beforeEach(() => { + submitForm(); + + onCompleted(response); + }); + + it('removes ArkoseLabs error', () => { + expectHiddenArkoseLabsError(); + }); + + it('does not show again the error when re-submitting the form', () => { + submitForm(); + + expectHiddenArkoseLabsError(); + }); + + it("sets the verification token input's value", () => { + expect(findArkoseLabsVerificationTokenInput().element.value).toBe(response.token); + }); + }); + }); + }); + + describe('when the username check fails', () => { + it('with a 404, nothing happens', async () => { + axiosMock.onGet().reply(404); + initArkoseLabs(MOCK_USERNAME); + await waitForPromises(); + + expect(initArkoseLabsScript).not.toHaveBeenCalled(); + expectHiddenArkoseLabsError(); + }); + + it('with some other HTTP error, the challenge is initialized', async () => { + axiosMock.onGet().reply(500); + initArkoseLabs(MOCK_USERNAME); + await waitForPromises(); + + expect(initArkoseLabsScript).toHaveBeenCalled(); + expectHiddenArkoseLabsError(); + }); + + it('due to the script inclusion, an error is shown', async () => { + const error = new Error(); + initArkoseLabsScript.mockImplementation(() => { + throw new Error(); + }); + axiosMock.onGet().reply(200, { result: true }); + initArkoseLabs(MOCK_USERNAME); + await waitForPromises(); + + expectArkoseLabsInitError(); + expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error); + }); + }); +}); diff --git a/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js b/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..eb19448ab1e9ebb222aa9fdb8291357a9dc80844 --- /dev/null +++ b/ee/spec/frontend/arkose_labs/init_arkose_labs_script_spec.js @@ -0,0 +1,50 @@ +import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script'; + +jest.mock('lodash/uniqueId', () => (x) => `${x}7`); + +const EXPECTED_CALLBACK_NAME = '_initArkoseLabsScript_callback_7'; +const TEST_PUBLIC_KEY = 'arkose-labs-public-api-key'; + +describe('initArkoseLabsScript', () => { + let subject; + + const initSubject = () => { + subject = initArkoseLabsScript({ publicKey: TEST_PUBLIC_KEY }); + }; + + const findScriptTags = () => document.querySelectorAll('script'); + + afterEach(() => { + subject = null; + document.getElementsByTagName('html')[0].innerHTML = ''; + }); + + it('sets a global enforcement callback', () => { + initSubject(); + + expect(window[EXPECTED_CALLBACK_NAME]).not.toBe(undefined); + }); + + it('adds ArkoseLabs scripts to the HTML head', () => { + expect(findScriptTags()).toHaveLength(0); + + initSubject(); + + const scriptTag = findScriptTags().item(0); + + expect(scriptTag.getAttribute('type')).toBe('text/javascript'); + expect(scriptTag.getAttribute('src')).toBe( + `https://client-api.arkoselabs.com/v2/${TEST_PUBLIC_KEY}/api.js`, + ); + expect(scriptTag.getAttribute('data-callback')).toBe(EXPECTED_CALLBACK_NAME); + }); + + it('when callback is called, cleans up the global object and resolves the Promise', () => { + initSubject(); + const enforcement = 'ArkoseLabsEnforcement'; + window[EXPECTED_CALLBACK_NAME](enforcement); + + expect(window[EXPECTED_CALLBACK_NAME]).toBe(undefined); + return expect(subject).resolves.toBe(enforcement); + }); +}); diff --git a/ee/spec/views/devise/sessions/new.html.haml_spec.rb b/ee/spec/views/devise/sessions/new.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9d8b03b5ac4f274a69d2d6370f6c94fdaaed6262 --- /dev/null +++ b/ee/spec/views/devise/sessions/new.html.haml_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'devise/sessions/new' do + before do + view.instance_variable_set(:@arkose_labs_public_key, "arkose-api-key") + end + + describe 'ArkoseLabs challenge' do + subject { render(template: 'devise/sessions/new', layout: 'layouts/devise') } + + before do + stub_devise + disable_captcha + allow(Gitlab).to receive(:com?).and_return(true) + end + + context 'when the :arkose_labs_login_challenge feature flag is enabled' do + before do + stub_feature_flags(arkose_labs_login_challenge: true) + + subject + end + + it 'renders the challenge container' do + expect(rendered).to have_css('.js-arkose-labs-challenge') + end + + it 'passes the API key to the challenge container' do + expect(rendered).to have_selector('.js-arkose-labs-challenge[data-api-key="arkose-api-key"]') + end + end + + context 'when the :arkose_labs_login_challenge feature flag is disabled' do + before do + stub_feature_flags(arkose_labs_login_challenge: false) + + subject + end + + it 'does not render challenge container' do + expect(rendered).not_to have_css('.js-arkose-labs-challenge') + end + end + end + + def stub_devise + allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user]) + allow(view).to receive(:resource).and_return(spy) + allow(view).to receive(:resource_name).and_return(:user) + end + + def disable_captcha + allow(view).to receive(:captcha_enabled?).and_return(false) + allow(view).to receive(:captcha_on_login_required?).and_return(false) + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9762e2b4f12b643d27ab34fe4570a5f02ad5045a..51decc88b248e3ddf88a55e00ddb1451aee9fdc8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3819,6 +3819,9 @@ msgstr "" msgid "An error occurred previewing the blob" msgstr "" +msgid "An error occurred when loading the user verification challenge. Refresh to try again." +msgstr "" + msgid "An error occurred when updating the title" msgstr "" @@ -9176,6 +9179,9 @@ msgstr "" msgid "Complete" msgstr "" +msgid "Complete verification to sign in." +msgstr "" + msgid "Completed" msgstr "" @@ -39801,6 +39807,9 @@ msgstr "" msgid "Unable to update this issue at this time." msgstr "" +msgid "Unable to verify the user" +msgstr "" + msgid "Unapprove a merge request" msgstr ""