diff --git a/app/api/auth/privateInstanceMiddleware.js b/app/api/auth/privateInstanceMiddleware.js index 2879ad3996..071ae297cb 100644 --- a/app/api/auth/privateInstanceMiddleware.js +++ b/app/api/auth/privateInstanceMiddleware.js @@ -1,9 +1,9 @@ import settings from '../settings'; -const allowedRoutes = ['login', 'setpassword/']; +const allowedRoutes = ['login', 'setpassword/', 'unlockaccount/']; const allowedRoutesMatch = new RegExp(allowedRoutes.join('|')); -const allowedApiCalls = ['/api/recoverpassword', '/api/resetpassword']; +const allowedApiCalls = ['/api/recoverpassword', '/api/resetpassword', '/api/unlockaccount']; const allowedApiMatch = new RegExp(allowedApiCalls.join('|')); const forbiddenRoutes = ['/api/', '/uploaded_documents/']; @@ -16,18 +16,13 @@ export default function (req, res, next) { return settings.get() .then((result) => { - if (result.private && req.url.match(forbiddenRoutesMatch)) { - if (req.url.match(allowedApiMatch)) { - next(); + if (result.private && !req.url.match(allowedApiMatch)) { + if (req.url.match(forbiddenRoutesMatch)) { + res.status(401); + res.json({ error: 'Unauthorized' }); return; } - res.status(401); - res.json({ error: 'Unauthorized' }); - return; - } - - if (result.private) { res.redirect('/login'); return; } diff --git a/app/api/auth/specs/privateInstanceMiddleware.spec.js b/app/api/auth/specs/privateInstanceMiddleware.spec.js index 45509f4bcf..1f09b6f868 100644 --- a/app/api/auth/specs/privateInstanceMiddleware.spec.js +++ b/app/api/auth/specs/privateInstanceMiddleware.spec.js @@ -70,6 +70,11 @@ describe('privateInstanceMiddleware', () => { req.url = 'host:port/api/resetpassword'; expectNextPromise(done); }); + + it('should allow the unlockaccount endpoint', (done) => { + req.url = 'host:port/api/unlockaccount'; + expectNextPromise(done); + }); }); describe('Other private-related calls', () => { @@ -119,5 +124,10 @@ describe('privateInstanceMiddleware', () => { req.url = 'url/setpassword/somehash'; expectNext(); }); + + it('should call next when instance is private and the url matches unlockaccount', () => { + req.url = 'url/unlockaccount/someAccount'; + expectNext(); + }); }); }); diff --git a/app/api/documents/specs/__snapshots__/routes.spec.js.snap b/app/api/documents/specs/__snapshots__/routes.spec.js.snap index 09cd0ea3a5..358d2cdf2f 100644 --- a/app/api/documents/specs/__snapshots__/routes.spec.js.snap +++ b/app/api/documents/specs/__snapshots__/routes.spec.js.snap @@ -116,6 +116,12 @@ Object { "items": Array [ Object { "children": Object { + "_id": Object { + "invalids": Array [ + "", + ], + "type": "string", + }, "filename": Object { "invalids": Array [ "", @@ -143,9 +149,10 @@ Object { }, "timestamp": Object { "invalids": Array [ - "", + Infinity, + -Infinity, ], - "type": "string", + "type": "number", }, }, "type": "object", diff --git a/app/api/entities/endpointSchema.js b/app/api/entities/endpointSchema.js index aaf09c3c58..3eddd0fc04 100644 --- a/app/api/entities/endpointSchema.js +++ b/app/api/entities/endpointSchema.js @@ -62,11 +62,12 @@ const saveSchema = Joi.object().keys({ }) })), attachments: Joi.array().items(Joi.object().keys({ + _id: Joi.string(), originalname: Joi.string(), filename: Joi.string(), mimetype: Joi.string(), size: Joi.number(), - timestamp: Joi.string(), + timestamp: Joi.number() })), creationDate: Joi.number(), processed: Joi.boolean(), diff --git a/app/api/entities/specs/__snapshots__/routes.spec.js.snap b/app/api/entities/specs/__snapshots__/routes.spec.js.snap index c3d8b4d777..eb045f74e1 100644 --- a/app/api/entities/specs/__snapshots__/routes.spec.js.snap +++ b/app/api/entities/specs/__snapshots__/routes.spec.js.snap @@ -432,6 +432,12 @@ Object { "items": Array [ Object { "children": Object { + "_id": Object { + "invalids": Array [ + "", + ], + "type": "string", + }, "filename": Object { "invalids": Array [ "", @@ -459,9 +465,10 @@ Object { }, "timestamp": Object { "invalids": Array [ - "", + Infinity, + -Infinity, ], - "type": "string", + "type": "number", }, }, "type": "object", diff --git a/app/api/migrations/migrations/12-add-RTL-to-settings-languages/index.js b/app/api/migrations/migrations/12-add-RTL-to-settings-languages/index.js index 81202f2fec..d0ee62fdc1 100644 --- a/app/api/migrations/migrations/12-add-RTL-to-settings-languages/index.js +++ b/app/api/migrations/migrations/12-add-RTL-to-settings-languages/index.js @@ -1,7 +1,3 @@ -export const rtlLanguagesList = [ - 'ar', 'dv', 'ha', 'he', 'ks', 'ku', 'ps', 'fa', 'ur', 'yi' -]; - export default { delta: 12, @@ -9,6 +5,8 @@ export default { description: 'Adds the missing RTL value for existing instances with RTL languages', + rtlLanguagesList: ['ar', 'dv', 'ha', 'he', 'ks', 'ku', 'ps', 'fa', 'ur', 'yi'], + async up(db) { process.stdout.write(`${this.name}...\r\n`); @@ -18,7 +16,7 @@ export default { languages = languages.map((l) => { const migratedLanguage = l; - if (rtlLanguagesList.includes(l.key)) { + if (this.rtlLanguagesList.includes(l.key)) { migratedLanguage.rtl = true; } diff --git a/app/api/migrations/migrations/12-add-RTL-to-settings-languages/specs/12-add-RTL-to-settings-languages.spec.js b/app/api/migrations/migrations/12-add-RTL-to-settings-languages/specs/12-add-RTL-to-settings-languages.spec.js index d512107b6d..07f9af7924 100644 --- a/app/api/migrations/migrations/12-add-RTL-to-settings-languages/specs/12-add-RTL-to-settings-languages.spec.js +++ b/app/api/migrations/migrations/12-add-RTL-to-settings-languages/specs/12-add-RTL-to-settings-languages.spec.js @@ -1,6 +1,6 @@ import { catchErrors } from 'api/utils/jasmineHelpers'; import testingDB from 'api/utils/testing_db'; -import migration, { rtlLanguagesList } from '../index.js'; +import migration from '../index.js'; import fixtures from './fixtures.js'; describe('migration add-RTL-to-settings-languages', () => { @@ -27,7 +27,7 @@ describe('migration add-RTL-to-settings-languages', () => { expect(rtlLanguages.length).toBe(10); rtlLanguages.forEach((language) => { - expect(rtlLanguagesList).toContain(language.key); + expect(migration.rtlLanguagesList).toContain(language.key); }); }); diff --git a/app/api/socketio/middleware.js b/app/api/socketio/middleware.js index fa2e4469a5..d7a11e0f11 100644 --- a/app/api/socketio/middleware.js +++ b/app/api/socketio/middleware.js @@ -9,7 +9,7 @@ export default (server, app) => { }); app.use((req, res, next) => { - req.io.getCurrentSessionSockets = () => { + req.getCurrentSessionSockets = () => { const sessionSockets = { sockets: [], emit(...args) { diff --git a/app/api/socketio/specs/middleware.spec.js b/app/api/socketio/specs/middleware.spec.js index 0dd6c972b3..1161b788c3 100644 --- a/app/api/socketio/specs/middleware.spec.js +++ b/app/api/socketio/specs/middleware.spec.js @@ -52,18 +52,29 @@ describe('socketio middleware', () => { }); it('should find and return an array of sockets belonging to the current cookie', () => { - let result = req.io.getCurrentSessionSockets(); + let result = req.getCurrentSessionSockets(); expect(result.sockets[0]).toBe(socket1); expect(result.sockets[1]).toBe(socket3); req.session.id = 'sessionId2'; executeMiddleware(req, res, next); - result = req.io.getCurrentSessionSockets(); + result = req.getCurrentSessionSockets(); expect(result.sockets[0]).toBe(socket2); }); + it('should isolate sockets for each requests when multiple requests are issued', () => { + const req1 = { ...req }; + const req2 = { ...req, session: { id: 'sessionId2' } }; + executeMiddleware(req1, res, next); + executeMiddleware(req2, res, next); + const req1Result = req1.getCurrentSessionSockets(); + const req2Result = req2.getCurrentSessionSockets(); + expect(req1Result.sockets).toEqual([socket1, socket3]); + expect(req2Result.sockets).toEqual([socket2]); + }); + it('should include in the result an "emit" function that emits to all the found sockets the sent message', () => { - const result = req.io.getCurrentSessionSockets(); + const result = req.getCurrentSessionSockets(); const data = { data: 'data' }; result.emit('Message', data); diff --git a/app/api/upload/routes.js b/app/api/upload/routes.js index a65a03a7b2..4bb3d24bd4 100644 --- a/app/api/upload/routes.js +++ b/app/api/upload/routes.js @@ -65,7 +65,7 @@ export default (app) => { const file = req.files[0].destination + req.files[0].filename; - const sessionSockets = req.io.getCurrentSessionSockets(); + const sessionSockets = req.getCurrentSessionSockets(); sessionSockets.emit('conversionStart', req.body.document); debugLog.debug(`Starting conversion of: ${req.files[0].originalname}`); return Promise.all([ @@ -106,7 +106,7 @@ export default (app) => { .then(() => { debugLog.debug('Saving documents'); return entities.saveMultiple(docs.map(doc => ({ ...doc, file: { ...doc.file, timestamp: Date.now() } }))).then(() => { - const sessionSockets = req.io.getCurrentSessionSockets(); + const sessionSockets = req.getCurrentSessionSockets(); sessionSockets.emit('documentProcessed', req.body.document); }); }); @@ -120,7 +120,7 @@ export default (app) => { entities.saveMultiple(docs.map(doc => ({ ...doc, processed: false }))); }); - const sessionSockets = req.io.getCurrentSessionSockets(); + const sessionSockets = req.getCurrentSessionSockets(); sessionSockets.emit('conversionFailed', req.body.document); }); diff --git a/app/api/upload/specs/routes.spec.js b/app/api/upload/specs/routes.spec.js index c61eac36ba..870fda903f 100644 --- a/app/api/upload/specs/routes.spec.js +++ b/app/api/upload/specs/routes.spec.js @@ -70,7 +70,6 @@ describe('upload routes', () => { spyOn(search, 'delete').and.returnValue(Promise.resolve()); spyOn(entities, 'indexEntities').and.returnValue(Promise.resolve()); iosocket = jasmine.createSpyObj('socket', ['emit']); - const io = { getCurrentSessionSockets: () => ({ sockets: [iosocket], emit: iosocket.emit }) }; routes = instrumentRoutes(uploadRoutes); file = { fieldname: 'file', @@ -82,7 +81,15 @@ describe('upload routes', () => { path: `${__dirname}/uploads/f2082bf51b6ef839690485d7153e847a.pdf`, size: 171411271 }; - req = { language: 'es', user: 'admin', headers: {}, body: { document: 'sharedId1' }, files: [file], io }; + req = { + language: 'es', + user: 'admin', + headers: {}, + body: { document: 'sharedId1' }, + files: [file], + io: {}, + getCurrentSessionSockets: () => ({ sockets: [iosocket], emit: iosocket.emit }) + }; db.clearAllAndLoad(fixtures).then(done).catch(catchErrors(done)); spyOn(errorLog, 'error'); //just to avoid annoying console output diff --git a/app/api/users/specs/users.spec.js b/app/api/users/specs/users.spec.js index 2b3c5caa84..74d324f7ff 100644 --- a/app/api/users/specs/users.spec.js +++ b/app/api/users/specs/users.spec.js @@ -32,6 +32,16 @@ describe('Users', () => { }) .catch(catchErrors(done))); + it('should not save a null password on update', async () => { + const user = { _id: recoveryUserId, role: 'admin' }; + + const [userInDb] = await users.get(recoveryUserId, '+password'); + await users.save(user, { _id: userId, role: 'admin' }); + const [updatedUser] = await users.get(recoveryUserId, '+password'); + + expect(updatedUser.password.toString()).toBe(userInDb.password.toString()); + }); + describe('when you try to change role', () => { it('should be an admin', (done) => { currentUser = { _id: userId, role: 'editor' }; diff --git a/app/api/users/users.js b/app/api/users/users.js index b2a1d4b57e..9ba54464f4 100644 --- a/app/api/users/users.js +++ b/app/api/users/users.js @@ -69,21 +69,20 @@ const sendAccountLockedEmail = (user, domain) => { }; export default { - save(user, currentUser) { - return model.get({ _id: user._id }) - .then(async ([userInTheDatabase]) => { - if (user._id === currentUser._id.toString() && user.role !== currentUser.role) { - return Promise.reject(createError('Can not change your own role', 403)); - } + async save(user, currentUser) { + const [userInTheDatabase] = await model.get({ _id: user._id }, '+password'); - if (user.hasOwnProperty('role') && user.role !== userInTheDatabase.role && currentUser.role !== 'admin') { - return Promise.reject(createError('Unauthorized', 403)); - } + if (user._id === currentUser._id.toString() && user.role !== currentUser.role) { + return Promise.reject(createError('Can not change your own role', 403)); + } + + if (user.hasOwnProperty('role') && user.role !== userInTheDatabase.role && currentUser.role !== 'admin') { + return Promise.reject(createError('Unauthorized', 403)); + } - return model.save({ - ...user, - password: user.password ? await encryptPassword(user.password) : undefined, - }); + return model.save({ + ...user, + password: user.password ? await encryptPassword(user.password) : userInTheDatabase.password, }); }, diff --git a/app/react/App/scss/elements/_controls.scss b/app/react/App/scss/elements/_controls.scss index afc5ae3bdc..927cb15d4c 100644 --- a/app/react/App/scss/elements/_controls.scss +++ b/app/react/App/scss/elements/_controls.scss @@ -228,7 +228,7 @@ input[type="checkbox"][disabled] ~ label { cursor: not-allowed; } -.icon-selector { +.icon-selector, .clear-field-button { button { background: none; border: none; diff --git a/app/react/Forms/components/Geolocation.js b/app/react/Forms/components/Geolocation.js index 720780a0e9..56bae5d6f5 100644 --- a/app/react/Forms/components/Geolocation.js +++ b/app/react/Forms/components/Geolocation.js @@ -1,6 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Map from 'app/Map/Map'; +import { Translate } from 'app/I18N'; + +function isCoordValid(coord) { + // eslint-disable-next-line no-restricted-globals + return typeof coord === 'number' && !isNaN(coord); +} export default class Geolocation extends Component { constructor(props) { @@ -8,9 +14,14 @@ export default class Geolocation extends Component { this.latChange = this.latChange.bind(this); this.lonChange = this.lonChange.bind(this); this.mapClick = this.mapClick.bind(this); + this.clearCoordinates = this.clearCoordinates.bind(this); } onChange(value) { + if (!isCoordValid(value.lat) && !isCoordValid(value.lon)) { + this.props.onChange(); + return; + } this.props.onChange(value); } @@ -28,6 +39,10 @@ export default class Geolocation extends Component { this.onChange({ lat: parseFloat(event.lngLat[1]), lon: parseFloat(event.lngLat[0]) }); } + clearCoordinates() { + this.props.onChange(); + } + render() { const markers = []; const { value } = this.props; @@ -39,7 +54,7 @@ export default class Geolocation extends Component { lon = value.lon; } - if (lat && lon) { + if (isCoordValid(lat) && isCoordValid(lon)) { markers.push({ latitude: parseFloat(value.lat), longitude: parseFloat(value.lon) }); } return ( @@ -55,6 +70,13 @@ export default class Geolocation extends Component { + { (isCoordValid(lat) || isCoordValid(lon)) && ( +
+ +
+ )} ); } diff --git a/app/react/Forms/components/specs/Geolocation.spec.js b/app/react/Forms/components/specs/Geolocation.spec.js index 9d504baadc..4c1aeebec4 100644 --- a/app/react/Forms/components/specs/Geolocation.spec.js +++ b/app/react/Forms/components/specs/Geolocation.spec.js @@ -46,4 +46,47 @@ describe('Geolocation', () => { expect(props.onChange).toHaveBeenCalledWith({ lat: 32.18, lon: 28 }); }); }); + + describe('empty lat/lon values', () => { + function testInputWillTriggerOnChangeWithoutValue(getInput) { + render(); + const inputs = component.find('input'); + const input = getInput(inputs); + input.simulate('change', { target: { value: '' } }); + expect(props.onChange.calls.argsFor(0)).toEqual([]); + } + describe('if lon is empty and lat is set to empty', () => { + it('should call onChange without a value', () => { + props.value.lon = ''; + testInputWillTriggerOnChangeWithoutValue(inputs => inputs.first()); + }); + }); + + describe('if lat is empty and lon is set to empty', () => { + it('should call onChange without a value', () => { + props.value.lat = ''; + testInputWillTriggerOnChangeWithoutValue(inputs => inputs.last()); + }); + }); + }); + + it('should render button to clear fields', () => { + render(); + expect(component).toMatchSnapshot(); + }); + + it('should hide clear fields button when lat and lon are empty', () => { + props.value = { lat: '', lon: '' }; + render(); + expect(component).toMatchSnapshot(); + }); + + describe('when clear fields button is clicked', () => { + it('should call onChange without a value', () => { + render(); + const button = component.find('.clear-field-button button').first(); + button.simulate('click'); + expect(props.onChange.calls.argsFor(0)).toEqual([]); + }); + }); }); diff --git a/app/react/Forms/components/specs/__snapshots__/Geolocation.spec.js.snap b/app/react/Forms/components/specs/__snapshots__/Geolocation.spec.js.snap new file mode 100644 index 0000000000..47ff76c1e7 --- /dev/null +++ b/app/react/Forms/components/specs/__snapshots__/Geolocation.spec.js.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Geolocation should hide clear fields button when lat and lon are empty 1`] = ` +
+ +
+
+ + +
+
+ + +
+
+
+`; + +exports[`Geolocation should render button to clear fields 1`] = ` +
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+`; diff --git a/app/react/Markdown/components/PayPalDonateLink.js b/app/react/Markdown/components/PayPalDonateLink.js index 24e39923cf..e93851d4c7 100644 --- a/app/react/Markdown/components/PayPalDonateLink.js +++ b/app/react/Markdown/components/PayPalDonateLink.js @@ -6,7 +6,7 @@ const PayPalDonateLink = ({ paypalid, classname, children, currency, amount }) = const amountParam = amount ? `&amount=${amount}` : ''; const url = `https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=${paypalid}¤cy_code=${currency}${amountParam}&source=url`; classname += ' paypal-donate'; - return {children}; + return {children}; }; PayPalDonateLink.defaultProps = { diff --git a/app/react/Markdown/components/specs/PayPalDonateLink.spec.js b/app/react/Markdown/components/specs/PayPalDonateLink.spec.js index 26e5398b0b..df67d6bb4d 100644 --- a/app/react/Markdown/components/specs/PayPalDonateLink.spec.js +++ b/app/react/Markdown/components/specs/PayPalDonateLink.spec.js @@ -6,7 +6,7 @@ describe('Link', () => { beforeEach(() => { spyOn(console, 'warn'); }); - it('should render a react-router Link', () => { + it('should render a react-router Link with target _blank', () => { const component = shallow(label); expect(component).toMatchSnapshot(); }); diff --git a/app/react/Markdown/components/specs/__snapshots__/PayPalDonateLink.spec.js.snap b/app/react/Markdown/components/specs/__snapshots__/PayPalDonateLink.spec.js.snap index b9d05c8950..dfdeb58c0c 100644 --- a/app/react/Markdown/components/specs/__snapshots__/PayPalDonateLink.spec.js.snap +++ b/app/react/Markdown/components/specs/__snapshots__/PayPalDonateLink.spec.js.snap @@ -5,7 +5,9 @@ exports[`Link should allow nesting children inside 1`] = ` className=" paypal-donate" href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=1234¤cy_code=EUR&source=url" onlyActiveOnIndex={false} + rel="noreferrer noopener" style={Object {}} + target="_blank" >
Extra content @@ -21,7 +23,9 @@ exports[`Link should allow to set a custom amount 1`] = ` className=" paypal-donate" href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=1234¤cy_code=EUR&amount=10.15&source=url" onlyActiveOnIndex={false} + rel="noreferrer noopener" style={Object {}} + target="_blank" > label @@ -32,18 +36,22 @@ exports[`Link should allow to set a custom class to the component 1`] = ` className=" paypal-donate" href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=1234¤cy_code=EUR&source=url" onlyActiveOnIndex={false} + rel="noreferrer noopener" style={Object {}} + target="_blank" > label `; -exports[`Link should render a react-router Link 1`] = ` +exports[`Link should render a react-router Link with target _blank 1`] = ` label diff --git a/app/react/Thesauris/components/ThesauriForm.js b/app/react/Thesauris/components/ThesauriForm.js index 946ab57ab3..686dbbbf51 100644 --- a/app/react/Thesauris/components/ThesauriForm.js +++ b/app/react/Thesauris/components/ThesauriForm.js @@ -16,7 +16,9 @@ export class ThesauriForm extends Component { static validation(thesauris, id) { return { name: { - duplicated: val => !thesauris.find(thesauri => thesauri._id !== id && thesauri.name.trim().toLowerCase() === val.trim().toLowerCase()), + duplicated: val => !thesauris.find(thesauri => thesauri.type !== 'template' && + thesauri._id !== id && + thesauri.name.trim().toLowerCase() === val.trim().toLowerCase()), required: notEmpty } }; diff --git a/app/react/Thesauris/components/specs/ThesauriForm.spec.js b/app/react/Thesauris/components/specs/ThesauriForm.spec.js index 6d153840f2..3885fa014c 100644 --- a/app/react/Thesauris/components/specs/ThesauriForm.spec.js +++ b/app/react/Thesauris/components/specs/ThesauriForm.spec.js @@ -134,4 +134,33 @@ describe('ThesauriForm', () => { expect(mapStateToProps(state).thesauris).toEqual(Immutable.fromJS([{ name: 'Countries' }])); }); }); + + describe('validation', () => { + describe('name duplicated', () => { + let thesauris; + let id; + beforeEach(() => { + thesauris = [ + { _id: 'id1', name: 'Countries' }, + { _id: 'id2', name: 'Cities' }, + { _id: 'id3', name: 'People', type: 'template' } + ]; + id = 'id1'; + }); + function testValidationResult(inputValue, expectedResult) { + const { name: { duplicated } } = ThesauriForm.validation(thesauris, id); + const res = duplicated(inputValue); + expect(res).toBe(expectedResult); + } + it('should return false if another thesaurus exists with the same name', () => { + testValidationResult('Cities', false); + }); + it('should return true if thesaurus with similar name is itself', () => { + testValidationResult('Countries', true); + }); + it('should return true if template has same name', () => { + testValidationResult('People', true); + }); + }); + }); });