/* eslint no-param-reassign: "off" */ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GfmAutoComplete, { escape, membersBeforeSave, highlighter, CONTACT_STATE_ACTIVE, CONTACTS_ADD_COMMAND, CONTACTS_REMOVE_COMMAND, } from 'ee_else_ce/gfm_auto_complete'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import AjaxCache from '~/lib/utils/ajax_cache'; import axios from '~/lib/utils/axios_utils'; import { eventlistenersMockDefaultMap, crmContactsMock, } from 'ee_else_ce_jest/gfm_auto_complete/mock_data'; describe('escape', () => { it.each` xssPayload | escapedPayload ${''} | ${'<script>alert(1)</script>'} ${'%3Cscript%3E alert(1) %3C%2Fscript%3E'} | ${'<script> alert(1) </script>'} ${'%253Cscript%253E alert(1) %253C%252Fscript%253E'} | ${'<script> alert(1) </script>'} `( 'escapes the input string correctly accounting for multiple encoding', ({ xssPayload, escapedPayload }) => { expect(escape(xssPayload)).toBe(escapedPayload); }, ); }); describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); let atwhoInstance; let sorterValue; let filterValue; describe('DefaultOptions.filter', () => { let items; beforeEach(() => { jest.spyOn(fetchDataMock, 'fetchData'); jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {}); }); describe('assets loading', () => { beforeEach(() => { atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; items = ['loading']; filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); }); it('should call the fetchData function without query', () => { expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:'); }); it('should not call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); }); it('should return the passed unfiltered items', () => { expect(filterValue).toEqual(items); }); }); describe('backend filtering', () => { beforeEach(() => { atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; items = []; }); describe('when previous query is different from current one', () => { beforeEach(() => { gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ previousQuery: 'oldquery', ...fetchDataMock, }); filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items); }); it('should call the fetchData function with query', () => { expect(fetchDataMock.fetchData).toHaveBeenCalledWith( 'inputor', '[vulnerability:', 'newquery', ); }); it('should not call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); }); it('should return the passed unfiltered items', () => { expect(filterValue).toEqual(items); }); }); describe('when previous query is not different from current one', () => { beforeEach(() => { gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ previousQuery: 'oldquery', ...fetchDataMock, }); filterValue = gfmAutoCompleteCallbacks.filter.call( atwhoInstance, 'oldquery', items, 'searchKey', ); }); it('should not call the fetchData function', () => { expect(fetchDataMock.fetchData).not.toHaveBeenCalled(); }); it('should call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith( 'oldquery', items, 'searchKey', ); }); }); }); }); describe('fetchData', () => { const { fetchData } = GfmAutoComplete.prototype; let mock; beforeEach(() => { mock = new MockAdapter(axios); jest.spyOn(axios, 'get'); jest.spyOn(AjaxCache, 'retrieve'); }); afterEach(() => { mock.restore(); }); describe('already loading data', () => { beforeEach(() => { const context = { isLoadingData: { '[vulnerability:': true }, dataSources: {}, cachedData: {}, }; fetchData.call(context, {}, '[vulnerability:', ''); }); it('should not call either axios nor AjaxCache', () => { expect(axios.get).not.toHaveBeenCalled(); expect(AjaxCache.retrieve).not.toHaveBeenCalled(); }); }); describe('backend filtering', () => { describe('data is not in cache', () => { let context; beforeEach(() => { context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, cachedData: {}, }; }); it('should call axios with query', () => { fetchData.call(context, {}, '[vulnerability:', 'query'); expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { params: { search: 'query' }, }); }); it.each([200, 500])('should set the loading state', async (responseStatus) => { mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus); fetchData.call(context, {}, '[vulnerability:', 'query'); expect(context.isLoadingData['[vulnerability:']).toBe(true); await waitForPromises(); expect(context.isLoadingData['[vulnerability:']).toBe(false); }); }); describe('data is in cache', () => { beforeEach(() => { const context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, cachedData: { '[vulnerability:': [{}] }, }; fetchData.call(context, {}, '[vulnerability:', 'query'); }); it('should anyway call axios with query ignoring cache', () => { expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { params: { search: 'query' }, }); }); }); }); describe('frontend filtering', () => { describe('data is not in cache', () => { beforeEach(() => { const context = { isLoadingData: { '#': false }, dataSources: { issues: 'issues_autocomplete_url' }, cachedData: {}, }; fetchData.call(context, {}, '#', 'query'); }); it('should call AjaxCache', () => { expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true); }); }); describe('data is in cache', () => { beforeEach(() => { const context = { isLoadingData: { '#': false }, dataSources: { issues: 'issues_autocomplete_url' }, cachedData: { '#': [{}] }, loadData: () => {}, }; fetchData.call(context, {}, '#', 'query'); }); it('should not call AjaxCache', () => { expect(AjaxCache.retrieve).not.toHaveBeenCalled(); }); }); }); }); describe('DefaultOptions.sorter', () => { describe('assets loading', () => { let items; beforeEach(() => { jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(true); atwhoInstance = { setting: {} }; items = []; sorterValue = gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, '', items); }); it('should disable highlightFirst', () => { expect(atwhoInstance.setting.highlightFirst).toBe(false); }); it('should return the passed unfiltered items', () => { expect(sorterValue).toEqual(items); }); }); describe('assets finished loading', () => { beforeEach(() => { jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(false); jest.spyOn($.fn.atwho.default.callbacks, 'sorter').mockImplementation(() => {}); }); it('should enable highlightFirst if alwaysHighlightFirst is set', () => { atwhoInstance = { setting: { alwaysHighlightFirst: true } }; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); it('should enable highlightFirst if a query is present', () => { atwhoInstance = { setting: {} }; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query'); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); it('should call the default atwho sorter', () => { atwhoInstance = { setting: {} }; const query = 'query'; const items = []; const searchKey = 'searchKey'; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey); expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey); }); }); }); describe('DefaultOptions.beforeInsert', () => { const beforeInsert = (context, value) => gfmAutoCompleteCallbacks.beforeInsert.call(context, value); beforeEach(() => { atwhoInstance = { setting: { skipSpecialCharacterTest: false } }; }); it('should not quote if value only contains alphanumeric charecters', () => { expect(beforeInsert(atwhoInstance, '@user1')).toBe('@user1'); expect(beforeInsert(atwhoInstance, '~label1')).toBe('~label1'); }); it('should quote if value contains any non-alphanumeric characters', () => { expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"'); expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"'); }); it('should quote integer labels', () => { expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"'); }); it('escapes Markdown strikethroughs when needed', () => { expect(beforeInsert(atwhoInstance, '~a~bug')).toEqual('~"a~bug"'); expect(beforeInsert(atwhoInstance, '~a~~bug~~')).toEqual('~"a\\~~bug\\~~"'); }); it('escapes Markdown emphasis when needed', () => { expect(beforeInsert(atwhoInstance, '~a_bug_')).toEqual('~a_bug\\_'); expect(beforeInsert(atwhoInstance, '~a _bug_')).toEqual('~"a \\_bug\\_"'); expect(beforeInsert(atwhoInstance, '~a*bug*')).toEqual('~"a\\*bug\\*"'); expect(beforeInsert(atwhoInstance, '~a *bug*')).toEqual('~"a \\*bug\\*"'); }); it('escapes Markdown code spans when needed', () => { expect(beforeInsert(atwhoInstance, '~a`bug`')).toEqual('~"a\\`bug\\`"'); expect(beforeInsert(atwhoInstance, '~a `bug`')).toEqual('~"a \\`bug\\`"'); }); }); describe('DefaultOptions.matcher', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); const flagsHash = flags.reduce((hash, el) => { hash[el] = null; return hash; }, {}); beforeEach(() => { atwhoInstance = { setting: {}, app: { controllers: flagsHash } }; }); const minLen = 1; const maxLen = 20; const argumentSize = [minLen, maxLen / 2, maxLen]; const allowedSymbols = [ '', 'a', 'n', 'z', 'A', 'Z', 'N', '0', '5', '9', 'А', 'а', 'Я', 'я', '.', "'", '-', '_', ]; const jointAllowedSymbols = allowedSymbols.join(''); describe('should match regular symbols', () => { flagsUseDefaultMatcher.forEach((flag) => { allowedSymbols.forEach((symbol) => { argumentSize.forEach((size) => { const query = new Array(size + 1).join(symbol); const subtext = flag + query; it(`matches argument "${flag}" with query "${subtext}"`, () => { expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(query); }); }); }); it(`matches combination of allowed symbols for flag "${flag}"`, () => { const subtext = flag + jointAllowedSymbols; expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(jointAllowedSymbols); }); }); }); describe('should not match special sequences', () => { const shouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']); const shouldNotBePrependedBy = ['`']; flagsUseDefaultMatcher.forEach((atSign) => { shouldNotBeFollowedBy.forEach((followedSymbol) => { const seq = atSign + followedSymbol; it(`should not match ${JSON.stringify(seq)}`, () => { expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); }); }); shouldNotBePrependedBy.forEach((prependedSymbol) => { const seq = prependedSymbol + atSign; it(`should not match "${seq}"`, () => { expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); }); }); }); }); }); describe('DefaultOptions.highlighter', () => { beforeEach(() => { atwhoInstance = { setting: {} }; }); it('should return li if no query is given', () => { const liTag = '
  • '; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag); expect(highlightedTag).toEqual(liTag); }); it('should highlight search query in li element', () => { const liTag = '
  • string
  • '; const query = 's'; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); expect(highlightedTag).toEqual('
  • string
  • '); }); it('should highlight search query with special char in li element', () => { const liTag = '
  • te.st
  • '; const query = '.'; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); expect(highlightedTag).toEqual('
  • te.st
  • '); }); }); describe('isLoading', () => { it('should be true with loading data object item', () => { expect(GfmAutoComplete.isLoading({ name: 'loading' })).toBe(true); }); it('should be true with loading data array', () => { expect(GfmAutoComplete.isLoading(['loading'])).toBe(true); }); it('should be true with loading data object array', () => { expect(GfmAutoComplete.isLoading([{ name: 'loading' }])).toBe(true); }); it('should be false with actual array data', () => { expect( GfmAutoComplete.isLoading([{ title: 'events' }, { title: 'Bar' }, { title: 'Qux' }]), ).toBe(false); }); it('should be false with actual data item', () => { expect(GfmAutoComplete.isLoading({ title: 'events' })).toBe(false); }); }); describe('membersBeforeSave', () => { const mockGroup = { username: 'my-group', name: 'My Group', count: 2, avatar_url: './group.jpg', type: 'Group', mentionsDisabled: false, }; it('should return the original object when username is null', () => { expect(membersBeforeSave([{ ...mockGroup, username: null }])).toEqual([ { ...mockGroup, username: null }, ]); }); it('should set the text avatar if avatar_url is null', () => { expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([ { username: 'my-group', avatarTag: '
    M
    ', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', }, ]); }); it('should set the image avatar if avatar_url is given', () => { expect(membersBeforeSave([mockGroup])).toEqual([ { username: 'my-group', avatarTag: 'my-group', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', }, ]); }); it('should set mentions disabled icon if mentionsDisabled is set', () => { expect(membersBeforeSave([{ ...mockGroup, mentionsDisabled: true }])).toEqual([ { username: 'my-group', avatarTag: 'my-group', title: 'My Group', search: 'MyGroup my-group', icon: '', }, ]); }); it('should set the right image classes for User type members', () => { expect( membersBeforeSave([ { username: 'my-user', name: 'My User', avatar_url: './users.jpg', type: 'User' }, ]), ).toEqual([ { username: 'my-user', avatarTag: 'my-user', title: 'My User', search: 'MyUser my-user', icon: '', }, ]); }); }); describe('Issues.insertTemplateFunction', () => { it('should return default template', () => { expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe( '${atwho-at}${id}', // eslint-disable-line no-template-curly-in-string ); }); it('should return reference when reference is set', () => { expect( GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue', reference: 'grp/proj#5', }), ).toBe('grp/proj#5'); }); }); describe('Issues.templateFunction', () => { it('should return html with id and title', () => { expect(GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue' })).toBe( '
  • 5 Some Issue
  • ', ); }); it('should replace id with reference if reference is set', () => { expect( GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue', reference: 'grp/proj#5', }), ).toBe('
  • grp/proj#5 Some Issue
  • '); }); it('escapes title in the template as it is user input', () => { expect( GfmAutoComplete.Issues.templateFunction({ id: 5, title: '${search}'; const escapedPayload = '<script>alert(1)</script>'; expect( GfmAutoComplete.Contacts.templateFunction({ email: xssPayload, firstName: xssPayload, lastName: xssPayload, }), ).toBe(`
  • ${escapedPayload} ${escapedPayload} ${escapedPayload}
  • `); }); }); describe('autocomplete show eventlisteners', () => { let $textarea; beforeEach(() => { setHTMLFixture(''); $textarea = $('textarea'); }); it('sets correct eventlisteners when autocomplete features are enabled', () => { const autocomplete = new GfmAutoComplete({}); autocomplete.setup($textarea); autocomplete.setupAtWho($textarea); /* eslint-disable-next-line no-underscore-dangle */ const events = $._data($textarea[0], 'events'); expect( Object.keys(events) .filter((x) => { return x.startsWith('shown'); }) .map((e) => { return { key: e, namespace: events[e][0].namespace }; }), ).toEqual(expect.arrayContaining(eventlistenersMockDefaultMap)); }); it('sets no eventlisteners when features are disabled', () => { const autocomplete = new GfmAutoComplete({}); autocomplete.setup($textarea, {}); autocomplete.setupAtWho($textarea); /* eslint-disable-next-line no-underscore-dangle */ const events = $._data($textarea[0], 'events'); expect( Object.keys(events) .filter((x) => { return x.startsWith('shown'); }) .map((e) => { return { key: e, namespace: events[e][0].namespace }; }), ).toStrictEqual([]); }); }); });