diff --git a/src/common/utils/paginationUtils.js b/src/common/utils/paginationUtils.js new file mode 100644 index 00000000..3eee443f --- /dev/null +++ b/src/common/utils/paginationUtils.js @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://www.bahmni.org/license/mplv2hd. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ + +// OpenMRS builds absolute pagination links from the request it sees behind the proxy +// (e.g. http://localhost/openmrs/...), which need not match the origin of the browser. +// Only the path and query of the `next` link are kept so that the request is made to the +// current origin. Returns undefined when there is no next page and throws for an invalid link. +export function getNextPageUrl(response) { + const links = Array.isArray(response && response.links) ? response.links : []; + const nextLink = links.find((link) => link.rel === 'next'); + if (!nextLink) { + return undefined; + } + let nextUrl; + try { + if (!nextLink.uri) { + throw new Error('missing uri'); + } + nextUrl = new URL(nextLink.uri, window.location.origin); + } catch (e) { + throw new Error(`Invalid pagination link: ${nextLink.uri}`); + } + // A path starting with two slashes would be read as a network-path reference to another host + if ((nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') + || /^[\\/]{2}/.test(nextUrl.pathname)) { + throw new Error(`Invalid pagination link: ${nextLink.uri}`); + } + return `${nextUrl.pathname}${nextUrl.search}`; +} diff --git a/src/form-builder/components/FormBuilderContainer.jsx b/src/form-builder/components/FormBuilderContainer.jsx index 96763810..dab14a81 100644 --- a/src/form-builder/components/FormBuilderContainer.jsx +++ b/src/form-builder/components/FormBuilderContainer.jsx @@ -18,6 +18,7 @@ import Spinner from 'common/Spinner'; import get from 'lodash/get'; import map from 'lodash/map'; import sortBy from 'lodash/sortBy'; +import { getNextPageUrl } from 'common/utils/paginationUtils'; import formHelper from '../helpers/formHelper'; import { fetchAllowedDomains } from '../helpers/hyperlinkValidationHelper'; import { connect } from 'react-redux'; @@ -53,31 +54,29 @@ export class FormBuilderContainer extends Component { const queryParams = '?='; const fetchFormsUrl = `${formBuilderConstants.formUrl}?v=custom:(id,uuid,name,version,published,auditInfo)`; return httpInterceptor.get(fetchFormsUrl) - .then((initialForms) => { - this.collectAllForms(initialForms, forms); - }) + .then((initialForms) => this.collectAllForms(initialForms, forms)) .catch((error) => { this.showErrors(error); this.setState({ loading: false }); }); } - collectAllForms(initialForms, forms) { + collectAllForms(initialForms, forms, requestedUrls = []) { forms.push(...initialForms.results); if (forms.length === formBuilderConstants.dataLimit) { this.setState({ data: this.orderFormByVersion(forms), loading: false }); - return; + return Promise.resolve(); } - if (initialForms.links !== undefined && initialForms.links.length > 0 && initialForms.links.find(link => link.rel === 'next') !== undefined) { - httpInterceptor.get(initialForms.links[0].uri) - .then((privileges) => this.collectAllForms(privileges, forms)) - .catch((error) => { - this.showErrors(error); - this.setState({ loading: false }); - }); - } else { + const nextUrl = getNextPageUrl(initialForms); + if (!nextUrl) { this.setState({ data: this.orderFormByVersion(forms), loading: false }); + return Promise.resolve(); + } + if (requestedUrls.includes(nextUrl)) { + throw new Error('Unable to load all forms: repeated pagination link'); } + return httpInterceptor.get(nextUrl) + .then((nextForms) => this.collectAllForms(nextForms, forms, [...requestedUrls, nextUrl])); } getDefaultLocale() { diff --git a/src/form-builder/components/FormPrivilegeTable.jsx b/src/form-builder/components/FormPrivilegeTable.jsx index 3a9c48fa..2a7665ff 100644 --- a/src/form-builder/components/FormPrivilegeTable.jsx +++ b/src/form-builder/components/FormPrivilegeTable.jsx @@ -18,6 +18,7 @@ import { selectSource, setChangedProperty } from 'form-builder/actions/control'; import { commonConstants } from 'common/constants'; import filter from 'lodash/filter'; import { httpInterceptor } from 'common/utils/httpInterceptor'; +import { getNextPageUrl } from 'common/utils/paginationUtils'; import Select from 'react-select'; import { UrlHelper } from 'form-builder/helpers/UrlHelper'; import sortBy from 'lodash/sortBy'; @@ -107,13 +108,8 @@ export default class FormPrivilegeTable extends Component { this.setState({ availablePrivileges: this.arrangePrivileges(allPrivileges), loading: false }); return; } - if (initialPrivileges.links !== undefined && initialPrivileges.links.length > 0) { - const nextLink = initialPrivileges.links.find(link => link.rel === 'next'); - if (!nextLink) { - this.setState({ availablePrivileges: this.arrangePrivileges(allPrivileges), loading: false }); - return; - } - const nextUri = this.getNextPaginationRequestUrl(nextLink.uri); + const nextUri = getNextPageUrl(initialPrivileges); + if (nextUri) { httpInterceptor.get(nextUri) .then((privileges) => this.collectAllPrivileges(privileges, allPrivileges)); } else { @@ -301,17 +297,6 @@ export default class FormPrivilegeTable extends Component { return formPrivilegeObj; } - getNextPaginationRequestUrl(uri) { - if (uri.indexOf('http') === -1) { - return uri; - } - const parsedUrl = new URL(uri); - if (parsedUrl.protocol != window.location.protocol) { - parsedUrl.protocol = window.location.protocol; - } - return parsedUrl.toString(); - } - removeSelectedPrivilege(e) { let array = this.state.availablePrivileges.filter((item) => item.label !== e); this.setState({ diff --git a/test/common/utils/paginationUtils.spec.js b/test/common/utils/paginationUtils.spec.js new file mode 100644 index 00000000..6b6a967b --- /dev/null +++ b/test/common/utils/paginationUtils.spec.js @@ -0,0 +1,91 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://www.bahmni.org/license/mplv2hd. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ + +import { expect } from 'chai'; +import { getNextPageUrl } from 'common/utils/paginationUtils'; + +describe('paginationUtils', () => { + describe('getNextPageUrl', () => { + it('should drop the origin of an absolute http link and keep path and encoded query', () => { + const uri = 'http://localhost/openmrs/ws/rest/v1/form?v=custom%3A%28id%2Cuuid%2Cname%29' + + '&limit=2&startIndex=2'; + expect(getNextPageUrl({ links: [{ rel: 'next', uri }] })) + .to.eql('/openmrs/ws/rest/v1/form?v=custom%3A%28id%2Cuuid%2Cname%29&limit=2&startIndex=2'); + }); + + it('should return the path and query of a link on the current origin', () => { + const uri = `${window.location.origin}/openmrs/ws/rest/v1/form?limit=2&startIndex=4`; + expect(getNextPageUrl({ links: [{ rel: 'next', uri }] })) + .to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=4'); + }); + + it('should keep a relative link unchanged', () => { + expect(getNextPageUrl({ links: [{ rel: 'next', uri: '/openmrs/ws/rest/v1/form?limit=2' }] })) + .to.eql('/openmrs/ws/rest/v1/form?limit=2'); + }); + + it('should pick the link with rel next when it is not the first link', () => { + const response = { links: [ + { rel: 'prev', uri: 'http://localhost/openmrs/ws/rest/v1/form?limit=2&' }, + { rel: 'next', uri: 'http://localhost/openmrs/ws/rest/v1/form?limit=2&startIndex=6' }, + ] }; + expect(getNextPageUrl(response)).to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=6'); + }); + + it('should return undefined when there is no next link', () => { + expect(getNextPageUrl({ results: [] })).to.eql(undefined); + expect(getNextPageUrl({ links: [] })).to.eql(undefined); + expect(getNextPageUrl({ links: [{ rel: 'prev', uri: '/a' }] })).to.eql(undefined); + expect(getNextPageUrl(undefined)).to.eql(undefined); + }); + + it('should throw for a next link that is missing or invalid', () => { + expect(() => getNextPageUrl({ links: [{ rel: 'next' }] })).to.throw('Invalid pagination'); + expect(() => getNextPageUrl({ links: [{ rel: 'next', uri: 'http://' }] })) + .to.throw('Invalid pagination'); + expect(() => getNextPageUrl({ links: [{ rel: 'next', uri: 'ftp://localhost/openmrs' }] })) + .to.throw('Invalid pagination'); + }); + + it('should throw for a next link whose path could resolve to another host', () => { + ['http://localhost//evil.com/a', 'http://localhost/\\evil.com/a', 'http://localhost///evil.com'] + .forEach((uri) => { + expect(() => getNextPageUrl({ links: [{ rel: 'next', uri }] })) + .to.throw('Invalid pagination'); + }); + }); + + it('should keep only path and query of a protocol-relative link to another host', () => { + const uri = '//evil.example/openmrs/ws/rest/v1/form?limit=2&startIndex=2'; + expect(getNextPageUrl({ links: [{ rel: 'next', uri }] })) + .to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=2'); + }); + + it('should throw for dot-segment and protocol-relative paths that normalise to //host', () => { + ['http://localhost/.//evil.com/a', 'http://localhost/a/..//evil.com/a'] + .forEach((uri) => { + expect(() => getNextPageUrl({ links: [{ rel: 'next', uri }] })) + .to.throw('Invalid pagination'); + }); + }); + + it('should never return a value that resolves to another origin', () => { + ['http://other.example/openmrs/a?x=1', '//evil.example/a', '///evil.com/a', 'https://evil.example:8443/a?b=%2F%2F'] + .forEach((uri) => { + const result = getNextPageUrl({ links: [{ rel: 'next', uri }] }); + expect(new URL(result, window.location.origin).origin).to.eql(window.location.origin); + }); + }); + + it('should ignore links that are not an array', () => { + expect(getNextPageUrl({ links: {} })).to.eql(undefined); + expect(getNextPageUrl({ links: 'next' })).to.eql(undefined); + }); + }); +}); diff --git a/test/form-builder/components/FormBuilderContainer.spec.js b/test/form-builder/components/FormBuilderContainer.spec.js new file mode 100644 index 00000000..058427f2 --- /dev/null +++ b/test/form-builder/components/FormBuilderContainer.spec.js @@ -0,0 +1,231 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://www.bahmni.org/license/mplv2hd. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { FormBuilderContainer } from 'form-builder/components/FormBuilderContainer.jsx'; +import { httpInterceptor } from 'common/utils/httpInterceptor'; +import { formBuilderConstants } from 'form-builder/constants'; +import { commonConstants } from 'common/constants'; + +describe('FormBuilderContainer pagination', () => { + const formsUrl = + `${formBuilderConstants.formUrl}?v=custom:(id,uuid,name,version,published,auditInfo)`; + const pageUrl = (startIndex) => + `${formBuilderConstants.formUrl}?v=custom%3A%28id%29&limit=2&startIndex=${startIndex}`; + const absolutePageUrl = (startIndex) => `http://localhost${pageUrl(startIndex)}`; + const form = (name) => ({ uuid: name, name, version: '1' }); + let getStub; + let wrapper; + + const mountContainer = () => { + wrapper = shallow( {}} />, + { disableLifecycleMethods: true, context: { router: {} } }); + return wrapper.instance(); + }; + + beforeEach(() => { + getStub = sinon.stub(httpInterceptor, 'get'); + }); + + afterEach(() => { + getStub.restore(); + }); + + it('should request next pages from the current origin and merge all pages', () => { + getStub.withArgs(formsUrl).returns(Promise.resolve({ + results: [form('b'), form('a')], + links: [{ rel: 'next', uri: absolutePageUrl(2) }], + })); + getStub.withArgs(pageUrl(2)).returns(Promise.resolve({ + results: [form('d'), form('c')], + links: [ + { rel: 'prev', uri: 'http://localhost/openmrs/ws/rest/v1/form?limit=2&' }, + { rel: 'next', uri: absolutePageUrl(4) }, + ], + })); + getStub.withArgs(pageUrl(4)).returns(Promise.resolve({ results: [form('e')], links: [] })); + + return mountContainer().getFormData().then(() => { + expect(getStub.args.map((args) => args[0])).to.eql([formsUrl, pageUrl(2), pageUrl(4)]); + expect(wrapper.state('data').map((f) => f.name)).to.eql(['a', 'b', 'c', 'd', 'e']); + expect(wrapper.state('loading')).to.eql(false); + }); + }); + + it('should show an error and no partial list when a later page fails', () => { + getStub.withArgs(formsUrl).returns(Promise.resolve({ + results: [form('a')], + links: [{ rel: 'next', uri: absolutePageUrl(2) }], + })); + getStub.withArgs(pageUrl(2)).returns(Promise.reject(new Error('Failed to fetch'))); + + return mountContainer().getFormData().then(() => { + expect(wrapper.state('data')).to.eql([]); + expect(wrapper.state('loading')).to.eql(false); + expect(wrapper.state('notification')).to.eql( + { message: 'Failed to fetch', type: commonConstants.responseType.error }); + }); + }); + + it('should show an error instead of looping when the next link repeats', () => { + getStub.withArgs(formsUrl).returns(Promise.resolve({ + results: [form('a')], + links: [{ rel: 'next', uri: absolutePageUrl(2) }], + })); + getStub.withArgs(pageUrl(2)).returns(Promise.resolve({ + results: [form('b')], + links: [{ rel: 'next', uri: absolutePageUrl(2) }], + })); + + return mountContainer().getFormData().then(() => { + expect(getStub.callCount).to.eql(2); + expect(wrapper.state('data')).to.eql([]); + expect(wrapper.state('loading')).to.eql(false); + expect(wrapper.state('notification').type).to.eql(commonConstants.responseType.error); + expect(wrapper.state('notification').message).to.contain('repeated pagination link'); + }); + }); + + it('should show an error when the next link is invalid', () => { + getStub.withArgs(formsUrl).returns(Promise.resolve({ + results: [form('a')], + links: [{ rel: 'next', uri: 'http://' }], + })); + + return mountContainer().getFormData().then(() => { + expect(getStub.callCount).to.eql(1); + expect(wrapper.state('data')).to.eql([]); + expect(wrapper.state('loading')).to.eql(false); + expect(wrapper.state('notification').message).to.contain('Invalid pagination link'); + }); + }); + + it('should stop without requesting another page when the data limit is reached', () => { + const results = Array.from({ length: formBuilderConstants.dataLimit }, (_, i) => form(`f${i}`)); + getStub.withArgs(formsUrl).returns(Promise.resolve({ + results, + links: [{ rel: 'next', uri: absolutePageUrl(2) }], + })); + + return mountContainer().getFormData().then(() => { + expect(getStub.callCount).to.eql(1); + expect(wrapper.state('data').length).to.eql(formBuilderConstants.dataLimit); + }); + }); + + it('should show the error message from the response when the first request fails', () => { + const body = { error: { globalErrors: [{ message: 'boom' }] } }; + const response = { json: () => Promise.resolve(body) }; + const error = new Error('Server Error'); + error.response = response; + getStub.withArgs(formsUrl).returns(Promise.reject(error)); + + return mountContainer().getFormData() + .then(() => new Promise((resolve) => setTimeout(resolve, 0))) + .then(() => { + expect(wrapper.state('loading')).to.eql(false); + expect(wrapper.state('notification').message).to.eql('boom'); + }); + }); + + describe('other actions', () => { + let dispatch; + + const mountWithContext = (context) => { + dispatch = sinon.spy(); + wrapper = shallow(, + { disableLifecycleMethods: true, context: { router: context } }); + return wrapper.instance(); + }; + + let setItem; + + beforeEach(() => { + setItem = sinon.spy(); + window.localStorage = { setItem }; + }); + + afterEach(() => { + delete window.localStorage; + }); + + it('should set the default locale and stop loading', () => { + getStub.withArgs(formBuilderConstants.defaultLocaleUrl, 'text') + .returns(Promise.resolve('fr')); + const container = mountWithContext({}); + + container.getDefaultLocale(); + + return new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + expect(dispatch.calledOnce).to.eql(true); + expect(setItem.calledWith('openmrsDefaultLocale', 'fr')).to.eql(true); + expect(wrapper.state('loading')).to.eql(false); + }); + }); + + it('should show an error when the default locale cannot be fetched', () => { + getStub.withArgs(formBuilderConstants.defaultLocaleUrl, 'text') + .returns(Promise.reject(new Error('locale failed'))); + const container = mountWithContext({}); + container.getDefaultLocale(); + + return new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + expect(wrapper.state('loading')).to.eql(false); + expect(wrapper.state('notification').message).to.eql('locale failed'); + }); + }); + + it('should show validation errors as error notification', () => { + mountWithContext({}).onValidationError('invalid file'); + expect(wrapper.state('notification')).to.eql( + { message: 'invalid file', type: commonConstants.responseType.error }); + }); + + it('should reject an invalid form name without posting', () => { + const postStub = sinon.stub(httpInterceptor, 'post'); + mountWithContext({}).saveForm({ name: ' bad name' }); + expect(postStub.called).to.eql(false); + expect(wrapper.state('notification').type).to.eql(commonConstants.responseType.error); + postStub.restore(); + }); + + it('should navigate to the new form after it is saved', () => { + const push = sinon.spy(); + const postStub = sinon.stub(httpInterceptor, 'post') + .returns(Promise.resolve({ uuid: 'newUuid' })); + mountWithContext({ history: { push } }).saveForm({ name: 'goodName' }); + + return new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + expect(push.calledWith('/form-builder/newUuid')).to.eql(true); + postStub.restore(); + }); + }); + + it('should show an error when saving the form fails', () => { + const postStub = sinon.stub(httpInterceptor, 'post') + .returns(Promise.reject(new Error('save failed'))); + mountWithContext({}).saveForm({ name: 'goodName' }); + + return new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + expect(wrapper.state('notification').message).to.eql('save failed'); + postStub.restore(); + }); + }); + + it('should render the form builder with the loaded data', () => { + mountWithContext({}); + wrapper.setState({ data: [form('a')], loading: false }); + expect(wrapper.find('Connect').length + wrapper.find('FormBuilder').length) + .to.be.greaterThan(0); + }); + }); +}); diff --git a/test/form-builder/components/FormPrivilegeTable.spec.js b/test/form-builder/components/FormPrivilegeTable.spec.js index a3b4c6e0..b75315ec 100644 --- a/test/form-builder/components/FormPrivilegeTable.spec.js +++ b/test/form-builder/components/FormPrivilegeTable.spec.js @@ -13,6 +13,7 @@ import FormPrivilegeTable from 'form-builder/components/FormPrivilegeTable.jsx'; import sinon from 'sinon'; import chaiEnzyme from 'chai-enzyme'; import chai, { expect } from 'chai'; +import { httpInterceptor } from 'common/utils/httpInterceptor'; chai.use(chaiEnzyme()); @@ -118,3 +119,43 @@ describe('FormPrivilegeTable', () => { // expect(wrapper.state('formPrivileges')[0].privilegeName).to.eql("abcd"); }); }); + +describe('FormPrivilegeTable pagination', () => { + let wrapper; + let getStub; + + beforeEach(() => { + getStub = sinon.stub(httpInterceptor, 'get').returns(new Promise(() => {})); + wrapper = shallow(); + getStub.resetHistory(); + }); + + afterEach(() => { + getStub.restore(); + }); + + it('should request the next page from the current origin and merge all pages', () => { + const next = { + results: [{ name: 'b' }], + links: [], + }; + getStub.returns(Promise.resolve(next)); + const first = { + results: [{ name: 'a' }], + links: [{ rel: 'next', uri: 'http://localhost//openmrs/ws/rest/v1/privilege?limit=1' }], + }; + expect(() => wrapper.instance().collectAllPrivileges(first, [])).to.throw('Invalid pagination'); + first.links[0].uri = 'http://other.example/openmrs/ws/rest/v1/privilege?limit=1&startIndex=1'; + const all = []; + wrapper.instance().collectAllPrivileges(first, all); + sinon.assert.calledWith(getStub, '/openmrs/ws/rest/v1/privilege?limit=1&startIndex=1'); + return Promise.resolve().then(() => Promise.resolve()).then(() => { + expect(all.map((p) => p.name)).to.eql(['a', 'b']); + }); + }); + + it('should not request another page when there is no next link', () => { + wrapper.instance().collectAllPrivileges({ results: [{ name: 'a' }], links: [] }, []); + sinon.assert.notCalled(getStub); + }); +});