diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index 2d602f00ba..5ac76f4b88 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/services/web/app/src/Features/Project/ProjectListController.js @@ -262,9 +262,15 @@ async function projectListPage(req, res, next) { delete req.session.saml } + function fakeDelay() { + return new Promise(resolve => { + setTimeout(() => resolve(undefined), 0) + }) + } + const prefetchedProjectsBlob = await Promise.race([ projectsBlobPending, - Promise.resolve(undefined), + fakeDelay(), ]) Metrics.inc('project-list-prefetch-projects', 1, { status: prefetchedProjectsBlob ? 'success' : 'too-slow', @@ -286,15 +292,16 @@ async function projectListPage(req, res, next) { // in v2 add notifications for matching university IPs if (Settings.overleaf != null && req.ip !== user.lastLoginIp) { - NotificationsBuilder.promises - .ipMatcherAffiliation(user._id) - .create(req.ip) - .catch(err => { - logger.error( - { err }, - 'failed to create institutional IP match notification' - ) - }) + try { + await NotificationsBuilder.promises + .ipMatcherAffiliation(user._id) + .create(req.ip) + } catch (err) { + logger.error( + { err }, + 'failed to create institutional IP match notification' + ) + } } const hasPaidAffiliation = userAffiliations.some( diff --git a/services/web/test/unit/src/Project/ProjectListControllerTests.js b/services/web/test/unit/src/Project/ProjectListControllerTests.js new file mode 100644 index 0000000000..a30ed68b7c --- /dev/null +++ b/services/web/test/unit/src/Project/ProjectListControllerTests.js @@ -0,0 +1,583 @@ +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const sinon = require('sinon') +const { expect } = require('chai') +const { ObjectId } = require('mongodb') +const Errors = require('../../../../app/src/Features/Errors/Errors') + +const MODULE_PATH = path.join( + __dirname, + '../../../../app/src/Features/Project/ProjectListController' +) + +describe('ProjectListController', function () { + beforeEach(function () { + this.project_id = ObjectId('abcdefabcdefabcdefabcdef') + + this.user = { + _id: ObjectId('123456123456123456123456'), + email: 'test@overleaf.com', + first_name: 'bjkdsjfk', + features: {}, + emails: [{ email: 'test@overleaf.com' }], + lastLoginIp: '111.111.111.112', + } + this.users = { + 'user-1': { + first_name: 'James', + }, + 'user-2': { + first_name: 'Henry', + }, + } + this.users[this.user._id] = this.user // Owner + this.usersArr = Object.entries(this.users).map(([key, value]) => ({ + _id: key, + ...value, + })) + this.tags = [ + { name: 1, project_ids: ['1', '2', '3'] }, + { name: 2, project_ids: ['a', '1'] }, + { name: 3, project_ids: ['a', 'b', 'c', 'd'] }, + ] + this.notifications = [ + { + _id: '1', + user_id: '2', + templateKey: '3', + messageOpts: '4', + key: '5', + }, + ] + this.settings = { + siteUrl: 'https://overleaf.com', + } + this.LimitationsManager = { + promises: { + userIsMemberOfGroupSubscription: sinon.stub().resolves(false), + }, + } + this.TagsHandler = { + promises: { + getAllTags: sinon.stub().resolves(this.tags), + }, + } + this.NotificationsHandler = { + promises: { + getUserNotifications: sinon.stub().resolves(this.notifications), + }, + } + this.UserModel = { + findById: sinon.stub().resolves(this.user), + } + this.UserPrimaryEmailCheckHandler = { + requiresPrimaryEmailCheck: sinon.stub().returns(false), + } + this.ProjectGetter = { + promises: { + findAllUsersProjects: sinon.stub(), + }, + } + this.ProjectHelper = { + isArchived: sinon.stub(), + isTrashed: sinon.stub(), + } + this.SessionManager = { + getLoggedInUserId: sinon.stub().returns(this.user._id), + } + this.UserController = { + logout: sinon.stub(), + } + this.UserGetter = { + promises: { + getUsers: sinon.stub().resolves(this.usersArr), + getUserFullEmails: sinon.stub().resolves([]), + }, + } + this.Features = { + hasFeature: sinon.stub(), + } + this.Metrics = { + inc: sinon.stub(), + } + this.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + } + this.SubscriptionViewModelBuilder = { + promises: { + getBestSubscription: sinon.stub().resolves({ type: 'free' }), + }, + } + this.SurveyHandler = { + promises: { + getSurvey: sinon.stub().resolves({}), + }, + } + this.NotificationBuilder = { + promises: { + ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }), + }, + } + + this.ProjectListController = SandboxedModule.require(MODULE_PATH, { + requires: { + mongodb: { ObjectId }, + '@overleaf/settings': this.settings, + '@overleaf/metrics': this.Metrics, + '../SplitTests/SplitTestHandler': this.SplitTestHandler, + '../User/UserController': this.UserController, + './ProjectHelper': this.ProjectHelper, + '../Subscription/LimitationsManager': this.LimitationsManager, + '../Tags/TagsHandler': this.TagsHandler, + '../Notifications/NotificationsHandler': this.NotificationsHandler, + '../../models/User': { User: this.UserModel }, + './ProjectGetter': this.ProjectGetter, + '../Authentication/SessionManager': this.SessionManager, + '../../infrastructure/Features': this.Features, + '../User/UserGetter': this.UserGetter, + '../Subscription/SubscriptionViewModelBuilder': + this.SubscriptionViewModelBuilder, + '../../infrastructure/Modules': { + promises: { + hooks: { fire: sinon.stub().resolves([]) }, + }, + }, + '../Survey/SurveyHandler': this.SurveyHandler, + '../User/UserPrimaryEmailCheckHandler': + this.UserPrimaryEmailCheckHandler, + '../Notifications/NotificationsBuilder': this.NotificationBuilder, + }, + }) + + this.req = { + query: {}, + params: { + Project_id: this.project_id, + }, + headers: {}, + session: { + user: this.user, + }, + body: {}, + i18n: { + translate() {}, + }, + } + this.res = {} + }) + + describe('projectListReactPage', function () { + beforeEach(function () { + this.projects = [ + { _id: 1, lastUpdated: 1, owner_ref: 'user-1' }, + { + _id: 2, + lastUpdated: 2, + owner_ref: 'user-2', + lastUpdatedBy: 'user-1', + }, + ] + this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] + this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] + this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] + this.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }] + this.allProjects = { + owned: this.projects, + readAndWrite: this.readAndWrite, + readOnly: this.readOnly, + tokenReadAndWrite: this.tokenReadAndWrite, + tokenReadOnly: this.tokenReadOnly, + } + + this.ProjectGetter.promises.findAllUsersProjects.resolves( + this.allProjects + ) + }) + + it('should render the project/list-react page', function (done) { + this.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should send the tags', function (done) { + this.res.render = (pageName, opts) => { + opts.tags.length.should.equal(this.tags.length) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should create trigger ip matcher notifications', function (done) { + this.settings.overleaf = true + this.req.ip = '111.111.111.111' + this.res.render = (pageName, opts) => { + this.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal( + true + ) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should send the projects', function (done) { + this.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + this.projects.length + + this.readAndWrite.length + + this.readOnly.length + + this.tokenReadAndWrite.length + + this.tokenReadOnly.length + ) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should send the user', function (done) { + this.res.render = (pageName, opts) => { + opts.user.should.deep.equal(this.user) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should inject the users', function (done) { + this.res.render = (pageName, opts) => { + const projects = opts.prefetchedProjectsBlob.projects + + projects + .filter(p => p.id === '1')[0] + .owner.firstName.should.equal( + this.users[this.projects.filter(p => p._id === 1)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .owner.firstName.should.equal( + this.users[this.projects.filter(p => p._id === 2)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .lastUpdatedBy.firstName.should.equal( + this.users[this.projects.filter(p => p._id === 2)[0].lastUpdatedBy] + .first_name + ) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it("should send the user's best subscription when saas feature present", function (done) { + this.Features.hasFeature.withArgs('saas').returns(true) + this.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should not return a best subscription without saas feature', function (done) { + this.Features.hasFeature.withArgs('saas').returns(false) + this.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.be.undefined + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + describe('With Institution SSO feature', function () { + beforeEach(function (done) { + this.institutionEmail = 'test@overleaf.com' + this.institutionName = 'Overleaf' + this.Features.hasFeature.withArgs('saml').returns(true) + this.Features.hasFeature.withArgs('affiliations').returns(true) + this.Features.hasFeature.withArgs('overleaf-integration').returns(true) + done() + }) + it('should show institution SSO available notification for confirmed domains', function () { + this.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test@overleaf.com', + affiliation: { + institution: { + id: 1, + confirmed: true, + name: 'Overleaf', + ssoBeta: false, + ssoEnabled: true, + }, + }, + }, + ]) + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.include({ + email: this.institutionEmail, + institutionId: 1, + institutionName: this.institutionName, + templateKey: 'notification_institution_sso_available', + }) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + it('should show a linked notification', function () { + this.req.session.saml = { + institutionEmail: this.institutionEmail, + linked: { + hasEntitlement: false, + universityName: this.institutionName, + }, + } + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.include({ + email: this.institutionEmail, + institutionName: this.institutionName, + templateKey: 'notification_institution_sso_linked', + }) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + it('should show a linked another email notification', function () { + // when they request to link an email but the institution returns + // a different email + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.include({ + institutionEmail: this.institutionEmail, + requestedEmail: 'requested@overleaf.com', + templateKey: 'notification_institution_sso_non_canonical', + }) + } + this.req.session.saml = { + emailNonCanonical: this.institutionEmail, + institutionEmail: this.institutionEmail, + requestedEmail: 'requested@overleaf.com', + linked: { + hasEntitlement: false, + universityName: this.institutionName, + }, + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should show a notification when intent was to register via SSO but account existed', function () { + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.include({ + email: this.institutionEmail, + templateKey: 'notification_institution_sso_already_registered', + }) + } + this.req.session.saml = { + institutionEmail: this.institutionEmail, + linked: { + hasEntitlement: false, + universityName: 'Overleaf', + }, + registerIntercept: { + id: 1, + name: 'Example University', + }, + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should not show a register notification if the flow was abandoned', function () { + // could initially start to register with an SSO email and then + // abandon flow and login with an existing non-institution SSO email + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.not.include({ + email: 'test@overleaf.com', + templateKey: 'notification_institution_sso_already_registered', + }) + } + this.req.session.saml = { + registerIntercept: { + id: 1, + name: 'Example University', + }, + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should show error notification', function () { + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution.length).to.equal(1) + expect(opts.notificationsInstitution[0].templateKey).to.equal( + 'notification_institution_sso_error' + ) + expect(opts.notificationsInstitution[0].error).to.be.instanceof( + Errors.SAMLAlreadyLinkedError + ) + } + this.req.session.saml = { + institutionEmail: this.institutionEmail, + error: new Errors.SAMLAlreadyLinkedError(), + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + describe('for an unconfirmed domain for an SSO institution', function () { + beforeEach(function (done) { + this.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test@overleaf-uncofirmed.com', + affiliation: { + institution: { + id: 1, + confirmed: false, + name: 'Overleaf', + ssoBeta: false, + ssoEnabled: true, + }, + }, + }, + ]) + done() + }) + it('should not show institution SSO available notification', function () { + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution.length).to.equal(0) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + }) + describe('when linking/logging in initiated on institution side', function () { + it('should not show a linked another email notification', function () { + // this is only used when initated on Overleaf, + // because we keep track of the requested email they tried to link + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.not.deep.include({ + institutionEmail: this.institutionEmail, + requestedEmail: undefined, + templateKey: 'notification_institution_sso_non_canonical', + }) + } + this.req.session.saml = { + emailNonCanonical: this.institutionEmail, + institutionEmail: this.institutionEmail, + linked: { + hasEntitlement: false, + universityName: this.institutionName, + }, + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + }) + describe('Institution with SSO beta testable', function () { + beforeEach(function (done) { + this.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'beta@beta.com', + affiliation: { + institution: { + id: 2, + confirmed: true, + name: 'Beta University', + ssoBeta: true, + ssoEnabled: false, + }, + }, + }, + ]) + done() + }) + it('should show institution SSO available notification when on a beta testing session', function () { + this.req.session.samlBeta = true + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.include({ + email: 'beta@beta.com', + institutionId: 2, + institutionName: 'Beta University', + templateKey: 'notification_institution_sso_available', + }) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + it('should not show institution SSO available notification when not on a beta testing session', function () { + this.req.session.samlBeta = false + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.not.include({ + email: 'test@overleaf.com', + institutionId: 1, + institutionName: 'Overleaf', + templateKey: 'notification_institution_sso_available', + }) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + }) + }) + + describe('Without Institution SSO feature', function () { + beforeEach(function (done) { + this.Features.hasFeature.withArgs('saml').returns(false) + done() + }) + it('should not show institution sso available notification', function () { + this.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.not.include({ + email: 'test@overleaf.com', + institutionId: 1, + institutionName: 'Overleaf', + templateKey: 'notification_institution_sso_available', + }) + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + }) + }) + + describe('projectListReactPage with duplicate projects', function () { + beforeEach(function () { + this.projects = [ + { _id: 1, lastUpdated: 1, owner_ref: 'user-1' }, + { _id: 2, lastUpdated: 2, owner_ref: 'user-2' }, + ] + this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] + this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] + this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] + this.tokenReadOnly = [ + { _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite + { _id: 7, lastUpdated: 4, owner_ref: 'user-5' }, + ] + this.allProjects = { + owned: this.projects, + readAndWrite: this.readAndWrite, + readOnly: this.readOnly, + tokenReadAndWrite: this.tokenReadAndWrite, + tokenReadOnly: this.tokenReadOnly, + } + + this.ProjectGetter.promises.findAllUsersProjects.resolves( + this.allProjects + ) + }) + + it('should render the project/list-react page', function (done) { + this.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + + it('should omit one of the projects', function (done) { + this.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + this.projects.length + + this.readAndWrite.length + + this.readOnly.length + + this.tokenReadAndWrite.length + + this.tokenReadOnly.length - + 1 + ) + done() + } + this.ProjectListController.projectListReactPage(this.req, this.res) + }) + }) +})