Commit bf0b2381 authored by Lukas Eipert's avatar Lukas Eipert

Create Apollo Link for Captcha handling

This implementation of an Apollo Link is similar to our Axios
Captcha interceptor. The Link checks whether a top-level error in the
GraphQL response hints towards the need of solving a captcha. If the
captcha is solved correctly, the mutation will be send again, but this
time the solution to the CAPTCHA is added to the HTTP headers.
parent 2de64d3f
import { ApolloLink, Observable } from 'apollo-link';
export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
forward(operation).flatMap((result) => {
const { errors = [] } = result;
// Our API will return with a top-level GraphQL error with extensions
// in case a captcha is required.
const captchaError = errors.find((e) => e?.extensions?.needs_captcha_response);
if (captchaError) {
const captchaSiteKey = captchaError.extensions.captcha_site_key;
const spamLogId = captchaError.extensions.spam_log_id;
return new Observable((observer) => {
import('~/captcha/wait_for_captcha_to_be_solved')
.then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
.then((captchaResponse) => {
// If the captcha was solved correctly, we re-do our action while setting
// captcha response headers.
operation.setContext({
headers: {
'X-GitLab-Captcha-Response': captchaResponse,
'X-GitLab-Spam-Log-Id': spamLogId,
},
});
forward(operation).subscribe(observer);
})
.catch((error) => {
observer.error(error);
observer.complete();
});
});
}
return Observable.of(result);
}),
);
import { ApolloLink, Observable } from 'apollo-link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved';
jest.mock('~/captcha/wait_for_captcha_to_be_solved');
describe('apolloCaptchaLink', () => {
const SPAM_LOG_ID = 'SPAM_LOG_ID';
const CAPTCHA_SITE_KEY = 'CAPTCHA_SITE_KEY';
const CAPTCHA_RESPONSE = 'CAPTCHA_RESPONSE';
const SUCCESS_RESPONSE = {
data: {
user: {
id: 3,
name: 'foo',
},
},
errors: [],
};
const NON_CAPTCHA_ERROR_RESPONSE = {
data: {
user: null,
},
errors: [
{
message: 'Something is severely wrong with your query.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
message: 'Object not found',
type: 2,
},
},
],
};
const SPAM_ERROR_RESPONSE = {
data: {
user: null,
},
errors: [
{
message: 'Your Query was detected to be SPAM.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
spam: true,
},
},
],
};
const CAPTCHA_ERROR_RESPONSE = {
data: {
user: null,
},
errors: [
{
message: 'This is an unrelated error, captcha should still work despite this.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
},
{
message: 'You need to solve a Captcha.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
spam: true,
needs_captcha_response: true,
captcha_site_key: CAPTCHA_SITE_KEY,
spam_log_id: SPAM_LOG_ID,
},
},
],
};
let link;
let mockLinkImplementation;
let mockContext;
const setupLink = (...responses) => {
mockLinkImplementation = jest.fn().mockImplementation(() => {
return Observable.of(responses.shift());
});
link = ApolloLink.from([apolloCaptchaLink, new ApolloLink(mockLinkImplementation)]);
};
function mockOperation() {
mockContext = jest.fn();
return { operationName: 'operation', variables: {}, setContext: mockContext };
}
it('successful responses are passed through', (done) => {
setupLink(SUCCESS_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(SUCCESS_RESPONSE);
expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
done();
});
});
it('non-spam related errors are passed through', (done) => {
setupLink(NON_CAPTCHA_ERROR_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE);
expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
expect(mockContext).not.toHaveBeenCalled();
expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
done();
});
});
it('unresolvable SPAM errors are passed through', (done) => {
setupLink(SPAM_ERROR_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(SPAM_ERROR_RESPONSE);
expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
expect(mockContext).not.toHaveBeenCalled();
expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
done();
});
});
describe('resolvable SPAM errors', () => {
it('re-submits request with SPAM headers if the captcha modal was solved correctly', (done) => {
waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(SUCCESS_RESPONSE);
expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
expect(mockContext).toHaveBeenCalledWith({
headers: {
'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE,
'X-GitLab-Spam-Log-Id': SPAM_LOG_ID,
},
});
expect(mockLinkImplementation).toHaveBeenCalledTimes(2);
done();
});
});
it('throws error if the captcha modal was not solved correctly', (done) => {
const error = new UnsolvedCaptchaError();
waitForCaptchaToBeSolved.mockRejectedValue(error);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
link.request(mockOperation()).subscribe({
next: done.catch,
error: (result) => {
expect(result).toEqual(error);
expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
expect(mockContext).not.toHaveBeenCalled();
expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
done();
},
});
});
});
});
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