Commit 36993a83 authored by Justin Boyson's avatar Justin Boyson Committed by David O'Regan

Check for empty position object before stringify

Updates specs to check for correct string parsing
parent da75cd82
import { isEmpty } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
......@@ -88,18 +89,23 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
) =>
service
.update(getters.getNotesData.draftsPath, {
draftId: note.id,
note: noteText,
resolveDiscussion,
position: JSON.stringify(position),
})
) => {
const params = {
draftId: note.id,
note: noteText,
resolveDiscussion,
};
// Stringifying an empty object yields `{}` which breaks graphql queries
// https://gitlab.com/gitlab-org/gitlab/-/issues/298827
if (!isEmpty(position)) params.position = JSON.stringify(position);
return service
.update(getters.getNotesData.draftsPath, params)
.then((res) => res.data)
.then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
.catch(() => flash(__('An error occurred while updating the comment')));
};
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id);
......
<script>
import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import { escape } from 'lodash';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import httpStatusCodes from '~/lib/utils/http_status';
......@@ -282,9 +282,13 @@ export default {
note: {
target_type: this.getNoteableData.targetType,
target_id: this.note.noteable_id,
note: { note: noteText, position: JSON.stringify(position) },
note: { note: noteText },
},
};
// Stringifying an empty object yields `{}` which breaks graphql queries
// https://gitlab.com/gitlab-org/gitlab/-/issues/298827
if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
this.isRequesting = true;
this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
......
---
title: fix stringify empty position object
merge_request: 56037
author:
type: fixed
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import service from '~/batch_comments/services/drafts_service';
import * as actions from '~/batch_comments/stores/modules/batch_comments/actions';
import axios from '~/lib/utils/axios_utils';
......@@ -201,6 +202,12 @@ describe('Batch comments store actions', () => {
describe('updateDraft', () => {
let getters;
service.update = jest.fn();
service.update.mockResolvedValue({ data: { id: 1 } });
const commit = jest.fn();
let context;
let params;
beforeEach(() => {
getters = {
......@@ -208,43 +215,43 @@ describe('Batch comments store actions', () => {
draftsPath: TEST_HOST,
},
};
});
it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', (done) => {
const commit = jest.fn();
const context = {
context = {
getters,
commit,
};
res = { id: 1 };
mock.onAny().reply(200, res);
params = { note: { id: 1 }, noteText: 'test' };
});
actions
.updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} })
.then(() => {
expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 });
})
.then(done)
.catch(done.fail);
afterEach(() => jest.clearAllMocks());
it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', () => {
return actions.updateDraft(context, { ...params, callback() {} }).then(() => {
expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 });
});
});
it('calls passed callback', (done) => {
const commit = jest.fn();
const context = {
getters,
commit,
};
it('calls passed callback', () => {
const callback = jest.fn();
res = { id: 1 };
mock.onAny().reply(200, res);
return actions.updateDraft(context, { ...params, callback }).then(() => {
expect(callback).toHaveBeenCalled();
});
});
actions
.updateDraft(context, { note: { id: 1 }, noteText: 'test', callback })
.then(() => {
expect(callback).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
it('does not stringify empty position', () => {
return actions.updateDraft(context, { ...params, position: {}, callback() {} }).then(() => {
expect(service.update.mock.calls[0][1].position).toBeUndefined();
});
});
it('stringifies a non-empty position', () => {
const position = { test: true };
const expectation = JSON.stringify(position);
return actions.updateDraft(context, { ...params, position, callback() {} }).then(() => {
expect(service.update.mock.calls[0][1].position).toBe(expectation);
});
});
});
......
import { mount, createLocalVue } from '@vue/test-utils';
import { escape } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import NoteActions from '~/notes/components/note_actions.vue';
import NoteBody from '~/notes/components/note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
......@@ -13,7 +14,7 @@ describe('issue_note', () => {
let wrapper;
const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]');
beforeEach(() => {
const createWrapper = (props = {}) => {
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
......@@ -23,6 +24,7 @@ describe('issue_note', () => {
store,
propsData: {
note,
...props,
},
localVue,
stubs: [
......@@ -33,14 +35,18 @@ describe('issue_note', () => {
'multiline-comment-form',
],
});
});
};
afterEach(() => {
wrapper.destroy();
});
describe('mutiline comments', () => {
it('should render if has multiline comment', () => {
beforeEach(() => {
createWrapper();
});
it('should render if has multiline comment', async () => {
const position = {
line_range: {
start: {
......@@ -69,9 +75,8 @@ describe('issue_note', () => {
line,
});
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().text()).toEqual('Comment on lines 1 to 2');
});
await wrapper.vm.$nextTick();
expect(findMultilineComment().text()).toBe('Comment on lines 1 to 2');
});
it('should only render if it has everything it needs', () => {
......@@ -147,108 +152,151 @@ describe('issue_note', () => {
});
});
it('should render user information', () => {
const { author } = note;
const avatar = wrapper.find(UserAvatarLink);
const avatarProps = avatar.props();
describe('rendering', () => {
beforeEach(() => {
createWrapper();
});
expect(avatarProps.linkHref).toBe(author.path);
expect(avatarProps.imgSrc).toBe(author.avatar_url);
expect(avatarProps.imgAlt).toBe(author.name);
expect(avatarProps.imgSize).toBe(40);
});
it('should render user information', () => {
const { author } = note;
const avatar = wrapper.findComponent(UserAvatarLink);
const avatarProps = avatar.props();
it('should render note header content', () => {
const noteHeader = wrapper.find(NoteHeader);
const noteHeaderProps = noteHeader.props();
expect(avatarProps.linkHref).toBe(author.path);
expect(avatarProps.imgSrc).toBe(author.avatar_url);
expect(avatarProps.imgAlt).toBe(author.name);
expect(avatarProps.imgSize).toBe(40);
});
expect(noteHeaderProps.author).toEqual(note.author);
expect(noteHeaderProps.createdAt).toEqual(note.created_at);
expect(noteHeaderProps.noteId).toEqual(note.id);
});
it('should render note header content', () => {
const noteHeader = wrapper.findComponent(NoteHeader);
const noteHeaderProps = noteHeader.props();
it('should render note actions', () => {
const { author } = note;
const noteActions = wrapper.find(NoteActions);
const noteActionsProps = noteActions.props();
expect(noteActionsProps.authorId).toBe(author.id);
expect(noteActionsProps.noteId).toBe(note.id);
expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url);
expect(noteActionsProps.accessLevel).toBe(note.human_access);
expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit);
expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji);
expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit);
expect(noteActionsProps.canReportAsAbuse).toBe(true);
expect(noteActionsProps.canResolve).toBe(false);
expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path);
expect(noteActionsProps.resolvable).toBe(false);
expect(noteActionsProps.isResolved).toBe(false);
expect(noteActionsProps.isResolving).toBe(false);
expect(noteActionsProps.resolvedBy).toEqual({});
});
expect(noteHeaderProps.author).toBe(note.author);
expect(noteHeaderProps.createdAt).toBe(note.created_at);
expect(noteHeaderProps.noteId).toBe(note.id);
});
it('should render issue body', () => {
const noteBody = wrapper.find(NoteBody);
const noteBodyProps = noteBody.props();
it('should render note actions', () => {
const { author } = note;
const noteActions = wrapper.findComponent(NoteActions);
const noteActionsProps = noteActions.props();
expect(noteBodyProps.note).toEqual(note);
expect(noteBodyProps.line).toBe(null);
expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
expect(noteBodyProps.isEditing).toBe(false);
expect(noteBodyProps.helpPagePath).toBe('');
});
expect(noteActionsProps.authorId).toBe(author.id);
expect(noteActionsProps.noteId).toBe(note.id);
expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url);
expect(noteActionsProps.accessLevel).toBe(note.human_access);
expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit);
expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji);
expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit);
expect(noteActionsProps.canReportAsAbuse).toBe(true);
expect(noteActionsProps.canResolve).toBe(false);
expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path);
expect(noteActionsProps.resolvable).toBe(false);
expect(noteActionsProps.isResolved).toBe(false);
expect(noteActionsProps.isResolving).toBe(false);
expect(noteActionsProps.resolvedBy).toEqual({});
});
it('prevents note preview xss', (done) => {
const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`;
const alertSpy = jest.spyOn(window, 'alert');
store.hotUpdate({
actions: {
updateNote() {},
setSelectedCommentPositionHover() {},
},
it('should render issue body', () => {
const noteBody = wrapper.findComponent(NoteBody);
const noteBodyProps = noteBody.props();
expect(noteBodyProps.note).toBe(note);
expect(noteBodyProps.line).toBe(null);
expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
expect(noteBodyProps.isEditing).toBe(false);
expect(noteBodyProps.helpPagePath).toBe('');
});
const noteBodyComponent = wrapper.find(NoteBody);
noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
it('prevents note preview xss', async () => {
const noteBody =
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />';
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const noteBodyComponent = wrapper.findComponent(NoteBody);
store.hotUpdate({
actions: {
updateNote() {},
setSelectedCommentPositionHover() {},
},
});
noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
setImmediate(() => {
await waitForPromises();
expect(alertSpy).not.toHaveBeenCalled();
expect(wrapper.vm.note.note_html).toEqual(escape(noteBody));
done();
expect(wrapper.vm.note.note_html).toBe(escape(noteBody));
});
});
describe('cancel edit', () => {
it('restores content of updated note', (done) => {
beforeEach(() => {
createWrapper();
});
it('restores content of updated note', async () => {
const updatedText = 'updated note text';
store.hotUpdate({
actions: {
updateNote() {},
},
});
const noteBody = wrapper.find(NoteBody);
const noteBody = wrapper.findComponent(NoteBody);
noteBody.vm.resetAutoSave = () => {};
noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {});
wrapper.vm
.$nextTick()
.then(() => {
const noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(updatedText);
noteBody.vm.$emit('cancelForm');
})
.then(() => wrapper.vm.$nextTick())
.then(() => {
const noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(note.note_html);
})
.then(done)
.catch(done.fail);
await wrapper.vm.$nextTick();
let noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(updatedText);
noteBody.vm.$emit('cancelForm');
await wrapper.vm.$nextTick();
noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(note.note_html);
});
});
describe('formUpdateHandler', () => {
const updateNote = jest.fn();
const params = ['', null, jest.fn(), ''];
const updateActions = () => {
store.hotUpdate({
actions: {
updateNote,
setSelectedCommentPositionHover() {},
},
});
};
afterEach(() => updateNote.mockReset());
it('responds to handleFormUpdate', () => {
createWrapper();
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
expect(wrapper.emitted('handleUpdateNote')).toBeTruthy();
});
it('does not stringify empty position', () => {
createWrapper();
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
});
it('stringifies populated position', () => {
const position = { test: true };
const expectation = JSON.stringify(position);
createWrapper({ note: { ...note, position } });
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
});
});
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