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(() => { ...@@ -28,11 +28,11 @@ export const initRecaptchaScript = memoize(() => {
return new Promise((resolve) => { return new Promise((resolve) => {
// This global callback resolves the Promise and is passed by name to the reCAPTCHA script. // 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. // 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. // https://github.com/facebook/jest/issues/1224#issuecomment-444586798.
delete window[RECAPTCHA_ONLOAD_CALLBACK_NAME]; delete window[RECAPTCHA_ONLOAD_CALLBACK_NAME];
resolve(val); resolve(window.grecaptcha);
}; };
appendRecaptchaScript(); appendRecaptchaScript();
}); });
......
...@@ -32,6 +32,7 @@ export default { ...@@ -32,6 +32,7 @@ export default {
SnippetBlobActionsEdit, SnippetBlobActionsEdit,
TitleField, TitleField,
FormFooterActions, FormFooterActions,
CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
}, },
...@@ -66,6 +67,10 @@ export default { ...@@ -66,6 +67,10 @@ export default {
description: '', description: '',
visibilityLevel: this.selectedLevel, visibilityLevel: this.selectedLevel,
}, },
captchaResponse: '',
needsCaptchaResponse: false,
captchaSiteKey: '',
spamLogId: '',
}; };
}, },
computed: { computed: {
...@@ -88,6 +93,8 @@ export default { ...@@ -88,6 +93,8 @@ export default {
description: this.snippet.description, description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel, visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.actions, blobActions: this.actions,
...(this.spamLogId && { spamLogId: this.spamLogId }),
...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
}; };
}, },
saveButtonLabel() { saveButtonLabel() {
...@@ -159,6 +166,13 @@ export default { ...@@ -159,6 +166,13 @@ export default {
.then(({ data }) => { .then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; 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; const errors = baseObj?.errors;
if (errors.length) { if (errors.length) {
this.flashAPIFailure(errors[0]); this.flashAPIFailure(errors[0]);
...@@ -173,6 +187,35 @@ export default { ...@@ -173,6 +187,35 @@ export default {
updateActions(actions) { updateActions(actions) {
this.actions = 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> </script>
...@@ -190,6 +233,11 @@ export default { ...@@ -190,6 +233,11 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6" class="loading-animation prepend-top-20 gl-mb-6"
/> />
<template v-else> <template v-else>
<captcha-modal
:captcha-site-key="captchaSiteKey"
:needs-captcha-response="needsCaptchaResponse"
@receivedCaptchaResponse="receivedCaptchaResponse"
/>
<title-field <title-field
id="snippet-title" id="snippet-title"
v-model="snippet.title" v-model="snippet.title"
......
...@@ -4,5 +4,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) { ...@@ -4,5 +4,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
snippet { snippet {
webUrl webUrl
} }
needsCaptchaResponse
captchaSiteKey
} }
} }
...@@ -4,5 +4,8 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) { ...@@ -4,5 +4,8 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
snippet { snippet {
webUrl webUrl
} }
needsCaptchaResponse
captchaSiteKey
spamLogId
} }
} }
...@@ -21934,6 +21934,9 @@ msgstr "" ...@@ -21934,6 +21934,9 @@ msgstr ""
msgid "Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience." msgid "Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience."
msgstr "" msgstr ""
msgid "Please solve the captcha"
msgstr ""
msgid "Please solve the reCAPTCHA" msgid "Please solve the reCAPTCHA"
msgstr "" 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', () => { ...@@ -12,7 +12,7 @@ describe('initRecaptchaScript', () => {
}); });
const getScriptOnload = () => window[RECAPTCHA_ONLOAD_CALLBACK_NAME]; 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', () => { describe('when called', () => {
let result; let result;
...@@ -37,13 +37,23 @@ describe('initRecaptchaScript', () => { ...@@ -37,13 +37,23 @@ describe('initRecaptchaScript', () => {
expect(document.head.querySelectorAll('script').length).toBe(1); expect(document.head.querySelectorAll('script').length).toBe(1);
}); });
it('when onload is triggered, resolves promise', async () => { describe('when onload is triggered', () => {
const instance = {}; 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(); expect(getScriptOnload()).toBeUndefined();
}); });
}); });
});
}); });
import VueApollo, { ApolloMutation } from 'vue-apollo'; import VueApollo, { ApolloMutation } from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import { deprecatedCreateFlash as Flash } from '~/flash'; import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetEditApp from '~/snippets/components/edit.vue';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
...@@ -54,6 +57,7 @@ const createTestSnippet = () => ({ ...@@ -54,6 +57,7 @@ const createTestSnippet = () => ({
describe('Snippet Edit app', () => { describe('Snippet Edit app', () => {
let wrapper; let wrapper;
let fakeApollo; let fakeApollo;
const captchaSiteKey = 'abc123';
const relativeUrlRoot = '/foo/'; const relativeUrlRoot = '/foo/';
const originalRelativeUrlRoot = gon.relative_url_root; const originalRelativeUrlRoot = gon.relative_url_root;
const GetSnippetQuerySpy = jest.fn().mockResolvedValue({ const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
...@@ -66,6 +70,8 @@ describe('Snippet Edit app', () => { ...@@ -66,6 +70,8 @@ describe('Snippet Edit app', () => {
updateSnippet: { updateSnippet: {
errors: [], errors: [],
snippet: createTestSnippet(), snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
}, },
}, },
}), }),
...@@ -74,10 +80,48 @@ describe('Snippet Edit app', () => { ...@@ -74,10 +80,48 @@ describe('Snippet Edit app', () => {
updateSnippet: { updateSnippet: {
errors: [TEST_MUTATION_ERROR], errors: [TEST_MUTATION_ERROR],
snippet: createTestSnippet(), snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
}, },
createSnippet: { createSnippet: {
errors: [TEST_MUTATION_ERROR], errors: [TEST_MUTATION_ERROR],
snippet: null, 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', () => { ...@@ -119,6 +163,7 @@ describe('Snippet Edit app', () => {
stubs: { stubs: {
ApolloMutation, ApolloMutation,
FormFooterActions, FormFooterActions,
CaptchaModal: stubComponent(CaptchaModal),
}, },
provide: { provide: {
selectedLevel, selectedLevel,
...@@ -144,6 +189,7 @@ describe('Snippet Edit app', () => { ...@@ -144,6 +189,7 @@ describe('Snippet Edit app', () => {
}); });
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
const findCaptchaModal = () => wrapper.find(CaptchaModal);
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
...@@ -194,6 +240,7 @@ describe('Snippet Edit app', () => { ...@@ -194,6 +240,7 @@ describe('Snippet Edit app', () => {
(props) => { (props) => {
createComponent(props); createComponent(props);
expect(wrapper.find(CaptchaModal).exists()).toBe(true);
expect(wrapper.find(TitleField).exists()).toBe(true); expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
...@@ -218,7 +265,7 @@ describe('Snippet Edit app', () => { ...@@ -218,7 +265,7 @@ describe('Snippet Edit app', () => {
loadSnippet({ title }); loadSnippet({ title });
triggerBlobActions(actions); triggerBlobActions(actions);
await wrapper.vm.$nextTick(); await nextTick();
expect(hasDisabledSubmit()).toBe(shouldDisable); expect(hasDisabledSubmit()).toBe(shouldDisable);
}, },
...@@ -239,7 +286,7 @@ describe('Snippet Edit app', () => { ...@@ -239,7 +286,7 @@ describe('Snippet Edit app', () => {
loadSnippet(...snippetArg); loadSnippet(...snippetArg);
await wrapper.vm.$nextTick(); await nextTick();
expect(findCancelButton().attributes('href')).toBe(expectation); expect(findCancelButton().attributes('href')).toBe(expectation);
}, },
...@@ -251,7 +298,7 @@ describe('Snippet Edit app', () => { ...@@ -251,7 +298,7 @@ describe('Snippet Edit app', () => {
createComponent({ props: { snippetGid: '' }, withApollo: true }); createComponent({ props: { snippetGid: '' }, withApollo: true });
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick(); await nextTick();
expect(GetSnippetQuerySpy).not.toHaveBeenCalled(); expect(GetSnippetQuerySpy).not.toHaveBeenCalled();
}); });
...@@ -288,7 +335,7 @@ describe('Snippet Edit app', () => { ...@@ -288,7 +335,7 @@ describe('Snippet Edit app', () => {
loadSnippet(...snippetArg); loadSnippet(...snippetArg);
setUploadFilesHtml(uploadedFiles); setUploadFilesHtml(uploadedFiles);
await wrapper.vm.$nextTick(); await nextTick();
clickSubmitBtn(); clickSubmitBtn();
...@@ -338,6 +385,82 @@ describe('Snippet Edit app', () => { ...@@ -338,6 +385,82 @@ describe('Snippet Edit app', () => {
expect(Flash).toHaveBeenCalledWith(expectMessage); 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', () => { 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