Commit 796df159 authored by Chad Woolley's avatar Chad Woolley Committed by Paul Slaughter

Add new captcha modal for snippets

- The new modal uses the Pajamas modal component
  and the reCAPTCHA Javascript API.
- The backend spam/reCAPTCHA communication flow is
  done via GraphQL.
- Also uses 'captcha' termiology in order to not
  be tightly coupled to Google's reCAPTCHA
  captcha implementation, and make it easier to
  support additional captcha implementations in
  the future.
parent 6ecd1edc
<script>
// NOTE 1: This is similar to recaptcha_modal.vue, but it directly uses the reCAPTCHA Javascript API
// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, rather than relying
// on the form-based ReCAPTCHA HTML being pre-rendered by the backend and using deprecated-modal.
// NOTE 2: Even though this modal currently only supports reCAPTCHA, we use 'captcha' instead
// of 'recaptcha' throughout the code, so that we can easily add support for future alternative
// captcha implementations other than reCAPTCHA (e.g. FriendlyCaptcha) without having to
// change the references in the code or API.
import { uniqueId } from 'lodash';
import { GlModal } from '@gitlab/ui';
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
export default {
components: {
GlModal,
},
props: {
needsCaptchaResponse: {
type: Boolean,
required: false,
default: false,
},
captchaSiteKey: {
type: String,
required: true,
},
},
data() {
return {
modalId: uniqueId('captcha-modal-'),
};
},
watch: {
needsCaptchaResponse(newNeedsCaptchaResponse) {
// If this is true, we need to present the captcha modal to the user.
// When the modal is shown we will also initialize and render the form.
if (newNeedsCaptchaResponse) {
this.$refs.modal.show();
}
},
},
methods: {
emitReceivedCaptchaResponse(captchaResponse) {
this.$emit('receivedCaptchaResponse', captchaResponse);
this.$refs.modal.hide();
},
emitNullReceivedCaptchaResponse() {
this.emitReceivedCaptchaResponse(null);
},
/**
* handler for when modal is shown
*/
shown() {
const containerRef = this.$refs.captcha;
// NOTE: This is the only bit that is specific to Google's reCAPTCHA captcha implementation.
initRecaptchaScript()
.then((grecaptcha) => {
grecaptcha.render(containerRef, {
sitekey: this.captchaSiteKey,
// This callback will emit and let the parent handle the response
callback: this.emitReceivedCaptchaResponse,
// TODO: Also need to handle expired-callback and error-callback
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
});
})
.catch((e) => {
// TODO: flash the error or notify the user some other way
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
this.emitNullReceivedCaptchaResponse();
this.$refs.modal.hide();
// eslint-disable-next-line no-console
console.error(
'[gitlab] an unexpected exception was caught while initializing captcha',
e,
);
});
},
/**
* handler for when modal is about to hide
*/
hide(bvModalEvent) {
// If hide() was called without any argument, the value of trigger will be null.
// See https://bootstrap-vue.org/docs/components/modal#prevent-closing
if (bvModalEvent.trigger) {
this.emitNullReceivedCaptchaResponse();
}
},
},
};
</script>
<template>
<!-- Note: The action-cancel button isn't necessary for the functionality of the modal, but -->
<!-- there must be at least one button or focusable element, or the gl-modal fails to render. -->
<!-- We could modify gl-model to remove this requirement. -->
<gl-modal
ref="modal"
:modal-id="modalId"
:title="__('Please solve the captcha')"
:action-cancel="{ text: __('Cancel') }"
@shown="shown"
@hide="hide"
>
<div ref="captcha"></div>
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
</gl-modal>
</template>
......@@ -28,11 +28,11 @@ export const initRecaptchaScript = memoize(() => {
return new Promise((resolve) => {
// This global callback resolves the Promise and is passed by name to the reCAPTCHA script.
window[RECAPTCHA_ONLOAD_CALLBACK_NAME] = (val) => {
window[RECAPTCHA_ONLOAD_CALLBACK_NAME] = () => {
// Let's clean up after ourselves. This is also important for testing, because `window` is NOT cleared between tests.
// https://github.com/facebook/jest/issues/1224#issuecomment-444586798.
delete window[RECAPTCHA_ONLOAD_CALLBACK_NAME];
resolve(val);
resolve(window.grecaptcha);
};
appendRecaptchaScript();
});
......
......@@ -32,6 +32,7 @@ export default {
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
GlButton,
GlLoadingIcon,
},
......@@ -66,6 +67,10 @@ export default {
description: '',
visibilityLevel: this.selectedLevel,
},
captchaResponse: '',
needsCaptchaResponse: false,
captchaSiteKey: '',
spamLogId: '',
};
},
computed: {
......@@ -88,6 +93,8 @@ export default {
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.actions,
...(this.spamLogId && { spamLogId: this.spamLogId }),
...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
};
},
saveButtonLabel() {
......@@ -159,6 +166,13 @@ export default {
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
if (baseObj.needsCaptchaResponse) {
// If we need a captcha response, start process for receiving captcha response.
// We will resubmit after the response is obtained.
this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
return;
}
const errors = baseObj?.errors;
if (errors.length) {
this.flashAPIFailure(errors[0]);
......@@ -173,6 +187,35 @@ export default {
updateActions(actions) {
this.actions = actions;
},
/**
* Start process for getting captcha response from user
*
* @param captchaSiteKey Stored in data and used to display the captcha.
* @param spamLogId Stored in data and included when the form is re-submitted.
*/
requestCaptchaResponse(captchaSiteKey, spamLogId) {
this.captchaSiteKey = captchaSiteKey;
this.spamLogId = spamLogId;
this.needsCaptchaResponse = true;
},
/**
* Handle the captcha response from the user
*
* @param captchaResponse The captchaResponse value emitted from the modal.
*/
receivedCaptchaResponse(captchaResponse) {
this.needsCaptchaResponse = false;
this.captchaResponse = captchaResponse;
if (this.captchaResponse) {
// If the user solved the captcha resubmit the form.
this.handleFormSubmit();
} else {
// If the user didn't solve the captcha (e.g. they just closed the modal),
// finish the update and allow them to continue editing or manually resubmit the form.
this.isUpdating = false;
}
},
},
};
</script>
......@@ -190,6 +233,11 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6"
/>
<template v-else>
<captcha-modal
:captcha-site-key="captchaSiteKey"
:needs-captcha-response="needsCaptchaResponse"
@receivedCaptchaResponse="receivedCaptchaResponse"
/>
<title-field
id="snippet-title"
v-model="snippet.title"
......
......@@ -4,5 +4,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
snippet {
webUrl
}
needsCaptchaResponse
captchaSiteKey
}
}
......@@ -4,5 +4,8 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
snippet {
webUrl
}
needsCaptchaResponse
captchaSiteKey
spamLogId
}
}
......@@ -21934,6 +21934,9 @@ msgstr ""
msgid "Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience."
msgstr ""
msgid "Please solve the captcha"
msgstr ""
msgid "Please solve the reCAPTCHA"
msgstr ""
......
import { GlModal } from '@gitlab/ui';
import { stubComponent } from 'helpers/stub_component';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
jest.mock('~/captcha/init_recaptcha_script');
describe('Captcha Modal', () => {
let wrapper;
let modal;
let grecaptcha;
const captchaSiteKey = 'abc123';
function createComponent({ props = {} } = {}) {
wrapper = shallowMount(CaptchaModal, {
propsData: {
captchaSiteKey,
...props,
},
stubs: {
GlModal: stubComponent(GlModal),
},
});
}
beforeEach(() => {
grecaptcha = {
render: jest.fn(),
};
initRecaptchaScript.mockResolvedValue(grecaptcha);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlModal = () => {
const glModal = wrapper.find(GlModal);
jest.spyOn(glModal.vm, 'show').mockImplementation(() => glModal.vm.$emit('shown'));
jest
.spyOn(glModal.vm, 'hide')
.mockImplementation(() => glModal.vm.$emit('hide', { trigger: '' }));
return glModal;
};
const showModal = () => {
wrapper.setProps({ needsCaptchaResponse: true });
};
beforeEach(() => {
createComponent();
modal = findGlModal();
});
describe('rendering', () => {
it('renders', () => {
expect(modal.exists()).toBe(true);
});
it('assigns the modal a unique ID', () => {
const firstInstanceModalId = modal.props('modalId');
createComponent();
const secondInstanceModalId = findGlModal().props('modalId');
expect(firstInstanceModalId).not.toEqual(secondInstanceModalId);
});
});
describe('functionality', () => {
describe('when modal is shown', () => {
describe('when initRecaptchaScript promise resolves successfully', () => {
beforeEach(async () => {
showModal();
await nextTick();
});
it('shows modal', async () => {
expect(findGlModal().vm.show).toHaveBeenCalled();
});
it('renders window.grecaptcha', () => {
expect(grecaptcha.render).toHaveBeenCalledWith(wrapper.vm.$refs.captcha, {
sitekey: captchaSiteKey,
callback: expect.any(Function),
});
});
describe('then the user solves the captcha', () => {
const captchaResponse = 'a captcha response';
beforeEach(() => {
// simulate the grecaptcha library invoking the callback
const { callback } = grecaptcha.render.mock.calls[0][1];
callback(captchaResponse);
});
it('emits receivedCaptchaResponse exactly once with the captcha response', () => {
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[captchaResponse]]);
});
it('hides modal with null trigger', async () => {
// Assert that hide is called with zero args, so that we don't trigger the logic
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
expect(modal.vm.hide).toHaveBeenCalledWith();
});
});
describe('then the user hides the modal without solving the captcha', () => {
// Even though we don't explicitly check for these trigger values, these are the
// currently supported ones which can be emitted.
// See https://bootstrap-vue.org/docs/components/modal#prevent-closing
describe.each`
trigger | expected
${'cancel'} | ${[[null]]}
${'esc'} | ${[[null]]}
${'backdrop'} | ${[[null]]}
${'headerclose'} | ${[[null]]}
`('using the $trigger trigger', ({ trigger, expected }) => {
beforeEach(() => {
const bvModalEvent = {
trigger,
};
modal.vm.$emit('hide', bvModalEvent);
});
it(`emits receivedCaptchaResponse with ${JSON.stringify(expected)}`, () => {
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual(expected);
});
});
});
});
describe('when initRecaptchaScript promise rejects', () => {
const fakeError = {};
beforeEach(() => {
initRecaptchaScript.mockImplementation(() => Promise.reject(fakeError));
jest.spyOn(console, 'error').mockImplementation();
showModal();
});
it('emits receivedCaptchaResponse exactly once with null', () => {
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[null]]);
});
it('hides modal with null trigger', async () => {
// Assert that hide is called with zero args, so that we don't trigger the logic
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
expect(modal.vm.hide).toHaveBeenCalledWith();
});
it('calls console.error with a message and the exception', () => {
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/exception.*captcha/),
fakeError,
);
});
});
});
});
});
......@@ -12,7 +12,7 @@ describe('initRecaptchaScript', () => {
});
const getScriptOnload = () => window[RECAPTCHA_ONLOAD_CALLBACK_NAME];
const triggerScriptOnload = (...args) => window[RECAPTCHA_ONLOAD_CALLBACK_NAME](...args);
const triggerScriptOnload = () => window[RECAPTCHA_ONLOAD_CALLBACK_NAME]();
describe('when called', () => {
let result;
......@@ -37,13 +37,23 @@ describe('initRecaptchaScript', () => {
expect(document.head.querySelectorAll('script').length).toBe(1);
});
it('when onload is triggered, resolves promise', async () => {
const instance = {};
describe('when onload is triggered', () => {
beforeEach(() => {
window.grecaptcha = 'fake grecaptcha';
triggerScriptOnload();
});
triggerScriptOnload(instance);
afterEach(() => {
window.grecaptcha = undefined;
});
await expect(result).resolves.toBe(instance);
it('resolves promise with window.grecaptcha as argument', async () => {
await expect(result).resolves.toBe(window.grecaptcha);
});
it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', async () => {
expect(getScriptOnload()).toBeUndefined();
});
});
});
});
import VueApollo, { ApolloMutation } from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
......@@ -54,6 +57,7 @@ const createTestSnippet = () => ({
describe('Snippet Edit app', () => {
let wrapper;
let fakeApollo;
const captchaSiteKey = 'abc123';
const relativeUrlRoot = '/foo/';
const originalRelativeUrlRoot = gon.relative_url_root;
const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
......@@ -66,6 +70,8 @@ describe('Snippet Edit app', () => {
updateSnippet: {
errors: [],
snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
},
},
}),
......@@ -74,10 +80,48 @@ describe('Snippet Edit app', () => {
updateSnippet: {
errors: [TEST_MUTATION_ERROR],
snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
},
createSnippet: {
errors: [TEST_MUTATION_ERROR],
snippet: null,
needsCaptchaResponse: null,
captchaSiteKey: null,
},
},
}),
// TODO: QUESTION - This has to be wrapped in a factory function in order for the mock to have
// the `mockResolvedValueOnce` counter properly cleared/reset between test `it` examples, by
// ensuring each one gets a fresh mock instance. It's apparently impossible/hard to manually
// clear/reset them (see https://github.com/facebook/jest/issues/7136). So, should
// we convert all the others to factory functions too, to be consistent? And/or move the whole
// `mutationTypes` declaration into a `beforeEach`? (not sure if that will still solve the
// mock reset problem though).
RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE: () =>
jest
.fn()
// NOTE: There may be a captcha-related error, but it is not used in the GraphQL/Vue flow,
// only a truthy 'needsCaptchaResponse' value is used to trigger the captcha modal showing.
.mockResolvedValueOnce({
data: {
createSnippet: {
errors: ['ignored captcha error message'],
snippet: null,
needsCaptchaResponse: true,
captchaSiteKey,
},
},
})
// After the captcha is solved and the modal is closed, the second form submission should
// be successful and return needsCaptchaResponse = false.
.mockResolvedValueOnce({
data: {
createSnippet: {
errors: ['ignored captcha error message'],
snippet: createTestSnippet(),
needsCaptchaResponse: false,
captchaSiteKey: null,
},
},
}),
......@@ -119,6 +163,7 @@ describe('Snippet Edit app', () => {
stubs: {
ApolloMutation,
FormFooterActions,
CaptchaModal: stubComponent(CaptchaModal),
},
provide: {
selectedLevel,
......@@ -144,6 +189,7 @@ describe('Snippet Edit app', () => {
});
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
const findCaptchaModal = () => wrapper.find(CaptchaModal);
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
......@@ -194,6 +240,7 @@ describe('Snippet Edit app', () => {
(props) => {
createComponent(props);
expect(wrapper.find(CaptchaModal).exists()).toBe(true);
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
......@@ -218,7 +265,7 @@ describe('Snippet Edit app', () => {
loadSnippet({ title });
triggerBlobActions(actions);
await wrapper.vm.$nextTick();
await nextTick();
expect(hasDisabledSubmit()).toBe(shouldDisable);
},
......@@ -239,7 +286,7 @@ describe('Snippet Edit app', () => {
loadSnippet(...snippetArg);
await wrapper.vm.$nextTick();
await nextTick();
expect(findCancelButton().attributes('href')).toBe(expectation);
},
......@@ -251,7 +298,7 @@ describe('Snippet Edit app', () => {
createComponent({ props: { snippetGid: '' }, withApollo: true });
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await nextTick();
expect(GetSnippetQuerySpy).not.toHaveBeenCalled();
});
......@@ -288,7 +335,7 @@ describe('Snippet Edit app', () => {
loadSnippet(...snippetArg);
setUploadFilesHtml(uploadedFiles);
await wrapper.vm.$nextTick();
await nextTick();
clickSubmitBtn();
......@@ -338,6 +385,82 @@ describe('Snippet Edit app', () => {
expect(Flash).toHaveBeenCalledWith(expectMessage);
},
);
describe('when needsCaptchaResponse is true', () => {
let modal;
let captchaResponse;
let mutationRes;
beforeEach(async () => {
mutationRes = mutationTypes.RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE();
createComponent({
props: {
snippetGid: '',
projectPath: '',
},
mutationRes,
});
// await waitForPromises();
modal = findCaptchaModal();
loadSnippet();
clickSubmitBtn();
await waitForPromises();
});
it('should display captcha modal', () => {
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(modal.props('needsCaptchaResponse')).toEqual(true);
expect(modal.props('captchaSiteKey')).toEqual(captchaSiteKey);
});
describe('when a non-empty captcha response is received', () => {
beforeEach(() => {
captchaResponse = 'xyz123';
});
it('sets needsCaptchaResponse to false', async () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick();
expect(modal.props('needsCaptchaResponse')).toEqual(false);
});
it('resubmits form with captchaResponse', async () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick();
expect(mutationRes.mock.calls[1][0]).toEqual({
mutation: CreateSnippetMutation,
variables: {
input: {
...getApiData(),
captchaResponse,
projectPath: '',
uploadedFiles: [],
},
},
});
});
});
describe('when an empty captcha response is received ', () => {
beforeEach(() => {
captchaResponse = '';
});
it('sets needsCaptchaResponse to false', async () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick();
expect(modal.props('needsCaptchaResponse')).toEqual(false);
});
it('does not resubmit form', async () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick();
expect(mutationRes.mock.calls.length).toEqual(1);
});
});
});
});
describe('on before unload', () => {
......
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