const { ObjectId } = require('mongodb') const should = require('chai').should() const SandboxedModule = require('sandboxed-module') const assert = require('assert') const moment = require('moment') const path = require('path') const sinon = require('sinon') const modulePath = path.join( __dirname, '../../../../app/src/Features/User/UserGetter' ) const { expect } = require('chai') const Errors = require('../../../../app/src/Features/Errors/Errors') const { normalizeQuery, normalizeMultiQuery } = require('../../../../app/src/Features/Helpers/Mongo') describe('UserGetter', function() { beforeEach(function() { this.fakeUser = { _id: '12390i', email: 'email2@foo.bar', emails: [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', confirmedAt: new Date() }, { email: 'email2@foo.bar', reversedHostname: 'rab.oof' } ] } this.findOne = sinon.stub().callsArgWith(2, null, this.fakeUser) this.findToArrayStub = sinon.stub().yields(null, [this.fakeUser]) this.find = sinon.stub().returns({ toArray: this.findToArrayStub }) this.Mongo = { db: { users: { findOne: this.findOne, find: this.find } }, ObjectId } this.getUserAffiliations = sinon.stub().resolves([]) this.UserGetter = SandboxedModule.require(modulePath, { globals: { console: console }, requires: { '../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery }, 'logger-sharelatex': { log() {} }, '../../infrastructure/mongodb': this.Mongo, '@overleaf/metrics': { timeAsyncMethod: sinon.stub() }, 'settings-sharelatex': (this.settings = { reconfirmNotificationDays: 14 }), '../Institutions/InstitutionsAPI': { promises: { getUserAffiliations: this.getUserAffiliations } }, '../../infrastructure/Features': { hasFeature: sinon.stub().returns(true) } } }) }) describe('getUser', function() { it('should get user', function(done) { const query = { _id: '000000000000000000000000' } const projection = { email: 1 } this.UserGetter.getUser(query, projection, (error, user) => { expect(error).to.not.exist this.findOne.called.should.equal(true) this.findOne.calledWith(query, { projection }).should.equal(true) user.should.deep.equal(this.fakeUser) done() }) }) it('should not allow null query', function(done) { this.UserGetter.getUser(null, {}, error => { error.should.exist error.message.should.equal('no query provided') done() }) }) }) describe('getUsers', function() { it('should get users with array of userIds', function(done) { const query = [new ObjectId()] const projection = { email: 1 } this.UserGetter.getUsers(query, projection, (error, users) => { expect(error).to.not.exist this.find.should.have.been.calledWithMatch( { _id: { $in: query } }, { projection } ) users.should.deep.equal([this.fakeUser]) done() }) }) it('should not allow null query', function(done) { this.UserGetter.getUser(null, {}, error => { error.should.exist error.message.should.equal('no query provided') done() }) }) }) describe('getUserFullEmails', function() { it('should get user', function(done) { this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) const projection = { email: 1, emails: 1, samlIdentifiers: 1 } this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist this.UserGetter.promises.getUser.called.should.equal(true) this.UserGetter.promises.getUser .calledWith(this.fakeUser._id, projection) .should.equal(true) done() } ) }) it('should fetch emails data', function(done) { this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', confirmedAt: this.fakeUser.emails[0].confirmedAt, emailHasInstitutionLicence: false, default: false }, { email: 'email2@foo.bar', reversedHostname: 'rab.oof', emailHasInstitutionLicence: false, default: true } ]) done() } ) }) it('should merge affiliation data', function(done) { this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) const affiliationsData = [ { email: 'email1@foo.bar', role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: { name: 'University Name', isUniversity: true, confirmed: true } } ] this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', confirmedAt: this.fakeUser.emails[0].confirmedAt, default: false, emailHasInstitutionLicence: true, affiliation: { institution: affiliationsData[0].institution, inferred: affiliationsData[0].inferred, department: affiliationsData[0].department, role: affiliationsData[0].role, licence: affiliationsData[0].licence, inReconfirmNotificationPeriod: false } }, { email: 'email2@foo.bar', reversedHostname: 'rab.oof', emailHasInstitutionLicence: false, default: true } ]) done() } ) }) it('should merge SAML identifier', function(done) { const fakeSamlIdentifiers = [ { providerId: 'saml_id', exteranlUserId: 'whatever' } ] const fakeUserWithSaml = this.fakeUser fakeUserWithSaml.emails[0].samlProviderId = 'saml_id' fakeUserWithSaml.samlIdentifiers = fakeSamlIdentifiers this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) this.getUserAffiliations.resolves([]) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', confirmedAt: this.fakeUser.emails[0].confirmedAt, default: false, emailHasInstitutionLicence: false, samlProviderId: 'saml_id', samlIdentifier: fakeSamlIdentifiers[0] }, { email: 'email2@foo.bar', reversedHostname: 'rab.oof', emailHasInstitutionLicence: false, default: true } ]) done() } ) }) it('should get user when it has no emails field', function(done) { this.fakeUser = { _id: '12390i', email: 'email2@foo.bar' } this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) const projection = { email: 1, emails: 1, samlIdentifiers: 1 } this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist this.UserGetter.promises.getUser.called.should.equal(true) this.UserGetter.promises.getUser .calledWith(this.fakeUser._id, projection) .should.equal(true) assert.deepEqual(fullEmails, []) done() } ) }) describe('affiliation reconfirmation', function() { const institutionNonSSO = { id: 1, name: 'University Name', commonsAccount: true, isUniversity: true, confirmed: true, ssoBeta: false, ssoEnabled: false, maxConfirmationMonths: 12 } const institutionSSO = { id: 2, name: 'SSO University Name', isUniversity: true, confirmed: true, ssoBeta: false, ssoEnabled: true, maxConfirmationMonths: 12 } describe('non-SSO institutions', function() { const email1 = 'leonard@example-affiliation.com' const email2 = 'mccoy@example-affiliation.com' const affiliationsData = [ { email: email1, role: 'Prof', department: 'Medicine', inferred: false, licence: 'pro_plus', institution: institutionNonSSO }, { email: email2, role: 'Prof', department: 'Medicine', inferred: false, licence: 'pro_plus', institution: institutionNonSSO } ] it('should flag inReconfirmNotificationPeriod for all affiliations in period', function(done) { const user = { _id: '12390i', email: email1, emails: [ { email: email1, reversedHostname: 'moc.noitailiffa-elpmaxe', confirmedAt: moment() .subtract( institutionNonSSO.maxConfirmationMonths + 2, 'months' ) .toDate(), default: true }, { email: email2, reversedHostname: 'moc.noitailiffa-elpmaxe', confirmedAt: moment() .subtract( institutionNonSSO.maxConfirmationMonths + 1, 'months' ) .toDate() } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(true) done() } ) }) it('should not flag affiliations outside of notification period', function(done) { const aboutToBeWithinPeriod = moment() .subtract(institutionNonSSO.maxConfirmationMonths, 'months') .add(15, 'days') .toDate() // expires in 15 days const user = { _id: '12390i', email: email1, emails: [ { email: email1, reversedHostname: 'moc.noitailiffa-elpmaxe', confirmedAt: new Date(), default: true }, { email: email2, reversedHostname: 'moc.noitailiffa-elpmaxe', confirmedAt: aboutToBeWithinPeriod } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(false) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(false) done() } ) }) }) describe('SSO institutions', function() { it('should flag only linked email, if in notification period', function(done) { const email1 = 'email1@sso.bar' const email2 = 'email2@sso.bar' const email3 = 'email3@sso.bar' const affiliationsData = [ { email: email1, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionSSO }, { email: email2, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionSSO }, { email: email3, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionSSO } ] const user = { _id: '12390i', email: email1, emails: [ { email: email1, reversedHostname: 'rab.oss', confirmedAt: new Date('2019-09-24'), reconfirmedAt: new Date('2019-09-24'), default: true }, { email: email2, reversedHostname: 'rab.oss', confirmedAt: new Date('2019-09-24'), reconfirmedAt: new Date('2019-09-24'), samlProviderId: institutionSSO.id }, { email: email3, reversedHostname: 'rab.oss', confirmedAt: new Date('2019-09-24'), reconfirmedAt: new Date('2019-09-24') } ], samlIdentifiers: [ { providerId: institutionSSO.id, externalUserId: 'abc123' } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(false) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[2].affiliation.inReconfirmNotificationPeriod ).to.equal(false) done() } ) }) }) describe('multiple institution affiliations', function() { it('should flag each institution', function(done) { const email1 = 'email1@sso.bar' const email2 = 'email2@sso.bar' const email3 = 'email3@foo.bar' const email4 = 'email4@foo.bar' const affiliationsData = [ { email: email1, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionSSO }, { email: email2, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionSSO }, { email: email3, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionNonSSO }, { email: email4, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionNonSSO } ] const user = { _id: '12390i', email: email1, emails: [ { email: email1, reversedHostname: 'rab.oss', confirmedAt: '2019-09-24T20:25:08.503Z', default: true }, { email: email2, reversedHostname: 'rab.oss', confirmedAt: new Date('2019-09-24T20:25:08.503Z'), samlProviderId: institutionSSO.id }, { email: email3, reversedHostname: 'rab.oof', confirmedAt: new Date('2019-10-24T20:25:08.503Z') }, { email: email4, reversedHostname: 'rab.oof', confirmedAt: new Date('2019-09-24T20:25:08.503Z') } ], samlIdentifiers: [ { providerId: institutionSSO.id, externalUserId: 'abc123' } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.to.equal(false) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[2].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[3].affiliation.inReconfirmNotificationPeriod ).to.equal(true) done() } ) }) }) describe('reconfirmedAt', function() { it('only use confirmedAt when no reconfirmedAt', function(done) { const email1 = 'email1@foo.bar' const email2 = 'email2@foo.bar' const email3 = 'email3@foo.bar' const affiliationsData = [ { email: email1, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionNonSSO }, { email: email2, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionNonSSO }, { email: email3, role: 'Prof', department: 'Maths', inferred: false, licence: 'pro_plus', institution: institutionNonSSO } ] const user = { _id: '12390i', email: email1, emails: [ { email: email1, reversedHostname: 'rab.oof', confirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 2, 'months' ), reconfirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 3, 'months' ), default: true }, { email: email2, reversedHostname: 'rab.oof', confirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 3, 'months' ), reconfirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 2, 'months' ) }, { email: email3, reversedHostname: 'rab.oof', confirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 4, 'months' ), reconfirmedAt: moment().subtract( institutionSSO.maxConfirmationMonths * 4, 'months' ) } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(true) expect( fullEmails[2].affiliation.inReconfirmNotificationPeriod ).to.equal(true) done() } ) }) }) describe('before reconfirmation period expires and within reconfirmation notification period', function() { const email = 'leonard@example-affiliation.com' it('should flag the email', function(done) { const confirmedAt = moment() .subtract(institutionNonSSO.maxConfirmationMonths, 'months') .subtract(14, 'days') .toDate() // expires in 14 days const affiliationsData = [ { email, role: 'Prof', department: 'Medicine', inferred: false, licence: 'pro_plus', institution: institutionNonSSO } ] const user = { _id: '12390i', email, emails: [ { email, confirmedAt, default: true } ] } this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(true) done() } ) }) }) describe('when no Settings.reconfirmNotificationDays', function() { it('should always return inReconfirmNotificationPeriod:false', function(done) { const email1 = 'email1@sso.bar' const email2 = 'email2@foo.bar' const email3 = 'email3@foo.bar' const confirmedAtAboutToExpire = moment() .subtract(institutionNonSSO.maxConfirmationMonths, 'months') .subtract(14, 'days') .toDate() // expires in 14 days const affiliationsData = [ { email: email1, institution: institutionSSO }, { email: email2, institution: institutionNonSSO }, { email: email3, institution: institutionNonSSO } ] const user = { _id: '12390i', email: email1, emails: [ { email: email1, confirmedAt: confirmedAtAboutToExpire, default: true, samlProviderId: institutionSSO.id }, { email: email2, confirmedAt: new Date('2019-09-24T20:25:08.503Z') }, { email: email3, confirmedAt: new Date('2019-10-24T20:25:08.503Z') } ], samlIdentifiers: [ { providerId: institutionSSO.id, externalUserId: 'abc123' } ] } this.settings.reconfirmNotificationDays = undefined this.getUserAffiliations.resolves(affiliationsData) this.UserGetter.promises.getUser = sinon.stub().resolves(user) this.UserGetter.getUserFullEmails( this.fakeUser._id, (error, fullEmails) => { expect(error).to.not.exist expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.to.equal(false) expect( fullEmails[1].affiliation.inReconfirmNotificationPeriod ).to.equal(false) expect( fullEmails[2].affiliation.inReconfirmNotificationPeriod ).to.equal(false) done() } ) }) }) }) }) describe('getUserbyMainEmail', function() { it('query user by main email', function(done) { const email = 'hello@world.com' const projection = { emails: 1 } this.UserGetter.getUserByMainEmail(email, projection, (error, user) => { expect(error).to.not.exist this.findOne.called.should.equal(true) this.findOne.calledWith({ email }, { projection }).should.equal(true) done() }) }) it('return user if found', function(done) { const email = 'hello@world.com' this.UserGetter.getUserByMainEmail(email, (error, user) => { expect(error).to.not.exist user.should.deep.equal(this.fakeUser) done() }) }) it('trim email', function(done) { const email = 'hello@world.com' this.UserGetter.getUserByMainEmail(` ${email} `, (error, user) => { expect(error).to.not.exist this.findOne.called.should.equal(true) this.findOne.calledWith({ email }).should.equal(true) done() }) }) }) describe('getUserByAnyEmail', function() { it('query user for any email', function(done) { const email = 'hello@world.com' const expectedQuery = { emails: { $exists: true }, 'emails.email': email } const projection = { emails: 1 } this.UserGetter.getUserByAnyEmail( ` ${email} `, projection, (error, user) => { expect(error).to.not.exist this.findOne .calledWith(expectedQuery, { projection }) .should.equal(true) user.should.deep.equal(this.fakeUser) done() } ) }) it('query contains $exists:true so partial index is used', function(done) { const expectedQuery = { emails: { $exists: true }, 'emails.email': '' } this.UserGetter.getUserByAnyEmail('', {}, (error, user) => { expect(error).to.not.exist this.findOne .calledWith(expectedQuery, { projection: {} }) .should.equal(true) done() }) }) it('checks main email as well', function(done) { this.findOne.callsArgWith(2, null, null) const email = 'hello@world.com' const projection = { emails: 1 } this.UserGetter.getUserByAnyEmail( ` ${email} `, projection, (error, user) => { expect(error).to.not.exist this.findOne.calledTwice.should.equal(true) this.findOne.calledWith({ email }, { projection }).should.equal(true) done() } ) }) }) describe('getUsersByHostname', function() { it('should find user by hostname', function(done) { const hostname = 'bar.foo' const expectedQuery = { emails: { $exists: true }, 'emails.reversedHostname': hostname .split('') .reverse() .join('') } const projection = { emails: 1 } this.UserGetter.getUsersByHostname( hostname, projection, (error, users) => { expect(error).to.not.exist this.find.calledOnce.should.equal(true) this.find.calledWith(expectedQuery, { projection }).should.equal(true) done() } ) }) }) describe('getUsersByAnyConfirmedEmail', function() { it('should find users by confirmed email', function(done) { const emails = ['confirmed@example.com'] this.UserGetter.getUsersByAnyConfirmedEmail(emails, (error, users) => { expect(error).to.not.exist expect(this.find).to.be.calledOnceWith( { 'emails.email': { $in: emails }, // use the index on emails.email emails: { $exists: true, $elemMatch: { email: { $in: emails }, confirmedAt: { $exists: true } } } }, { projection: {} } ) done() }) }) }) describe('getUsersByV1Id', function() { it('should find users by list of v1 ids', function(done) { const v1Ids = [501] const expectedQuery = { 'overleaf.id': { $in: v1Ids } } const projection = { emails: 1 } this.UserGetter.getUsersByV1Ids(v1Ids, projection, (error, users) => { expect(error).to.not.exist this.find.calledOnce.should.equal(true) this.find.calledWith(expectedQuery, { projection }).should.equal(true) done() }) }) }) describe('ensureUniqueEmailAddress', function() { beforeEach(function() { this.UserGetter.getUserByAnyEmail = sinon.stub() }) it('should return error if existing user is found', function(done) { this.UserGetter.getUserByAnyEmail.callsArgWith(1, null, this.fakeUser) this.UserGetter.ensureUniqueEmailAddress(this.newEmail, err => { should.exist(err) expect(err).to.be.an.instanceof(Errors.EmailExistsError) done() }) }) it('should return null if no user is found', function(done) { this.UserGetter.getUserByAnyEmail.callsArgWith(1) this.UserGetter.ensureUniqueEmailAddress(this.newEmail, err => { should.not.exist(err) done() }) }) }) })