first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const path = require('node:path')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Analytics/AccountMappingHelper'
)
describe('AccountMappingHelper', function () {
beforeEach(function () {
this.AccountMappingHelper = SandboxedModule.require(MODULE_PATH)
})
describe('extractAccountMappingsFromSubscription', function () {
describe('when the v1 id is the same in the updated subscription and the subscription', function () {
describe('when the salesforce id is the same in the updated subscription and the subscription', function () {
beforeEach(function () {
this.subscription = {
id: new ObjectId('abc123abc123abc123abc123'),
salesforce_id: 'def456def456def456',
}
this.updatedSubscription = { salesforce_id: 'def456def456def456' }
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an empty array', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(0)
})
})
describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
beforeEach(function () {
this.subscription = {
id: new ObjectId('abc123abc123abc123abc123'),
salesforce_id: 'def456def456def456',
}
this.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' }
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an array with a single item', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(1)
})
it('uses "account" as sourceEntity', function () {
expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
})
it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.salesforce_id
)
})
it('uses "subscription" as targetEntity', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntity',
'subscription'
)
})
it('uses the subscriptionId as targetEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
})
describe('when the update subscription has a salesforce id and the subscription has no salesforce_id', function () {
beforeEach(function () {
this.subscription = { id: new ObjectId('abc123abc123abc123abc123') }
this.updatedSubscription = { salesforce_id: 'def456def456def456' }
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an array with a single item', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(1)
})
it('uses "account" as sourceEntity', function () {
expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
})
it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.salesforce_id
)
})
it('uses "subscription" as targetEntity', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntity',
'subscription'
)
})
it('uses the subscriptionId as targetEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
})
})
describe('when the v1 id has changed between the subscription and the updated subscription', function () {
describe('when the salesforce id has not changed between the subscription and the updated subscription', function () {
beforeEach(function () {
this.subscription = {
id: new ObjectId('abc123abc123abc123abc123'),
v1_id: '1',
salesforce_id: '',
}
this.updatedSubscription = { v1_id: '2', salesforce_id: '' }
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an array with a single item', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(1)
})
it('uses "university" as the sourceEntity', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntity',
'university'
)
})
it('uses the v1_id from the updated subscription as the sourceEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.v1_id
)
})
it('uses "subscription" as the targetEntity', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntity',
'subscription'
)
})
it('uses the subscription id as the targetEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
})
describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
beforeEach(function () {
this.subscription = {
id: new ObjectId('abc123abc123abc123abc123'),
v1_id: '',
salesforce_id: 'def456def456def456',
}
this.updatedSubscription = {
v1_id: '2',
salesforce_id: '',
}
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an array with two items', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(2)
})
it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.salesforce_id
)
})
it('uses the subscription id as the targetEntityId for the first item', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function () {
expect(this.result[1]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.v1_id
)
})
it('uses the subscription id as the targetEntityId for the second item', function () {
expect(this.result[1]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
})
})
})
describe('when the recurlySubscription_id has changed between the subscription and the updated subscription', function () {
beforeEach(function () {
this.subscription = {
id: new ObjectId('abc123abc123abc123abc123'),
recurlySubscription_id: '',
}
this.updatedSubscription = {
recurlySubscription_id: '1234a5678b90123cd4567e8f901a2b34',
}
this.result =
this.AccountMappingHelper.extractAccountMappingsFromSubscription(
this.subscription,
this.updatedSubscription
)
})
it('returns an array with one item', function () {
expect(this.result).to.be.an('array')
expect(this.result).to.have.length(1)
})
it('uses "recurly" as the source', function () {
expect(this.result[0]).to.haveOwnProperty('source', 'recurly')
})
it('uses "subscription" as the sourceEntity', function () {
expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'subscription')
})
it('uses the recurlySubscription_id as the sourceEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'sourceEntityId',
this.updatedSubscription.recurlySubscription_id
)
})
it('uses "v2" as the target', function () {
expect(this.result[0]).to.haveOwnProperty('target', 'v2')
})
it('uses "subscription" as the targetEntity', function () {
expect(this.result[0]).to.haveOwnProperty('targetEntity', 'subscription')
})
it('uses the subscription id as the targetEntityId', function () {
expect(this.result[0]).to.haveOwnProperty(
'targetEntityId',
this.subscription.id
)
})
})
})

View File

@@ -0,0 +1,113 @@
import esmock from 'esmock'
import sinon from 'sinon'
import MockResponse from '../helpers/MockResponse.js'
const modulePath = new URL(
'../../../../app/src/Features/Analytics/AnalyticsController.mjs',
import.meta.url
).pathname
describe('AnalyticsController', function () {
beforeEach(async function () {
this.SessionManager = { getLoggedInUserId: sinon.stub() }
this.AnalyticsManager = {
updateEditingSession: sinon.stub(),
recordEventForSession: sinon.stub(),
}
this.Features = {
hasFeature: sinon.stub().returns(true),
}
this.controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Analytics/AnalyticsManager.js':
this.AnalyticsManager,
'../../../../app/src/Features/Authentication/SessionManager.js':
this.SessionManager,
'../../../../app/src/infrastructure/Features.js': this.Features,
'../../../../app/src/infrastructure/GeoIpLookup.js': (this.GeoIpLookup = {
promises: {
getDetails: sinon.stub().resolves(),
},
}),
})
this.res = new MockResponse()
})
describe('updateEditingSession', function () {
beforeEach(function () {
this.req = {
params: {
projectId: 'a project id',
},
session: {},
body: {
segmentation: {
editorType: 'abc',
},
},
}
this.GeoIpLookup.promises.getDetails = sinon
.stub()
.resolves({ country_code: 'XY' })
})
it('delegates to the AnalyticsManager', function (done) {
this.SessionManager.getLoggedInUserId.returns('1234')
this.res.callback = () => {
sinon.assert.calledWith(
this.AnalyticsManager.updateEditingSession,
'1234',
'a project id',
'XY',
{ editorType: 'abc' }
)
done()
}
this.controller.updateEditingSession(this.req, this.res)
})
})
describe('recordEvent', function () {
beforeEach(function () {
const body = {
foo: 'stuff',
_csrf: 'atoken123',
}
this.req = {
params: {
event: 'i_did_something',
},
body,
sessionID: 'sessionIDHere',
session: {},
}
this.expectedData = Object.assign({}, body)
delete this.expectedData._csrf
})
it('should use the session', function (done) {
this.controller.recordEvent(this.req, this.res)
sinon.assert.calledWith(
this.AnalyticsManager.recordEventForSession,
this.req.session,
this.req.params.event,
this.expectedData
)
done()
})
it('should remove the CSRF token before sending to the manager', function (done) {
this.controller.recordEvent(this.req, this.res)
sinon.assert.calledWith(
this.AnalyticsManager.recordEventForSession,
this.req.session,
this.req.params.event,
this.expectedData
)
done()
})
})
})

View File

@@ -0,0 +1,424 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const { assert } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Analytics/AnalyticsManager'
)
describe('AnalyticsManager', function () {
beforeEach(function () {
this.fakeUserId = 'dbfc9438d14996f73dd172fb'
this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
this.Settings = {
analytics: { enabled: true },
}
this.analyticsEventsQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
this.analyticsEditingSessionQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
this.onboardingEmailsQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
this.analyticsUserPropertiesQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
this.analyticsAccountMappingQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
const self = this
this.Queues = {
getQueue: queueName => {
switch (queueName) {
case 'analytics-events':
return self.analyticsEventsQueue
case 'analytics-editing-sessions':
return self.analyticsEditingSessionQueue
case 'emails-onboarding':
return self.onboardingEmailsQueue
case 'analytics-user-properties':
return self.analyticsUserPropertiesQueue
case 'analytics-account-mapping':
return self.analyticsAccountMappingQueue
default:
throw new Error('Unexpected queue name')
}
},
createScheduledJob: sinon.stub().resolves(),
}
this.backgroundRequest = sinon.stub().yields()
this.request = sinon.stub().yields()
this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
'../../infrastructure/Queues': this.Queues,
'./UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
get: sinon.stub().resolves(this.analyticsId),
}),
},
})
})
describe('ignores when', function () {
it('user is smoke test user', function () {
this.Settings.smokeTest = { userId: this.fakeUserId }
this.AnalyticsManager.identifyUser(this.fakeUserId, '')
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('analytics service is disabled', function () {
this.Settings.analytics.enabled = false
this.AnalyticsManager.identifyUser(this.fakeUserId, '')
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('userId is missing', function () {
this.AnalyticsManager.identifyUser(undefined, this.analyticsId)
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('analyticsId is missing', function () {
this.AnalyticsManager.identifyUser(
new ObjectId(this.fakeUserId),
undefined
)
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('analyticsId is not a valid UUID', function () {
this.AnalyticsManager.identifyUser(
new ObjectId(this.fakeUserId),
this.fakeUserId
)
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('userId and analyticsId are the same Mongo ID', function () {
this.AnalyticsManager.identifyUser(
new ObjectId(this.fakeUserId),
new ObjectId(this.fakeUserId)
)
sinon.assert.notCalled(this.Queues.createScheduledJob)
})
it('editing session segmentation is not valid', function () {
this.AnalyticsManager.updateEditingSession(
this.fakeUserId,
'789ghi',
'fr',
{ '<alert>': 'foo' }
)
sinon.assert.called(this.logger.info)
sinon.assert.notCalled(this.analyticsEditingSessionQueue.add)
})
it('event is not valid', async function () {
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'not an event!'
)
sinon.assert.called(this.logger.info)
sinon.assert.notCalled(this.analyticsEventsQueue.add)
})
it('event segmentation is not valid', async function () {
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'an_event',
{ 'not_a!': 'Valid Segmentation' }
)
sinon.assert.called(this.logger.info)
sinon.assert.notCalled(this.analyticsEventsQueue.add)
})
it('user property name is not valid', async function () {
await this.AnalyticsManager.setUserPropertyForUser(
this.fakeUserId,
'an invalid property',
'a_value'
)
sinon.assert.called(this.logger.info)
sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
})
it('user property value is not valid', async function () {
await this.AnalyticsManager.setUserPropertyForUser(
this.fakeUserId,
'a_property',
'an invalid value'
)
sinon.assert.called(this.logger.info)
sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
})
})
describe('queues the appropriate message for', function () {
it('identifyUser', function () {
const analyticsId = 'bd101c4c-722f-4204-9e2d-8303e5d9c120'
this.AnalyticsManager.identifyUser(this.fakeUserId, analyticsId, true)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(
this.Queues.createScheduledJob,
'analytics-events',
{
name: 'identify',
data: {
userId: this.fakeUserId,
analyticsId,
isNewUser: true,
createdAt: sinon.match.date,
},
},
60000
)
})
it('recordEventForUser', async function () {
const event = 'fake-event'
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
event,
null
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
analyticsId: this.analyticsId,
event,
segmentation: null,
isLoggedIn: true,
})
})
it('updateEditingSession', function () {
const projectId = '789ghi'
const countryCode = 'fr'
const segmentation = { editorType: 'abc' }
this.AnalyticsManager.updateEditingSession(
this.fakeUserId,
projectId,
countryCode,
segmentation
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(
this.analyticsEditingSessionQueue.add,
'editing-session',
{
userId: this.fakeUserId,
projectId,
countryCode,
segmentation,
}
)
})
it('empty field in event segmentation', async function () {
const timings = null
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'an_event',
{ compileTime: timings?.compileE2E }
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
analyticsId: this.analyticsId,
event: 'an_event',
segmentation: { compileTime: undefined },
isLoggedIn: true,
})
})
it('empty space in event segmentation value', async function () {
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'an_event',
{ segment: 'a value with spaces' }
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
analyticsId: this.analyticsId,
event: 'an_event',
segmentation: { segment: 'a value with spaces' },
isLoggedIn: true,
})
})
it('percent sign in event segmentation value', async function () {
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'an_event',
{ segment: 'a value with escaped comma %2C' }
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
analyticsId: this.analyticsId,
event: 'an_event',
segmentation: { segment: 'a value with escaped comma %2C' },
isLoggedIn: true,
})
})
it('boolean field in event segmentation', async function () {
await this.AnalyticsManager.recordEventForUser(
this.fakeUserId,
'an_event',
{ isAutoCompile: false }
)
sinon.assert.notCalled(this.logger.info)
sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
analyticsId: this.analyticsId,
event: 'an_event',
segmentation: { isAutoCompile: false },
isLoggedIn: true,
})
})
it('account mapping', async function () {
const message = {
source: 'salesforce',
sourceEntity: 'account',
sourceEntityId: 'abc123abc123abc123',
target: 'v1',
targetEntity: 'university',
targetEntityId: 1,
createdAt: '2021-01-01T00:00:00Z',
}
await this.AnalyticsManager.registerAccountMapping(message)
sinon.assert.calledWithMatch(
this.analyticsAccountMappingQueue.add,
'account-mapping',
message
)
})
})
describe('AnalyticsIdMiddleware', function () {
beforeEach(function () {
this.userId = '123abc'
this.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795'
const self = this
this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': {},
'../../infrastructure/Queues': {
getQueue: queueName => {
switch (queueName) {
case 'analytics-events':
return self.analyticsEventsQueue
case 'analytics-editing-sessions':
return self.analyticsEditingSessionQueue
case 'emails-onboarding':
return self.onboardingEmailsQueue
case 'analytics-user-properties':
return self.analyticsUserPropertiesQueue
case 'analytics-account-mapping':
return self.analyticsAccountMappingQueue
default:
throw new Error('Unexpected queue name')
}
},
},
'./UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
get: sinon.stub().resolves(this.analyticsId),
}),
crypto: {
randomUUID: () => this.analyticsId,
},
},
})
this.req = new MockRequest()
this.req.session = {}
this.res = new MockResponse()
this.next = () => {}
})
it('sets session.analyticsId with no user in session', async function () {
await this.AnalyticsManager.analyticsIdMiddleware(
this.req,
this.res,
this.next
)
assert.equal(this.analyticsId, this.req.session.analyticsId)
})
it('does not update analyticsId when existing, with no user in session', async function () {
this.req.session.analyticsId = 'foo'
await this.AnalyticsManager.analyticsIdMiddleware(
this.req,
this.res,
this.next
)
assert.equal('foo', this.req.session.analyticsId)
})
it('sets session.analyticsId with a logged in user in session having an analyticsId', async function () {
this.req.session.user = {
_id: this.userId,
analyticsId: this.analyticsId,
}
await this.AnalyticsManager.analyticsIdMiddleware(
this.req,
this.res,
() => {
assert.equal(this.analyticsId, this.req.session.analyticsId)
}
)
})
it('sets session.analyticsId with a legacy user session without an analyticsId', async function () {
this.UserAnalyticsIdCache.get.resolves(this.userId)
this.req.session.user = {
_id: this.userId,
analyticsId: undefined,
}
await this.AnalyticsManager.analyticsIdMiddleware(
this.req,
this.res,
() => {
assert.equal(this.userId, this.req.session.analyticsId)
}
)
})
it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function () {
this.UserAnalyticsIdCache.get.resolves(this.userId)
this.req.session.user = {
_id: this.userId,
analyticsId: undefined,
}
this.req.analyticsId = 'foo'
this.AnalyticsManager.analyticsIdMiddleware(this.req, this.res, () => {
assert.equal(this.userId, this.req.session.analyticsId)
})
})
it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function () {
this.UserAnalyticsIdCache.get.resolves(this.userId)
this.req.session.user = {
_id: this.userId,
analyticsId: undefined,
}
this.req.analyticsId = this.userId
await this.AnalyticsManager.analyticsIdMiddleware(
this.req,
this.res,
() => {
assert.equal(this.userId, this.req.session.analyticsId)
}
)
})
})
})

View File

@@ -0,0 +1,200 @@
import esmock from 'esmock'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
import { assert } from 'chai'
const MODULE_PATH = new URL(
'../../../../app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware',
import.meta.url
).pathname
describe('AnalyticsUTMTrackingMiddleware', function () {
beforeEach(async function () {
this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
this.userId = '61795fcb013504bb7b663092'
this.req = new MockRequest()
this.res = new MockResponse()
this.next = sinon.stub().returns()
this.req.session = {
user: {
_id: this.userId,
analyticsId: this.analyticsId,
},
}
this.AnalyticsUTMTrackingMiddleware = await esmock.strict(MODULE_PATH, {
'../../../../app/src/Features/Analytics/AnalyticsManager.js':
(this.AnalyticsManager = {
recordEventForSession: sinon.stub().resolves(),
setUserPropertyForSessionInBackground: sinon.stub(),
}),
'@overleaf/settings': {
siteUrl: 'https://www.overleaf.com',
},
})
this.middleware = this.AnalyticsUTMTrackingMiddleware.recordUTMTags()
})
describe('without UTM tags in query', function () {
beforeEach(function () {
this.req.url = '/project'
this.middleware(this.req, this.res, this.next)
})
it('user is not redirected', function () {
assert.isFalse(this.res.redirected)
})
it('next middleware is executed', function () {
sinon.assert.calledOnce(this.next)
})
it('no event or user property is recorded', function () {
sinon.assert.notCalled(this.AnalyticsManager.recordEventForSession)
sinon.assert.notCalled(
this.AnalyticsManager.setUserPropertyForSessionInBackground
)
})
})
describe('with all UTM tags in query', function () {
beforeEach(function () {
this.req.url =
'/project?utm_source=Organic&utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_content=foo-bar&utm_term=overridden'
this.req.query = {
utm_source: 'Organic',
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
utm_content: 'foo-bar',
utm_term: 'overridden',
}
this.middleware(this.req, this.res, this.next)
})
it('user is redirected', function () {
assert.isTrue(this.res.redirected)
assert.equal('/project', this.res.redirectedTo)
})
it('next middleware is not executed', function () {
sinon.assert.notCalled(this.next)
})
it('page-view event is recorded for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.recordEventForSession,
this.req.session,
'page-view',
{
path: '/project',
utm_source: 'Organic',
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
utm_content: 'foo-bar',
utm_term: 'overridden',
}
)
})
it('utm-tags user property is set for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForSessionInBackground,
this.req.session,
'utm-tags',
'Organic;Facebook;Some Campaign;foo-bar'
)
})
})
describe('with some UTM tags in query', function () {
beforeEach(function () {
this.req.url =
'/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_term=foo'
this.req.query = {
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
utm_term: 'foo',
}
this.middleware(this.req, this.res, this.next)
})
it('user is redirected', function () {
assert.isTrue(this.res.redirected)
assert.equal('/project', this.res.redirectedTo)
})
it('next middleware is not executed', function () {
sinon.assert.notCalled(this.next)
})
it('page-view event is recorded for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.recordEventForSession,
this.req.session,
'page-view',
{
path: '/project',
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
utm_term: 'foo',
}
)
})
it('utm-tags user property is set for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForSessionInBackground,
this.req.session,
'utm-tags',
'N/A;Facebook;Some Campaign;foo'
)
})
})
describe('with some UTM tags and additional parameters in query', function () {
beforeEach(function () {
this.req.url =
'/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&other_param=some-value'
this.req.query = {
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
other_param: 'some-value',
}
this.middleware(this.req, this.res, this.next)
})
it('user is redirected', function () {
assert.isTrue(this.res.redirected)
assert.equal('/project?other_param=some-value', this.res.redirectedTo)
})
it('next middleware is not executed', function () {
sinon.assert.notCalled(this.next)
})
it('page-view event is recorded for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.recordEventForSession,
this.req.session,
'page-view',
{
path: '/project',
utm_medium: 'Facebook',
utm_campaign: 'Some Campaign',
}
)
})
it('utm-tags user property is set for session', function () {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForSessionInBackground,
this.req.session,
'utm-tags',
'N/A;Facebook;Some Campaign;N/A'
)
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Authentication/SessionManager.js'
const SandboxedModule = require('sandboxed-module')
const tk = require('timekeeper')
const { ObjectId } = require('mongodb-legacy')
describe('SessionManager', function () {
beforeEach(function () {
this.UserModel = { findOne: sinon.stub() }
this.SessionManager = SandboxedModule.require(modulePath, {
requires: {},
})
this.user = {
_id: new ObjectId(),
email: (this.email = 'USER@example.com'),
first_name: 'bob',
last_name: 'brown',
referal_id: 1234,
isAdmin: false,
}
this.session = sinon.stub()
})
afterEach(function () {
tk.reset()
})
describe('isUserLoggedIn', function () {
beforeEach(function () {
this.stub = sinon.stub(this.SessionManager, 'getLoggedInUserId')
})
afterEach(function () {
this.stub.restore()
})
it('should do the right thing in all cases', function () {
this.SessionManager.getLoggedInUserId.returns('some_id')
expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(true)
this.SessionManager.getLoggedInUserId.returns(null)
expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
this.SessionManager.getLoggedInUserId.returns(false)
expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
this.SessionManager.getLoggedInUserId.returns(undefined)
expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
})
})
describe('setInSessionUser', function () {
beforeEach(function () {
this.user = {
_id: 'id',
first_name: 'a',
last_name: 'b',
email: 'c',
}
this.SessionManager.getSessionUser = sinon.stub().returns(this.user)
})
it('should update the right properties', function () {
this.SessionManager.setInSessionUser(this.session, {
first_name: 'new_first_name',
email: 'new_email',
})
const expectedUser = {
_id: 'id',
first_name: 'new_first_name',
last_name: 'b',
email: 'new_email',
}
expect(this.user).to.deep.equal(expectedUser)
expect(this.user).to.deep.equal(expectedUser)
})
})
describe('getLoggedInUserId', function () {
beforeEach(function () {
this.req = { session: {} }
})
it('should return the user id from the session', function () {
this.user_id = '2134'
this.session.user = { _id: this.user_id }
const result = this.SessionManager.getLoggedInUserId(this.session)
expect(result).to.equal(this.user_id)
})
it('should return user for passport session', function () {
this.user_id = '2134'
this.session = {
passport: {
user: {
_id: this.user_id,
},
},
}
const result = this.SessionManager.getLoggedInUserId(this.session)
expect(result).to.equal(this.user_id)
})
it('should return null if there is no user on the session', function () {
this.session = {}
const result = this.SessionManager.getLoggedInUserId(this.session)
expect(result).to.equal(null)
})
it('should return null if there is no session', function () {
const result = this.SessionManager.getLoggedInUserId(undefined)
expect(result).to.equal(null)
})
})
})

View File

@@ -0,0 +1,727 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Authorization/AuthorizationManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
const PublicAccessLevels = require('../../../../app/src/Features/Authorization/PublicAccessLevels')
const { ObjectId } = require('mongodb-legacy')
describe('AuthorizationManager', function () {
beforeEach(function () {
this.user = { _id: new ObjectId() }
this.project = { _id: new ObjectId() }
this.doc = { _id: new ObjectId() }
this.thread = { _id: new ObjectId() }
this.token = 'some-token'
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(null),
},
}
this.ProjectGetter.promises.getProject
.withArgs(this.project._id)
.resolves(this.project)
this.CollaboratorsGetter = {
promises: {
getMemberIdPrivilegeLevel: sinon.stub().resolves(PrivilegeLevels.NONE),
},
}
this.CollaboratorsHandler = {}
this.User = {
findOne: sinon.stub().returns({ exec: sinon.stub().resolves(null) }),
}
this.User.findOne
.withArgs({ _id: this.user._id })
.returns({ exec: sinon.stub().resolves(this.user) })
this.TokenAccessHandler = {
promises: {
validateTokenForAnonymousAccess: sinon
.stub()
.resolves({ isValidReadAndWrite: false, isValidReadOnly: false }),
},
}
this.DocumentUpdaterHandler = {
promises: {
getComment: sinon
.stub()
.resolves({ metadata: { user_id: new ObjectId() } }),
},
}
this.AuthorizationManager = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
'../Project/ProjectGetter': this.ProjectGetter,
'../../models/User': { User: this.User },
'../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
'../DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'@overleaf/settings': {
passwordStrengthOptions: {},
adminPrivilegeAvailable: true,
},
},
})
})
describe('isRestrictedUser', function () {
it('should produce the correct values', function () {
const notRestrictedScenarios = [
[null, 'readAndWrite', false, false],
['id', 'readAndWrite', true, false],
['id', 'readAndWrite', true, true],
['id', 'readOnly', false, false],
['id', 'readOnly', false, true],
['id', 'review', false, true],
]
const restrictedScenarios = [
[null, 'readOnly', false, false],
['id', 'readOnly', true, false],
[null, false, true, false],
[null, false, false, false],
['id', false, true, false],
['id', false, false, false],
]
for (const notRestrictedArgs of notRestrictedScenarios) {
expect(
this.AuthorizationManager.isRestrictedUser(...notRestrictedArgs)
).to.equal(false)
}
for (const restrictedArgs of restrictedScenarios) {
expect(
this.AuthorizationManager.isRestrictedUser(...restrictedArgs)
).to.equal(true)
}
})
})
describe('getPrivilegeLevelForProject', function () {
describe('with a token-based project', function () {
beforeEach(function () {
this.project.publicAccesLevel = 'tokenBased'
})
describe('with a user id with a privilege level', function () {
beforeEach(async function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_ONLY)
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it("should return the user's privilege level", function () {
expect(this.result).to.equal('readOnly')
})
})
describe('with a user id with no privilege level', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return false', function () {
expect(this.result).to.equal(false)
})
})
describe('with a user id who is an admin', function () {
beforeEach(async function () {
this.user.isAdmin = true
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return the user as an owner', function () {
expect(this.result).to.equal('owner')
})
})
describe('with no user (anonymous)', function () {
describe('when the token is not valid', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
this.project._id,
this.token
)
})
it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal(
false
)
})
it('should check if the token is valid', function () {
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess.should.have.been.calledWith(
this.project._id,
this.token
)
})
it('should return false', function () {
expect(this.result).to.equal(false)
})
})
describe('when the token is valid for read-and-write', function () {
beforeEach(async function () {
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess =
sinon
.stub()
.withArgs(this.project._id, this.token)
.resolves({ isValidReadAndWrite: true, isValidReadOnly: false })
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
this.project._id,
this.token
)
})
it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal(
false
)
})
it('should check if the token is valid', function () {
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess.should.have.been.calledWith(
this.project._id,
this.token
)
})
it('should give read-write access', function () {
expect(this.result).to.equal('readAndWrite')
})
})
describe('when the token is valid for read-only', function () {
beforeEach(async function () {
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess =
sinon
.stub()
.withArgs(this.project._id, this.token)
.resolves({ isValidReadAndWrite: false, isValidReadOnly: true })
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
this.project._id,
this.token
)
})
it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal(
false
)
})
it('should check if the token is valid', function () {
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess.should.have.been.calledWith(
this.project._id,
this.token
)
})
it('should give read-only access', function () {
expect(this.result).to.equal('readOnly')
})
})
})
})
describe('with a private project', function () {
beforeEach(function () {
this.project.publicAccesLevel = 'private'
})
describe('with a user id with a privilege level', function () {
beforeEach(async function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_ONLY)
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it("should return the user's privilege level", function () {
expect(this.result).to.equal('readOnly')
})
})
describe('with a user id with no privilege level', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return false', function () {
expect(this.result).to.equal(false)
})
})
describe('with a user id who is an admin', function () {
beforeEach(async function () {
this.user.isAdmin = true
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return the user as an owner', function () {
expect(this.result).to.equal('owner')
})
})
describe('with no user (anonymous)', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
this.project._id,
this.token
)
})
it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal(
false
)
})
it('should return false', function () {
expect(this.result).to.equal(false)
})
})
})
describe('with a public project', function () {
beforeEach(function () {
this.project.publicAccesLevel = 'readAndWrite'
})
describe('with a user id with a privilege level', function () {
beforeEach(async function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_ONLY)
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it("should return the user's privilege level", function () {
expect(this.result).to.equal('readOnly')
})
})
describe('with a user id with no privilege level', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return the public privilege level', function () {
expect(this.result).to.equal('readAndWrite')
})
})
describe('with a user id who is an admin', function () {
beforeEach(async function () {
this.user.isAdmin = true
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
this.project._id,
this.token
)
})
it('should return the user as an owner', function () {
expect(this.result).to.equal('owner')
})
})
describe('with no user (anonymous)', function () {
beforeEach(async function () {
this.result =
await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
this.project._id,
this.token
)
})
it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal(
false
)
})
it('should return the public privilege level', function () {
expect(this.result).to.equal('readAndWrite')
})
})
})
describe("when the project doesn't exist", function () {
it('should return a NotFoundError', async function () {
const someOtherId = new ObjectId()
await expect(
this.AuthorizationManager.promises.getPrivilegeLevelForProject(
this.user._id,
someOtherId,
this.token
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('when the project id is not valid', function () {
beforeEach(function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_ONLY)
})
it('should return a error', async function () {
await expect(
this.AuthorizationManager.promises.getPrivilegeLevelForProject(
undefined,
'not project id',
this.token
)
).to.be.rejected
})
})
})
testPermission('canUserReadProject', {
siteAdmin: true,
owner: true,
readAndWrite: true,
review: true,
readOnly: true,
publicReadAndWrite: true,
publicReadOnly: true,
tokenReadAndWrite: true,
tokenReadOnly: true,
})
testPermission('canUserWriteOrReviewProjectContent', {
siteAdmin: true,
owner: true,
readAndWrite: true,
review: true,
publicReadAndWrite: true,
tokenReadAndWrite: true,
})
testPermission('canUserWriteProjectContent', {
siteAdmin: true,
owner: true,
readAndWrite: true,
publicReadAndWrite: true,
tokenReadAndWrite: true,
})
testPermission('canUserWriteProjectSettings', {
siteAdmin: true,
owner: true,
readAndWrite: true,
tokenReadAndWrite: true,
})
testPermission('canUserRenameProject', {
siteAdmin: true,
owner: true,
})
testPermission('canUserAdminProject', { siteAdmin: true, owner: true })
describe('isUserSiteAdmin', function () {
describe('when user is admin', function () {
beforeEach(function () {
this.user.isAdmin = true
})
it('should return true', async function () {
const isAdmin =
await this.AuthorizationManager.promises.isUserSiteAdmin(
this.user._id
)
expect(isAdmin).to.equal(true)
})
})
describe('when user is not admin', function () {
it('should return false', async function () {
const isAdmin =
await this.AuthorizationManager.promises.isUserSiteAdmin(
this.user._id
)
expect(isAdmin).to.equal(false)
})
})
describe('when user is not found', function () {
it('should return false', async function () {
const someOtherId = new ObjectId()
const isAdmin =
await this.AuthorizationManager.promises.isUserSiteAdmin(someOtherId)
expect(isAdmin).to.equal(false)
})
})
describe('when no user is passed', function () {
it('should return false', async function () {
const isAdmin =
await this.AuthorizationManager.promises.isUserSiteAdmin(null)
expect(isAdmin).to.equal(false)
})
})
})
describe('canUserDeleteOrResolveThread', function () {
it('should return true when user has write permissions', async function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_AND_WRITE)
const canResolve =
await this.AuthorizationManager.promises.canUserDeleteOrResolveThread(
this.user._id,
this.project._id,
this.doc._id,
this.thread._id,
this.token
)
expect(canResolve).to.equal(true)
})
it('should return false when user has read permission', async function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.READ_ONLY)
const canResolve =
await this.AuthorizationManager.promises.canUserDeleteOrResolveThread(
this.user._id,
this.project._id,
this.doc._id,
this.thread._id,
this.token
)
expect(canResolve).to.equal(false)
})
describe('when user has review permission', function () {
beforeEach(function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(PrivilegeLevels.REVIEW)
})
it('should return false when user is not the comment author', async function () {
const canResolve =
await this.AuthorizationManager.promises.canUserDeleteOrResolveThread(
this.user._id,
this.project._id,
this.doc._id,
this.thread._id,
this.token
)
expect(canResolve).to.equal(false)
})
it('should return true when user is the comment author', async function () {
this.DocumentUpdaterHandler.promises.getComment
.withArgs(this.project._id, this.doc._id, this.thread._id)
.resolves({ metadata: { user_id: this.user._id } })
const canResolve =
await this.AuthorizationManager.promises.canUserDeleteOrResolveThread(
this.user._id,
this.project._id,
this.doc._id,
this.thread._id,
this.token
)
expect(canResolve).to.equal(true)
})
})
})
})
function testPermission(permission, privilegeLevels) {
describe(permission, function () {
describe('when authenticated', function () {
describe('when user is site admin', function () {
beforeEach('set user as site admin', function () {
this.user.isAdmin = true
})
expectPermission(permission, privilegeLevels.siteAdmin || false)
})
describe('when user is owner', function () {
setupUserPrivilegeLevel(PrivilegeLevels.OWNER)
expectPermission(permission, privilegeLevels.owner || false)
})
describe('when user has read-write access', function () {
setupUserPrivilegeLevel(PrivilegeLevels.READ_AND_WRITE)
expectPermission(permission, privilegeLevels.readAndWrite || false)
})
describe('when user has review access', function () {
setupUserPrivilegeLevel(PrivilegeLevels.REVIEW)
expectPermission(permission, privilegeLevels.review || false)
})
describe('when user has read-only access', function () {
setupUserPrivilegeLevel(PrivilegeLevels.READ_ONLY)
expectPermission(permission, privilegeLevels.readOnly || false)
})
describe('when user has read-write access as the public', function () {
setupPublicAccessLevel(PublicAccessLevels.READ_AND_WRITE)
expectPermission(
permission,
privilegeLevels.publicReadAndWrite || false
)
})
describe('when user has read-only access as the public', function () {
setupPublicAccessLevel(PublicAccessLevels.READ_ONLY)
expectPermission(permission, privilegeLevels.publicReadOnly || false)
})
describe('when user is not found', function () {
it('should return false', async function () {
const otherUserId = new ObjectId()
const value = await this.AuthorizationManager.promises[permission](
otherUserId,
this.project._id,
this.token
)
expect(value).to.equal(false)
})
})
})
describe('when anonymous', function () {
beforeEach(function () {
this.user = null
})
describe('with read-write access through a token', function () {
setupTokenAccessLevel('readAndWrite')
expectPermission(permission, privilegeLevels.tokenReadAndWrite || false)
})
describe('with read-only access through a token', function () {
setupTokenAccessLevel('readOnly')
expectPermission(permission, privilegeLevels.tokenReadOnly || false)
})
describe('with public read-write access', function () {
setupPublicAccessLevel(PublicAccessLevels.READ_AND_WRITE)
expectPermission(
permission,
privilegeLevels.publicReadAndWrite || false
)
})
describe('with public read-only access', function () {
setupPublicAccessLevel(PublicAccessLevels.READ_ONLY)
expectPermission(permission, privilegeLevels.publicReadOnly || false)
})
})
})
}
function setupUserPrivilegeLevel(privilegeLevel) {
beforeEach(`set user privilege level to ${privilegeLevel}`, function () {
this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel
.withArgs(this.user._id, this.project._id)
.resolves(privilegeLevel)
})
}
function setupPublicAccessLevel(level) {
beforeEach(`set public access level to ${level}`, function () {
this.project.publicAccesLevel = level
})
}
function setupTokenAccessLevel(level) {
beforeEach(`set token access level to ${level}`, function () {
this.project.publicAccesLevel = PublicAccessLevels.TOKEN_BASED
this.TokenAccessHandler.promises.validateTokenForAnonymousAccess
.withArgs(this.project._id, this.token)
.resolves({
isValidReadAndWrite: level === 'readAndWrite',
isValidReadOnly: level === 'readOnly',
})
})
}
function expectPermission(permission, expectedValue) {
it(`should return ${expectedValue}`, async function () {
const value = await this.AuthorizationManager.promises[permission](
this.user && this.user._id,
this.project._id,
this.token
)
expect(value).to.equal(expectedValue)
})
}

View File

@@ -0,0 +1,461 @@
const sinon = require('sinon')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const MODULE_PATH =
'../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
describe('AuthorizationMiddleware', function () {
beforeEach(function () {
this.userId = new ObjectId().toString()
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
this.thread_id = new ObjectId().toString()
this.token = 'some-token'
this.AuthenticationController = {}
this.SessionManager = {
getSessionUser: sinon.stub().returns(null),
getLoggedInUserId: sinon.stub().returns(this.userId),
isUserLoggedIn: sinon.stub().returns(true),
}
this.AuthorizationManager = {
promises: {
canUserReadProject: sinon.stub(),
canUserWriteProjectSettings: sinon.stub(),
canUserWriteProjectContent: sinon.stub(),
canUserWriteOrReviewProjectContent: sinon.stub(),
canUserDeleteOrResolveThread: sinon.stub(),
canUserAdminProject: sinon.stub(),
canUserRenameProject: sinon.stub(),
isUserSiteAdmin: sinon.stub(),
isRestrictedUserForProject: sinon.stub(),
},
}
this.HttpErrorHandler = {
forbidden: sinon.stub(),
}
this.TokenAccessHandler = {
getRequestToken: sinon.stub().returns(this.token),
}
this.DocumentUpdaterHandler = {
promises: {
getComment: sinon.stub().resolves(),
},
}
this.AuthorizationMiddleware = SandboxedModule.require(MODULE_PATH, {
requires: {
'./AuthorizationManager': this.AuthorizationManager,
'../Errors/HttpErrorHandler': this.HttpErrorHandler,
'mongodb-legacy': { ObjectId },
'../Authentication/AuthenticationController':
this.AuthenticationController,
'../Authentication/SessionManager': this.SessionManager,
'../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
'../Helpers/AdminAuthorizationHelper': {
canRedirectToAdminDomain: sinon.stub().returns(false),
},
'../DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
},
})
this.req = {
params: {
project_id: this.project_id,
},
body: {},
}
this.res = {
redirect: sinon.stub(),
locals: {
currentUrl: '/current/url',
},
}
this.next = sinon.stub()
})
describe('ensureCanReadProject', function () {
testMiddleware('ensureUserCanReadProject', 'canUserReadProject')
})
describe('ensureUserCanWriteProjectContent', function () {
testMiddleware(
'ensureUserCanWriteProjectContent',
'canUserWriteProjectContent'
)
})
describe('ensureUserCanWriteOrReviewProjectContent', function () {
testMiddleware(
'ensureUserCanWriteOrReviewProjectContent',
'canUserWriteOrReviewProjectContent'
)
})
describe('ensureUserCanDeleteOrResolveThread', function () {
beforeEach(function () {
this.req.params.doc_id = this.doc_id
this.req.params.thread_id = this.thread_id
})
describe('when user has permission', function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserDeleteOrResolveThread
.withArgs(
this.userId,
this.project_id,
this.doc_id,
this.thread_id,
this.token
)
.resolves(true)
})
invokeMiddleware('ensureUserCanDeleteOrResolveThread')
expectNext()
})
describe("when user doesn't have permission", function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserDeleteOrResolveThread
.withArgs(
this.userId,
this.project_id,
this.doc_id,
this.thread_id,
this.token
)
.resolves(false)
})
invokeMiddleware('ensureUserCanDeleteOrResolveThread')
expectForbidden()
})
})
describe('ensureUserCanWriteProjectSettings', function () {
describe('when renaming a project', function () {
beforeEach(function () {
this.req.body.name = 'new project name'
})
testMiddleware(
'ensureUserCanWriteProjectSettings',
'canUserRenameProject'
)
})
describe('when setting another parameter', function () {
beforeEach(function () {
this.req.body.compiler = 'texlive-2017'
})
testMiddleware(
'ensureUserCanWriteProjectSettings',
'canUserWriteProjectSettings'
)
})
})
describe('ensureUserCanAdminProject', function () {
testMiddleware('ensureUserCanAdminProject', 'canUserAdminProject')
})
describe('ensureUserIsSiteAdmin', function () {
describe('with logged in user', function () {
describe('when user has permission', function () {
setupSiteAdmin(true)
invokeMiddleware('ensureUserIsSiteAdmin')
expectNext()
})
describe("when user doesn't have permission", function () {
setupSiteAdmin(false)
invokeMiddleware('ensureUserIsSiteAdmin')
expectRedirectToRestricted()
})
})
describe('with oauth user', function () {
setupOAuthUser()
describe('when user has permission', function () {
setupSiteAdmin(true)
invokeMiddleware('ensureUserIsSiteAdmin')
expectNext()
})
describe("when user doesn't have permission", function () {
setupSiteAdmin(false)
invokeMiddleware('ensureUserIsSiteAdmin')
expectRedirectToRestricted()
})
})
describe('with anonymous user', function () {
setupAnonymousUser()
invokeMiddleware('ensureUserIsSiteAdmin')
expectRedirectToRestricted()
})
})
describe('blockRestrictedUserFromProject', function () {
describe('for a restricted user', function () {
setupPermission('isRestrictedUserForProject', true)
invokeMiddleware('blockRestrictedUserFromProject')
expectForbidden()
})
describe('for a regular user', function (done) {
setupPermission('isRestrictedUserForProject', false)
invokeMiddleware('blockRestrictedUserFromProject')
expectNext()
})
})
describe('ensureUserCanReadMultipleProjects', function () {
beforeEach(function () {
this.req.query = { project_ids: 'project1,project2' }
})
describe('with logged in user', function () {
describe('when user has permission to access all projects', function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project1', this.token)
.resolves(true)
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project2', this.token)
.resolves(true)
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectNext()
})
describe("when user doesn't have permission to access one of the projects", function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project1', this.token)
.resolves(true)
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project2', this.token)
.resolves(false)
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectRedirectToRestricted()
})
})
describe('with oauth user', function () {
setupOAuthUser()
beforeEach(function () {
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project1', this.token)
.resolves(true)
this.AuthorizationManager.promises.canUserReadProject
.withArgs(this.userId, 'project2', this.token)
.resolves(true)
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectNext()
})
describe('with anonymous user', function () {
setupAnonymousUser()
describe('when user has permission', function () {
describe('when user has permission to access all projects', function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserReadProject
.withArgs(null, 'project1', this.token)
.resolves(true)
this.AuthorizationManager.promises.canUserReadProject
.withArgs(null, 'project2', this.token)
.resolves(true)
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectNext()
})
describe("when user doesn't have permission to access one of the projects", function () {
beforeEach(function () {
this.AuthorizationManager.promises.canUserReadProject
.withArgs(null, 'project1', this.token)
.resolves(true)
this.AuthorizationManager.promises.canUserReadProject
.withArgs(null, 'project2', this.token)
.resolves(false)
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectRedirectToRestricted()
})
})
})
})
})
function testMiddleware(middleware, permission) {
describe(middleware, function () {
describe('with missing project_id', function () {
setupMissingProjectId()
invokeMiddleware(middleware)
expectError()
})
describe('with logged in user', function () {
describe('when user has permission', function () {
setupPermission(permission, true)
invokeMiddleware(middleware)
expectNext()
})
describe("when user doesn't have permission", function () {
setupPermission(permission, false)
invokeMiddleware(middleware)
expectForbidden()
})
})
describe('with oauth user', function () {
setupOAuthUser()
describe('when user has permission', function () {
setupPermission(permission, true)
invokeMiddleware(middleware)
expectNext()
})
describe("when user doesn't have permission", function () {
setupPermission(permission, false)
invokeMiddleware(middleware)
expectForbidden()
})
})
describe('with anonymous user', function () {
setupAnonymousUser()
describe('when user has permission', function () {
setupAnonymousPermission(permission, true)
invokeMiddleware(middleware)
expectNext()
})
describe("when user doesn't have permission", function () {
setupAnonymousPermission(permission, false)
invokeMiddleware(middleware)
expectForbidden()
})
})
describe('with malformed project id', function () {
setupMalformedProjectId()
invokeMiddleware(middleware)
expectNotFound()
})
})
}
function setupAnonymousUser() {
beforeEach('set up anonymous user', function () {
this.SessionManager.getLoggedInUserId.returns(null)
this.SessionManager.isUserLoggedIn.returns(false)
})
}
function setupOAuthUser() {
beforeEach('set up oauth user', function () {
this.SessionManager.getLoggedInUserId.returns(null)
this.req.oauth_user = { _id: this.userId }
})
}
function setupPermission(permission, value) {
beforeEach(`set permission ${permission} to ${value}`, function () {
this.AuthorizationManager.promises[permission]
.withArgs(this.userId, this.project_id, this.token)
.resolves(value)
})
}
function setupAnonymousPermission(permission, value) {
beforeEach(`set anonymous permission ${permission} to ${value}`, function () {
this.AuthorizationManager.promises[permission]
.withArgs(null, this.project_id, this.token)
.resolves(value)
})
}
function setupSiteAdmin(value) {
beforeEach(`set site admin to ${value}`, function () {
this.AuthorizationManager.promises.isUserSiteAdmin
.withArgs(this.userId)
.resolves(value)
})
}
function setupMissingProjectId() {
beforeEach('set up missing project id', function () {
delete this.req.params.project_id
})
}
function setupMalformedProjectId() {
beforeEach('set up malformed project id', function () {
this.req.params = { project_id: 'bad-project-id' }
})
}
function invokeMiddleware(method) {
beforeEach(`invoke ${method}`, function (done) {
this.next.callsFake(() => done())
this.HttpErrorHandler.forbidden.callsFake(() => done())
this.res.redirect.callsFake(() => done())
this.AuthorizationMiddleware[method](this.req, this.res, this.next)
})
}
function expectNext() {
it('calls the next middleware', function () {
expect(this.next).to.have.been.calledWithExactly()
})
}
function expectError() {
it('calls the error middleware', function () {
expect(this.next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})
}
function expectNotFound() {
it('raises a 404', function () {
expect(this.next).to.have.been.calledWith(
sinon.match.instanceOf(Errors.NotFoundError)
)
})
}
function expectForbidden() {
it('raises a 403', function () {
expect(this.HttpErrorHandler.forbidden).to.have.been.calledWith(
this.req,
this.res
)
expect(this.next).not.to.have.been.called
})
}
function expectRedirectToRestricted() {
it('redirects to restricted', function () {
expect(this.res.redirect).to.have.been.calledWith(
'/restricted?from=%2Fcurrent%2Furl'
)
expect(this.next).not.to.have.been.called
})
}

View File

@@ -0,0 +1,826 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Authorization/PermissionsManager.js'
const SandboxedModule = require('sandboxed-module')
const { ForbiddenError } = require('../../../../app/src/Features/Errors/Errors')
describe('PermissionsManager', function () {
beforeEach(function () {
this.PermissionsManager = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/Modules': (this.Modules = {
promises: {
hooks: {
fire: (this.hooksFire = sinon.stub().resolves([[]])),
},
},
}),
},
})
this.PermissionsManager.registerCapability('capability1', {
default: true,
})
this.PermissionsManager.registerCapability('capability2', {
default: true,
})
this.PermissionsManager.registerCapability('capability3', {
default: true,
})
this.PermissionsManager.registerCapability('capability4', {
default: false,
})
this.PermissionsManager.registerPolicy('openPolicy', {
capability1: true,
capability2: true,
})
this.PermissionsManager.registerPolicy('restrictivePolicy', {
capability1: true,
capability2: false,
})
this.openPolicyResponseSet = [
[
{
managedUsersEnabled: true,
groupPolicy: { openPolicy: true },
},
{
managedUsersEnabled: true,
groupPolicy: { openPolicy: true },
},
],
]
this.restrictivePolicyResponseSet = [
[
{
managedUsersEnabled: true,
groupPolicy: { openPolicy: true },
},
{
managedUsersEnabled: true,
groupPolicy: { restrictivePolicy: true },
},
],
]
})
describe('validatePolicies', function () {
it('accepts empty object', function () {
expect(() => this.PermissionsManager.validatePolicies({})).not.to.throw
})
it('accepts object with registered policies', function () {
expect(() =>
this.PermissionsManager.validatePolicies({
openPolicy: true,
restrictivePolicy: false,
})
).not.to.throw
})
it('accepts object with policies containing non-boolean values', function () {
expect(() =>
this.PermissionsManager.validatePolicies({
openPolicy: 1,
})
).to.throw('policy value must be a boolean: openPolicy = 1')
expect(() =>
this.PermissionsManager.validatePolicies({
openPolicy: undefined,
})
).to.throw('policy value must be a boolean: openPolicy = undefined')
expect(() =>
this.PermissionsManager.validatePolicies({
openPolicy: null,
})
).to.throw('policy value must be a boolean: openPolicy = null')
})
it('throws error on object with policies that are not registered', function () {
expect(() =>
this.PermissionsManager.validatePolicies({
openPolicy: true,
unregisteredPolicy: false,
})
).to.throw('unknown policy: unregisteredPolicy')
})
})
describe('hasPermission', function () {
describe('when no policies apply to the user', function () {
it('should return true if default permission is true', function () {
const groupPolicy = {}
const capability = 'capability1'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
})
it('should return false if the default permission is false', function () {
const groupPolicy = {}
const capability = 'capability4'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.false
})
})
describe('when a policy applies to the user', function () {
it('should return true if the user has the capability after the policy is applied', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: true,
}
const capability = 'capability1'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
})
it('should return false if the user does not have the capability after the policy is applied', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: true,
}
const capability = 'capability2'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.false
})
it('should return the default permission if the policy does not apply to the capability', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: true,
}
{
const capability = 'capability3'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
}
{
const capability = 'capability4'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.false
}
})
it('should return the default permission if the policy is not enforced', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: false,
}
const capability1 = 'capability1'
const result1 = this.PermissionsManager.hasPermission(
groupPolicy,
capability1
)
const capability2 = 'capability2'
const result2 = this.PermissionsManager.hasPermission(
groupPolicy,
capability2
)
expect(result1).to.be.true
expect(result2).to.be.true
})
})
describe('when multiple policies apply to the user', function () {
it('should return true if all policies allow the capability', function () {
this.PermissionsManager.registerPolicy('policy1', {
capability1: true,
capability2: true,
})
this.PermissionsManager.registerPolicy('policy2', {
capability1: true,
capability2: true,
})
const groupPolicy = {
policy1: true,
policy2: true,
}
const capability = 'capability1'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
})
it('should return false if any policy denies the capability', function () {
this.PermissionsManager.registerPolicy('policy1', {
capability1: true,
capability2: true,
})
this.PermissionsManager.registerPolicy('policy2', {
capability1: false,
capability2: true,
})
const groupPolicy = {
policy1: true,
policy2: true,
}
const capability = 'capability1'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.false
})
it('should return the default permssion when the applicable policy is not enforced', function () {
this.PermissionsManager.registerPolicy('policy1', {
capability1: true,
capability2: true,
})
this.PermissionsManager.registerPolicy('policy2', {
capability1: false,
capability2: true,
})
const groupPolicy = {
policy1: true,
policy2: false,
}
const capability = 'capability1'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
})
it('should return the default permission if the policies do not restrict to the capability', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: true,
}
{
const capability = 'capability3'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.true
}
{
const capability = 'capability4'
const result = this.PermissionsManager.hasPermission(
groupPolicy,
capability
)
expect(result).to.be.false
}
})
})
})
describe('getUserCapabilities', function () {
it('should return the default capabilities when no group policy is provided', function () {
const groupPolicy = {}
const capabilities =
this.PermissionsManager.getUserCapabilities(groupPolicy)
expect(capabilities).to.deep.equal(
new Set(['capability1', 'capability2', 'capability3'])
)
})
it('should return a reduced capability set when a group policy is provided', function () {
this.PermissionsManager.registerPolicy('policy', {
capability1: true,
capability2: false,
})
const groupPolicy = {
policy: true,
}
const capabilities =
this.PermissionsManager.getUserCapabilities(groupPolicy)
expect(capabilities).to.deep.equal(
new Set(['capability1', 'capability3'])
)
})
it('should return a reduced capability set when multiple group policies are provided', function () {
this.PermissionsManager.registerPolicy('policy1', {
capability1: true,
capability2: false,
})
this.PermissionsManager.registerPolicy('policy2', {
capability1: false,
capability2: true,
})
const groupPolicy = {
policy1: true,
policy2: true,
}
const capabilities =
this.PermissionsManager.getUserCapabilities(groupPolicy)
expect(capabilities).to.deep.equal(new Set(['capability3']))
})
it('should return an empty capability set when group policies remove all permissions', function () {
this.PermissionsManager.registerPolicy('policy1', {
capability1: true,
capability2: false,
})
this.PermissionsManager.registerPolicy('policy2', {
capability1: false,
capability2: true,
})
this.PermissionsManager.registerPolicy('policy3', {
capability1: true,
capability2: true,
capability3: false,
})
const groupPolicy = {
policy1: true,
policy2: true,
policy3: true,
}
const capabilities =
this.PermissionsManager.getUserCapabilities(groupPolicy)
expect(capabilities).to.deep.equal(new Set())
})
})
describe('getUserValidationStatus', function () {
it('should return the status for the policy when the user conforms', async function () {
this.PermissionsManager.registerPolicy(
'policy',
{},
{
validator: async ({ user, subscription }) => {
return user.prop === 'allowed' && subscription.prop === 'managed'
},
}
)
const groupPolicy = {
policy: true,
}
const user = { prop: 'allowed' }
const subscription = { prop: 'managed' }
const result =
await this.PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy,
subscription,
})
expect(result).to.deep.equal(new Map([['policy', true]]))
})
it('should return the status for the policy when the user does not conform', async function () {
this.PermissionsManager.registerPolicy(
'policy',
{},
{
validator: async ({ user, subscription }) => {
return user.prop === 'allowed' && subscription.prop === 'managed'
},
}
)
const groupPolicy = {
policy: true,
}
const user = { prop: 'not allowed' }
const subscription = { prop: 'managed' }
const result =
await this.PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy,
subscription,
})
expect(result).to.deep.equal(new Map([['policy', false]]))
})
it('should return the status for multiple policies according to whether the user conforms', async function () {
this.PermissionsManager.registerPolicy(
'policy1',
{},
{
validator: async ({ user, subscription }) => {
return user.prop === 'allowed' && subscription.prop === 'managed'
},
}
)
this.PermissionsManager.registerPolicy(
'policy2',
{},
{
validator: async ({ user, subscription }) => {
return user.prop === 'other' && subscription.prop === 'managed'
},
}
)
this.PermissionsManager.registerPolicy(
'policy3',
{},
{
validator: async ({ user, subscription }) => {
return user.prop === 'allowed' && subscription.prop === 'managed'
},
}
)
const groupPolicy = {
policy1: true,
policy2: true,
policy3: false, // this policy is not enforced
}
const user = { prop: 'allowed' }
const subscription = { prop: 'managed' }
const result =
await this.PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy,
subscription,
})
expect(result).to.deep.equal(
new Map([
['policy1', true],
['policy2', false],
])
)
})
})
describe('assertUserPermissions', function () {
describe('allowed', function () {
it('should not error when managedUsersEnabled is not enabled for user', async function () {
const result =
await this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['add-secondary-email']
)
expect(result).to.be.undefined
})
it('should not error when default capability is true', async function () {
this.PermissionsManager.registerCapability('some-policy-to-check', {
default: true,
})
this.hooksFire.resolves([
[
{
managedUsersEnabled: true,
groupPolicy: {},
},
],
])
const result =
await this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['some-policy-to-check']
)
expect(result).to.be.undefined
})
it('should not error when default permission is false but user has permission', async function () {
this.PermissionsManager.registerCapability('some-policy-to-check', {
default: false,
})
this.PermissionsManager.registerPolicy('userCanDoSomePolicy', {
'some-policy-to-check': true,
})
this.hooksFire.resolves([
[
{
managedUsersEnabled: true,
groupPolicy: {
userCanDoSomePolicy: true,
},
},
],
])
const result =
await this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['some-policy-to-check']
)
expect(result).to.be.undefined
})
})
describe('not allowed', function () {
it('should return error when managedUsersEnabled is enabled for user but there is no group policy', async function () {
this.hooksFire.resolves([[{ managedUsersEnabled: true }]])
await expect(
this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['add-secondary-email']
)
).to.be.rejectedWith(Error, 'unknown capability: add-secondary-email')
})
it('should return error when default permission is false', async function () {
this.PermissionsManager.registerCapability('some-policy-to-check', {
default: false,
})
this.hooksFire.resolves([
[
{
managedUsersEnabled: true,
groupPolicy: {},
},
],
])
await expect(
this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['some-policy-to-check']
)
).to.be.rejectedWith(ForbiddenError)
})
it('should return error when default permission is true but user does not have permission', async function () {
this.PermissionsManager.registerCapability('some-policy-to-check', {
default: true,
})
this.PermissionsManager.registerPolicy('userCannotDoSomePolicy', {
'some-policy-to-check': false,
})
this.hooksFire.resolves([
[
{
managedUsersEnabled: true,
groupPolicy: { userCannotDoSomePolicy: true },
},
],
])
await expect(
this.PermissionsManager.promises.assertUserPermissions(
{ _id: 'user123' },
['some-policy-to-check']
)
).to.be.rejectedWith(ForbiddenError)
})
})
})
describe('registerAllowedProperty', function () {
it('allows us to register a property', async function () {
this.PermissionsManager.registerAllowedProperty('metadata1')
const result = await this.PermissionsManager.getAllowedProperties()
expect(result).to.deep.equal(new Set(['metadata1']))
})
// used if multiple modules would require the same prop, since we dont know which will load first, both must register
it('should handle multiple registrations of the same property', async function () {
this.PermissionsManager.registerAllowedProperty('metadata1')
this.PermissionsManager.registerAllowedProperty('metadata1')
const result = await this.PermissionsManager.getAllowedProperties()
expect(result).to.deep.equal(new Set(['metadata1']))
})
})
describe('combineAllowedProperties', function () {
it('should handle multiple occurences of the same property, preserving the first occurence', async function () {
const policy1 = {
groupPolicy: {
policy: false,
},
prop1: 'some other value here',
}
const policy2 = {
groupPolicy: {
policy: false,
},
prop1: 'some value here',
}
const results = [policy1, policy2]
this.PermissionsManager.registerAllowedProperty('prop1')
const combinedProps =
this.PermissionsManager.combineAllowedProperties(results)
expect(combinedProps).to.deep.equal({
prop1: 'some other value here',
})
})
it('should add registered properties to the set', async function () {
const policy = {
groupPolicy: {
policy: false,
},
prop1: 'some value here',
propNotMeThough: 'dont copy please',
}
const policy2 = {
groupPolicy: {
policy: false,
},
prop2: 'some value here',
}
const results = [policy, policy2]
this.PermissionsManager.registerAllowedProperty('prop1')
this.PermissionsManager.registerAllowedProperty('prop2')
const combinedProps =
this.PermissionsManager.combineAllowedProperties(results)
expect(combinedProps).to.deep.equal({
prop1: 'some value here',
prop2: 'some value here',
})
})
it('should not add unregistered properties to the req object', async function () {
const policy = {
groupPolicy: {
policy: false,
},
prop1: 'some value here',
}
const policy2 = {
groupPolicy: {
policy: false,
},
prop2: 'some value here',
}
this.PermissionsManager.registerAllowedProperty('prop1')
const results = [policy, policy2]
const combinedProps =
this.PermissionsManager.combineAllowedProperties(results)
expect(combinedProps).to.deep.equal({ prop1: 'some value here' })
})
it('should handle an empty array', async function () {
const results = []
const combinedProps =
this.PermissionsManager.combineAllowedProperties(results)
expect(combinedProps).to.deep.equal({})
})
})
describe('combineGroupPolicies', function () {
it('should return an empty object when an empty array is passed', async function () {
const results = []
const combinedPolicy =
this.PermissionsManager.combineGroupPolicies(results)
expect(combinedPolicy).to.deep.equal({})
})
it('should combine multiple group policies into a single policy object', async function () {
const groupPolicy = {
policy1: true,
}
const groupPolicy2 = {
policy2: false,
policy3: true,
}
this.PermissionsManager.registerAllowedProperty('prop1')
const results = [groupPolicy, groupPolicy2]
const combinedPolicy =
this.PermissionsManager.combineGroupPolicies(results)
expect(combinedPolicy).to.deep.equal({
policy1: true,
policy3: true,
})
})
it('should handle duplicate enforced policies across different group policies', async function () {
const groupPolicy = {
policy1: false,
policy2: true,
}
const groupPolicy2 = {
policy2: true,
policy3: true,
}
this.PermissionsManager.registerAllowedProperty('prop1')
const results = [groupPolicy, groupPolicy2]
const combinedPolicy =
this.PermissionsManager.combineGroupPolicies(results)
expect(combinedPolicy).to.deep.equal({
policy2: true,
policy3: true,
})
})
it('should handle group policies with no enforced policies', async function () {
const groupPolicy = {
policy1: false,
policy2: false,
}
const groupPolicy2 = {
policy2: false,
policy3: true,
}
this.PermissionsManager.registerAllowedProperty('prop1')
const results = [groupPolicy, groupPolicy2]
const combinedPolicy =
this.PermissionsManager.combineGroupPolicies(results)
expect(combinedPolicy).to.deep.equal({ policy3: true })
})
it('should choose the stricter option between two policy values', async function () {
const groupPolicy = {
policy1: false,
policy2: true,
policy4: true,
}
const groupPolicy2 = {
policy2: false,
policy3: true,
policy4: false,
}
this.PermissionsManager.registerAllowedProperty('prop1')
const results = [groupPolicy, groupPolicy2]
const combinedPolicy =
this.PermissionsManager.combineGroupPolicies(results)
expect(combinedPolicy).to.deep.equal({
policy2: true,
policy3: true,
policy4: true,
})
})
})
describe('checkUserListPermissions', function () {
it('should return true when all users have permissions required', async function () {
const userList = ['user1', 'user2', 'user3']
const capabilities = ['capability1', 'capability2']
this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet)
this.hooksFire.onCall(1).resolves(this.openPolicyResponseSet)
this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet)
const usersHavePermission =
await this.PermissionsManager.promises.checkUserListPermissions(
userList,
capabilities
)
expect(usersHavePermission).to.equal(true)
})
it('should return false if any user does not have permission', async function () {
const userList = ['user1', 'user2', 'user3']
const capabilities = ['capability1', 'capability2']
this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet)
this.hooksFire.onCall(1).resolves(this.restrictivePolicyResponseSet)
this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet)
const usersHavePermission =
await this.PermissionsManager.promises.checkUserListPermissions(
userList,
capabilities
)
expect(usersHavePermission).to.equal(false)
})
})
})

View File

@@ -0,0 +1,199 @@
import esmock from 'esmock'
import path from 'node:path'
import sinon from 'sinon'
import { expect } from 'chai'
import MockResponse from '../helpers/MockResponse.js'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/BetaProgram/BetaProgramController'
)
describe('BetaProgramController', function () {
beforeEach(async function () {
this.user = {
_id: (this.user_id = 'a_simple_id'),
email: 'user@example.com',
features: {},
betaProgram: false,
}
this.req = {
query: {},
session: {
user: this.user,
},
}
this.SplitTestSessionHandler = {
promises: {
sessionMaintenance: sinon.stub(),
},
}
this.BetaProgramController = await esmock.strict(modulePath, {
'../../../../app/src/Features/SplitTests/SplitTestSessionHandler':
this.SplitTestSessionHandler,
'../../../../app/src/Features/BetaProgram/BetaProgramHandler':
(this.BetaProgramHandler = {
promises: {
optIn: sinon.stub().resolves(),
optOut: sinon.stub().resolves(),
},
}),
'../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(),
},
}),
'@overleaf/settings': (this.settings = {
languages: {},
}),
'../../../../app/src/Features/Authentication/AuthenticationController':
(this.AuthenticationController = {
getLoggedInUserId: sinon.stub().returns(this.user._id),
}),
})
this.res = new MockResponse()
this.next = sinon.stub()
})
describe('optIn', function () {
it("should redirect to '/beta/participate'", function (done) {
this.res.callback = () => {
this.res.redirectedTo.should.equal('/beta/participate')
done()
}
this.BetaProgramController.optIn(this.req, this.res, done)
})
it('should not call next with an error', function () {
this.BetaProgramController.optIn(this.req, this.res, this.next)
this.next.callCount.should.equal(0)
})
it('should call BetaProgramHandler.optIn', function () {
this.BetaProgramController.optIn(this.req, this.res, this.next)
this.BetaProgramHandler.promises.optIn.callCount.should.equal(1)
})
it('should invoke the session maintenance', function (done) {
this.res.callback = () => {
this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
this.req
)
done()
}
this.BetaProgramController.optIn(this.req, this.res, done)
})
describe('when BetaProgramHandler.opIn produces an error', function () {
beforeEach(function () {
this.BetaProgramHandler.promises.optIn.throws(new Error('woops'))
})
it("should not redirect to '/beta/participate'", function () {
this.BetaProgramController.optIn(this.req, this.res, this.next)
this.res.redirect.callCount.should.equal(0)
})
it('should produce an error', function (done) {
this.BetaProgramController.optIn(this.req, this.res, err => {
expect(err).to.be.instanceof(Error)
done()
})
})
})
})
describe('optOut', function () {
it("should redirect to '/beta/participate'", function (done) {
this.res.callback = () => {
expect(this.res.redirectedTo).to.equal('/beta/participate')
done()
}
this.BetaProgramController.optOut(this.req, this.res, done)
})
it('should not call next with an error', function (done) {
this.res.callback = () => {
this.next.callCount.should.equal(0)
done()
}
this.BetaProgramController.optOut(this.req, this.res, done)
})
it('should call BetaProgramHandler.optOut', function (done) {
this.res.callback = () => {
this.BetaProgramHandler.promises.optOut.callCount.should.equal(1)
done()
}
this.BetaProgramController.optOut(this.req, this.res, done)
})
it('should invoke the session maintenance', function (done) {
this.res.callback = () => {
this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
this.req,
null
)
done()
}
this.BetaProgramController.optOut(this.req, this.res, done)
})
describe('when BetaProgramHandler.optOut produces an error', function () {
beforeEach(function () {
this.BetaProgramHandler.promises.optOut.throws(new Error('woops'))
})
it("should not redirect to '/beta/participate'", function (done) {
this.BetaProgramController.optOut(this.req, this.res, error => {
expect(error).to.exist
expect(this.res.redirected).to.equal(false)
done()
})
})
it('should produce an error', function (done) {
this.BetaProgramController.optOut(this.req, this.res, error => {
expect(error).to.exist
done()
})
})
})
})
describe('optInPage', function () {
beforeEach(function () {
this.UserGetter.promises.getUser.resolves(this.user)
})
it('should render the opt-in page', function (done) {
this.res.callback = () => {
expect(this.res.renderedTemplate).to.equal('beta_program/opt_in')
done()
}
this.BetaProgramController.optInPage(this.req, this.res, done)
})
describe('when UserGetter.getUser produces an error', function () {
beforeEach(function () {
this.UserGetter.promises.getUser.throws(new Error('woops'))
})
it('should not render the opt-in page', function () {
this.BetaProgramController.optInPage(this.req, this.res, this.next)
this.res.render.callCount.should.equal(0)
})
it('should produce an error', function (done) {
this.BetaProgramController.optInPage(this.req, this.res, error => {
expect(error).to.exist
expect(error).to.be.instanceof(Error)
done()
})
})
})
})
})

View File

@@ -0,0 +1,142 @@
import esmock from 'esmock'
import path from 'node:path'
import sinon from 'sinon'
import { expect } from 'chai'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/BetaProgram/BetaProgramHandler'
)
describe('BetaProgramHandler', function () {
beforeEach(async function () {
this.user_id = 'some_id'
this.user = {
_id: this.user_id,
email: 'user@example.com',
features: {},
betaProgram: false,
save: sinon.stub().callsArgWith(0, null),
}
this.handler = await esmock.strict(modulePath, {
'@overleaf/metrics': {
inc: sinon.stub(),
},
'../../../../app/src/Features/User/UserUpdater': (this.UserUpdater = {
promises: {
updateUser: sinon.stub().resolves(),
},
}),
'../../../../app/src/Features/Analytics/AnalyticsManager':
(this.AnalyticsManager = {
setUserPropertyForUserInBackground: sinon.stub(),
}),
})
})
describe('optIn', function () {
beforeEach(function () {
this.user.betaProgram = false
this.call = callback => {
this.handler.optIn(this.user_id, callback)
}
})
it('should call userUpdater', function (done) {
this.call(err => {
expect(err).to.not.exist
this.UserUpdater.promises.updateUser.callCount.should.equal(1)
done()
})
})
it('should set beta-program user property to true', function (done) {
this.call(err => {
expect(err).to.not.exist
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUserInBackground,
this.user_id,
'beta-program',
true
)
done()
})
})
it('should not produce an error', function (done) {
this.call(err => {
expect(err).to.not.exist
done()
})
})
describe('when userUpdater produces an error', function () {
beforeEach(function () {
this.UserUpdater.promises.updateUser.rejects()
})
it('should produce an error', function (done) {
this.call(err => {
expect(err).to.exist
expect(err).to.be.instanceof(Error)
done()
})
})
})
})
describe('optOut', function () {
beforeEach(function () {
this.user.betaProgram = true
this.call = callback => {
this.handler.optOut(this.user_id, callback)
}
})
it('should call userUpdater', function (done) {
this.call(err => {
expect(err).to.not.exist
this.UserUpdater.promises.updateUser.callCount.should.equal(1)
done()
})
})
it('should set beta-program user property to false', function (done) {
this.call(err => {
expect(err).to.not.exist
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUserInBackground,
this.user_id,
'beta-program',
false
)
done()
})
})
it('should not produce an error', function (done) {
this.call(err => {
expect(err).to.not.exist
done()
})
})
describe('when userUpdater produces an error', function () {
beforeEach(function () {
this.UserUpdater.promises.updateUser.rejects()
})
it('should produce an error', function (done) {
this.call(err => {
expect(err).to.exist
expect(err).to.be.instanceof(Error)
done()
})
})
})
})
})

View File

@@ -0,0 +1,143 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/BrandVariations/BrandVariationsHandler'
)
describe('BrandVariationsHandler', function () {
beforeEach(function () {
this.settings = {
apis: {
v1: {
publicUrl: 'http://overleaf.example.com',
},
},
modules: {
sanitize: {
options: {
allowedTags: ['br', 'strong'],
allowedAttributes: {
strong: ['style'],
},
},
},
},
}
this.V1Api = { request: sinon.stub() }
this.BrandVariationsHandler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
'../V1/V1Api': this.V1Api,
},
})
return (this.mockedBrandVariationDetails = {
id: '12',
active: true,
brand_name: 'The journal',
logo_url: 'http://my.cdn.tld/journal-logo.png',
journal_cover_url: 'http://my.cdn.tld/journal-cover.jpg',
home_url: 'http://www.thejournal.com/',
publish_menu_link_html: 'Submit your paper to the <em>The Journal</em>',
})
})
describe('getBrandVariationById', function () {
it('should call the callback with an error when the branding variation id is not provided', function (done) {
return this.BrandVariationsHandler.getBrandVariationById(
null,
(err, brandVariationDetails) => {
expect(err).to.be.instanceof(Error)
return done()
}
)
})
it('should call the callback with an error when the request errors', function (done) {
this.V1Api.request.callsArgWith(1, new Error())
return this.BrandVariationsHandler.getBrandVariationById(
'12',
(err, brandVariationDetails) => {
expect(err).to.be.instanceof(Error)
return done()
}
)
})
it('should call the callback with branding details when request succeeds', function (done) {
this.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
return this.BrandVariationsHandler.getBrandVariationById(
'12',
(err, brandVariationDetails) => {
expect(err).to.not.exist
expect(brandVariationDetails).to.deep.equal(
this.mockedBrandVariationDetails
)
return done()
}
)
})
it('should transform relative URLs in v1 absolute ones', function (done) {
this.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
this.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
return this.BrandVariationsHandler.getBrandVariationById(
'12',
(err, brandVariationDetails) => {
expect(
brandVariationDetails.logo_url.startsWith(
this.settings.apis.v1.publicUrl
)
).to.be.true
return done()
}
)
})
it("should sanitize 'submit_button_html'", function (done) {
this.mockedBrandVariationDetails.submit_button_html =
'<br class="break"/><strong style="color:#B39500">AGU Journal</strong><iframe>hello</iframe>'
this.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
return this.BrandVariationsHandler.getBrandVariationById(
'12',
(err, brandVariationDetails) => {
expect(brandVariationDetails.submit_button_html).to.equal(
'<br /><strong style="color:#B39500">AGU Journal</strong>hello'
)
return done()
}
)
})
})
})

View File

@@ -0,0 +1,230 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const { RequestFailedError } = require('@overleaf/fetch-utils')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Chat/ChatApiHandler'
)
describe('ChatApiHandler', function () {
beforeEach(function () {
this.settings = {
apis: {
chat: {
internal_url: 'http://chat.overleaf.env',
},
},
}
this.FetchUtils = {
fetchJson: sinon.stub(),
fetchNothing: sinon.stub().resolves(),
}
this.ChatApiHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.settings,
'@overleaf/fetch-utils': this.FetchUtils,
},
})
this.project_id = '3213213kl12j'
this.user_id = '2k3jlkjs9'
this.content = 'my message here'
})
describe('sendGlobalMessage', function () {
describe('successfully', function () {
beforeEach(async function () {
this.message = { mock: 'message' }
this.FetchUtils.fetchJson.resolves(this.message)
this.result = await this.ChatApiHandler.promises.sendGlobalMessage(
this.project_id,
this.user_id,
this.content
)
})
it('should post the data to the chat api', function () {
this.FetchUtils.fetchJson.should.have.been.calledWith(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages`
),
{
method: 'POST',
json: {
content: this.content,
user_id: this.user_id,
},
}
)
})
it('should return the message from the post', function () {
expect(this.result).to.deep.equal(this.message)
})
})
describe('with a non-success status code', function () {
beforeEach(async function () {
this.error = new RequestFailedError('some-url', {}, { status: 500 })
this.FetchUtils.fetchJson.rejects(this.error)
await expect(
this.ChatApiHandler.promises.sendGlobalMessage(
this.project_id,
this.user_id,
this.content
)
).to.be.rejectedWith(this.error)
})
})
})
describe('getGlobalMessages', function () {
beforeEach(function () {
this.messages = [{ mock: 'message' }]
this.limit = 30
this.before = '1234'
})
describe('successfully', function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves(this.messages)
this.result = await this.ChatApiHandler.promises.getGlobalMessages(
this.project_id,
this.limit,
this.before
)
})
it('should make get request for room to chat api', function () {
this.FetchUtils.fetchJson.should.have.been.calledWith(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages?limit=${this.limit}&before=${this.before}`
)
)
})
it('should return the messages from the request', function () {
expect(this.result).to.deep.equal(this.messages)
})
})
describe('with failure error code', function () {
beforeEach(async function () {
this.error = new RequestFailedError('some-url', {}, { status: 500 })
this.FetchUtils.fetchJson.rejects(this.error)
await expect(
this.ChatApiHandler.getGlobalMessages(
this.project_id,
this.limit,
this.before
)
).to.be.rejectedWith(this.error)
})
})
})
describe('duplicateCommentThreads', function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves(
(this.mapping = {
'comment-thread-1': 'comment-thread-1-dup',
'comment-thread-2': 'comment-thread-2-dup',
'comment-thread-3': 'comment-thread-3-dup',
})
)
this.threads = [
'comment-thread-1',
'comment-thread-2',
'comment-thread-3',
]
this.result = await this.ChatApiHandler.promises.duplicateCommentThreads(
this.project_id,
this.threads
)
})
it('should make a post request to the chat api', function () {
expect(this.FetchUtils.fetchJson).to.have.been.calledWith(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.chat.internal_url}/project/${this.project_id}/duplicate-comment-threads`
),
{
method: 'POST',
json: {
threads: this.threads,
},
}
)
})
it('should return the thread mapping', function () {
expect(this.result).to.deep.equal(this.mapping)
})
})
describe('generateThreadData', async function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves(
(this.chatResponse = {
'comment-thread-1': {
messages: [
{
content: 'message 1',
timestamp: '2024-01-01T00:00:00.000Z',
user_id: 'user-1',
},
],
},
'comment-thread-2': {
messages: [
{
content: 'message 2',
timestamp: '2024-01-01T00:00:00.000Z',
user_id: 'user-2',
},
],
},
})
)
// Chat won't return threads that couldn't be found, so response can have
// fewer threads
this.threads = [
'comment-thread-1',
'comment-thread-2',
'comment-thread-3',
]
this.result = await this.ChatApiHandler.promises.generateThreadData(
this.project_id,
this.threads
)
})
it('should make a post request to the chat api', function () {
expect(this.FetchUtils.fetchJson).to.have.been.calledWith(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.chat.internal_url}/project/${this.project_id}/generate-thread-data`
),
{
method: 'POST',
json: {
threads: this.threads,
},
}
)
})
it('should return the thread data', function () {
expect(this.result).to.deep.equal(this.chatResponse)
})
})
})

View File

@@ -0,0 +1,127 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Chat/ChatController'
)
describe('ChatController', function () {
beforeEach(function () {
this.user_id = 'mock-user-id'
this.settings = {}
this.ChatApiHandler = {}
this.ChatManager = {}
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user_id),
}
this.ChatController = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
'./ChatApiHandler': this.ChatApiHandler,
'./ChatManager': this.ChatManager,
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
'../Authentication/SessionManager': this.SessionManager,
'../User/UserInfoManager': (this.UserInfoManager = {}),
'../User/UserInfoController': (this.UserInfoController = {}),
},
})
this.req = {
params: {
project_id: this.project_id,
},
}
this.res = {
json: sinon.stub(),
send: sinon.stub(),
sendStatus: sinon.stub(),
}
})
describe('sendMessage', function () {
beforeEach(function () {
this.req.body = { content: (this.content = 'message-content') }
this.UserInfoManager.getPersonalInfo = sinon
.stub()
.yields(null, (this.user = { unformatted: 'user' }))
this.UserInfoController.formatPersonalInfo = sinon
.stub()
.returns((this.formatted_user = { formatted: 'user' }))
this.ChatApiHandler.sendGlobalMessage = sinon
.stub()
.yields(
null,
(this.message = { mock: 'message', user_id: this.user_id })
)
return this.ChatController.sendMessage(this.req, this.res)
})
it('should look up the user', function () {
return this.UserInfoManager.getPersonalInfo
.calledWith(this.user_id)
.should.equal(true)
})
it('should format and inject the user into the message', function () {
this.UserInfoController.formatPersonalInfo
.calledWith(this.user)
.should.equal(true)
return this.message.user.should.deep.equal(this.formatted_user)
})
it('should tell the chat handler about the message', function () {
return this.ChatApiHandler.sendGlobalMessage
.calledWith(this.project_id, this.user_id, this.content)
.should.equal(true)
})
it('should tell the editor real time controller about the update with the data from the chat handler', function () {
return this.EditorRealTimeController.emitToRoom
.calledWith(this.project_id, 'new-chat-message', this.message)
.should.equal(true)
})
it('should return a 204 status code', function () {
return this.res.sendStatus.calledWith(204).should.equal(true)
})
})
describe('getMessages', function () {
beforeEach(function () {
this.req.query = {
limit: (this.limit = '30'),
before: (this.before = '12345'),
}
this.ChatManager.injectUserInfoIntoThreads = sinon.stub().yields()
this.ChatApiHandler.getGlobalMessages = sinon
.stub()
.yields(null, (this.messages = ['mock', 'messages']))
return this.ChatController.getMessages(this.req, this.res)
})
it('should ask the chat handler about the request', function () {
return this.ChatApiHandler.getGlobalMessages
.calledWith(this.project_id, this.limit, this.before)
.should.equal(true)
})
it('should return the messages', function () {
return this.res.json.calledWith(this.messages).should.equal(true)
})
})
})

View File

@@ -0,0 +1,135 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Chat/ChatManager'
)
const { expect } = require('chai')
describe('ChatManager', function () {
beforeEach(function () {
this.user_id = 'mock-user-id'
this.ChatManager = SandboxedModule.require(modulePath, {
requires: {
'../User/UserInfoManager': (this.UserInfoManager = {}),
'../User/UserInfoController': (this.UserInfoController = {}),
},
})
this.req = {
params: {
project_id: this.project_id,
},
}
this.res = {
json: sinon.stub(),
send: sinon.stub(),
sendStatus: sinon.stub(),
}
})
describe('injectUserInfoIntoThreads', function () {
beforeEach(function () {
this.users = {
user_id_1: {
mock: 'user_1',
},
user_id_2: {
mock: 'user_2',
},
}
this.UserInfoManager.getPersonalInfo = (userId, callback) => {
return callback(null, this.users[userId])
}
sinon.spy(this.UserInfoManager, 'getPersonalInfo')
return (this.UserInfoController.formatPersonalInfo = user => ({
formatted: user.mock,
}))
})
it('should inject a user object into messaged and resolved data', function (done) {
return this.ChatManager.injectUserInfoIntoThreads(
{
thread1: {
resolved: true,
resolved_by_user_id: 'user_id_1',
messages: [
{
user_id: 'user_id_1',
content: 'foo',
},
{
user_id: 'user_id_2',
content: 'bar',
},
],
},
thread2: {
messages: [
{
user_id: 'user_id_1',
content: 'baz',
},
],
},
},
(error, threads) => {
expect(error).to.be.null
expect(threads).to.deep.equal({
thread1: {
resolved: true,
resolved_by_user_id: 'user_id_1',
resolved_by_user: { formatted: 'user_1' },
messages: [
{
user_id: 'user_id_1',
user: { formatted: 'user_1' },
content: 'foo',
},
{
user_id: 'user_id_2',
user: { formatted: 'user_2' },
content: 'bar',
},
],
},
thread2: {
messages: [
{
user_id: 'user_id_1',
user: { formatted: 'user_1' },
content: 'baz',
},
],
},
})
return done()
}
)
})
it('should only need to look up each user once', function (done) {
return this.ChatManager.injectUserInfoIntoThreads(
[
{
messages: [
{
user_id: 'user_id_1',
content: 'foo',
},
{
user_id: 'user_id_1',
content: 'bar',
},
],
},
],
(error, threads) => {
expect(error).to.be.null
this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
return done()
}
)
})
})
})

View File

@@ -0,0 +1,435 @@
import sinon from 'sinon'
import { expect } from 'chai'
import esmock from 'esmock'
import mongodb from 'mongodb-legacy'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
const ObjectId = mongodb.ObjectId
const MODULE_PATH =
'../../../../app/src/Features/Collaborators/CollaboratorsController.mjs'
describe('CollaboratorsController', function () {
beforeEach(async function () {
this.res = new MockResponse()
this.req = new MockRequest()
this.user = { _id: new ObjectId() }
this.projectId = new ObjectId()
this.callback = sinon.stub()
this.CollaboratorsHandler = {
promises: {
removeUserFromProject: sinon.stub().resolves(),
setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
},
createTokenHashPrefix: sinon.stub().returns('abc123'),
}
this.CollaboratorsGetter = {
promises: {
getAllInvitedMembers: sinon.stub(),
},
}
this.EditorRealTimeController = {
emitToRoom: sinon.stub(),
}
this.HttpErrorHandler = {
forbidden: sinon.stub(),
notFound: sinon.stub(),
}
this.TagsHandler = {
promises: {
removeProjectFromAllTags: sinon.stub().resolves(),
},
}
this.SessionManager = {
getSessionUser: sinon.stub().returns(this.user),
getLoggedInUserId: sinon.stub().returns(this.user._id),
}
this.OwnershipTransferHandler = {
promises: {
transferOwnership: sinon.stub().resolves(),
},
}
this.TokenAccessHandler = {
getRequestToken: sinon.stub().returns('access-token'),
}
this.ProjectAuditLogHandler = {
addEntryInBackground: sinon.stub(),
}
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves({ owner_ref: this.user._id }),
},
}
this.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
this.LimitationsManager = {
promises: {
canAddXEditCollaborators: sinon.stub().resolves(),
canChangeCollaboratorPrivilegeLevel: sinon.stub().resolves(true),
},
}
this.CollaboratorsController = await esmock.strict(MODULE_PATH, {
'mongodb-legacy': { ObjectId },
'../../../../app/src/Features/Collaborators/CollaboratorsHandler.js':
this.CollaboratorsHandler,
'../../../../app/src/Features/Collaborators/CollaboratorsGetter.js':
this.CollaboratorsGetter,
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js':
this.OwnershipTransferHandler,
'../../../../app/src/Features/Editor/EditorRealTimeController':
this.EditorRealTimeController,
'../../../../app/src/Features/Errors/HttpErrorHandler.js':
this.HttpErrorHandler,
'../../../../app/src/Features/Tags/TagsHandler.js': this.TagsHandler,
'../../../../app/src/Features/Authentication/SessionManager.js':
this.SessionManager,
'../../../../app/src/Features/TokenAccess/TokenAccessHandler.js':
this.TokenAccessHandler,
'../../../../app/src/Features/Project/ProjectAuditLogHandler.js':
this.ProjectAuditLogHandler,
'../../../../app/src/Features/Project/ProjectGetter.js':
this.ProjectGetter,
'../../../../app/src/Features/SplitTests/SplitTestHandler.js':
this.SplitTestHandler,
'../../../../app/src/Features/Subscription/LimitationsManager.js':
this.LimitationsManager,
})
})
describe('removeUserFromProject', function () {
beforeEach(function (done) {
this.req.params = {
Project_id: this.projectId,
user_id: this.user._id,
}
this.res.sendStatus = sinon.spy(() => {
done()
})
this.CollaboratorsController.removeUserFromProject(this.req, this.res)
})
it('should from the user from the project', function () {
expect(
this.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(this.projectId, this.user._id)
})
it('should emit a userRemovedFromProject event to the proejct', function () {
expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
this.projectId,
'userRemovedFromProject',
this.user._id
)
})
it('should send the back a success response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should have called emitToRoom', function () {
expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
this.projectId,
'project:membership:changed'
)
})
it('should write a project audit log', function () {
this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
this.projectId,
'remove-collaborator',
this.user._id,
this.req.ip,
{ userId: this.user._id }
)
})
})
describe('removeSelfFromProject', function () {
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
this.res.sendStatus = sinon.spy(() => {
done()
})
this.CollaboratorsController.removeSelfFromProject(this.req, this.res)
})
it('should remove the logged in user from the project', function () {
expect(
this.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(this.projectId, this.user._id)
})
it('should emit a userRemovedFromProject event to the proejct', function () {
expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
this.projectId,
'userRemovedFromProject',
this.user._id
)
})
it('should remove the project from all tags', function () {
expect(
this.TagsHandler.promises.removeProjectFromAllTags
).to.have.been.calledWith(this.user._id, this.projectId)
})
it('should return a success code', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should write a project audit log', function () {
this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
this.projectId,
'leave-project',
this.user._id,
this.req.ip
)
})
})
describe('getAllMembers', function () {
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
this.res.json = sinon.spy(() => {
done()
})
this.next = sinon.stub()
this.members = [{ a: 1 }]
this.CollaboratorsGetter.promises.getAllInvitedMembers.resolves(
this.members
)
this.CollaboratorsController.getAllMembers(this.req, this.res, this.next)
})
it('should not produce an error', function () {
this.next.callCount.should.equal(0)
})
it('should produce a json response', function () {
this.res.json.callCount.should.equal(1)
this.res.json.calledWith({ members: this.members }).should.equal(true)
})
it('should call CollaboratorsGetter.getAllInvitedMembers', function () {
expect(this.CollaboratorsGetter.promises.getAllInvitedMembers).to.have
.been.calledOnce
})
describe('when CollaboratorsGetter.getAllInvitedMembers produces an error', function () {
beforeEach(function (done) {
this.res.json = sinon.stub()
this.next = sinon.spy(() => {
done()
})
this.CollaboratorsGetter.promises.getAllInvitedMembers.rejects(
new Error('woops')
)
this.CollaboratorsController.getAllMembers(
this.req,
this.res,
this.next
)
})
it('should produce an error', function () {
expect(this.next).to.have.been.calledOnce
expect(this.next).to.have.been.calledWithMatch(
sinon.match.instanceOf(Error)
)
})
it('should not produce a json response', function () {
this.res.json.callCount.should.equal(0)
})
})
})
describe('setCollaboratorInfo', function () {
beforeEach(function () {
this.req.params = {
Project_id: this.projectId,
user_id: this.user._id,
}
this.req.body = { privilegeLevel: 'readOnly' }
})
it('should set the collaborator privilege level', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly')
done()
}
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
it('should return a 404 when the project or collaborator is not found', function (done) {
this.HttpErrorHandler.notFound = sinon.spy((req, res) => {
expect(req).to.equal(this.req)
expect(res).to.equal(this.res)
done()
})
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
new Errors.NotFoundError()
)
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
it('should pass the error to the next handler when setting the privilege level fails', function (done) {
this.next = sinon.spy(err => {
expect(err).instanceOf(Error)
done()
})
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
new Error()
)
this.CollaboratorsController.setCollaboratorInfo(
this.req,
this.res,
this.next
)
})
describe('when setting privilege level to readAndWrite', function () {
beforeEach(function () {
this.req.body = { privilegeLevel: 'readAndWrite' }
})
describe('when owner can add new edit collaborators', function () {
it('should set privilege level after checking collaborators can be added', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
expect(
this.LimitationsManager.promises
.canChangeCollaboratorPrivilegeLevel
).to.have.been.calledWith(
this.projectId,
this.user._id,
'readAndWrite'
)
done()
}
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
describe('when owner cannot add edit collaborators', function () {
beforeEach(function () {
this.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves(
false
)
})
it('should return a 403 if trying to set a new edit collaborator', function (done) {
this.HttpErrorHandler.forbidden = sinon.spy((req, res) => {
expect(req).to.equal(this.req)
expect(res).to.equal(this.res)
expect(
this.LimitationsManager.promises
.canChangeCollaboratorPrivilegeLevel
).to.have.been.calledWith(
this.projectId,
this.user._id,
'readAndWrite'
)
expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
).to.not.have.been.called
done()
})
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
})
describe('when setting privilege level to readOnly', function () {
beforeEach(function () {
this.req.body = { privilegeLevel: 'readOnly' }
})
describe('when owner cannot add edit collaborators', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAddXEditCollaborators.resolves(
false
)
})
it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
expect(this.LimitationsManager.promises.canAddXEditCollaborators).to
.not.have.been.called
expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly')
done()
}
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
})
})
describe('transferOwnership', function () {
beforeEach(function () {
this.req.body = { user_id: this.user._id.toString() }
})
it('returns 204 on success', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
done()
}
this.CollaboratorsController.transferOwnership(this.req, this.res)
})
it('returns 404 if the project does not exist', function (done) {
this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
expect(req).to.equal(this.req)
expect(res).to.equal(this.res)
expect(message).to.match(/project not found/)
done()
})
this.OwnershipTransferHandler.promises.transferOwnership.rejects(
new Errors.ProjectNotFoundError()
)
this.CollaboratorsController.transferOwnership(this.req, this.res)
})
it('returns 404 if the user does not exist', function (done) {
this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
expect(req).to.equal(this.req)
expect(res).to.equal(this.res)
expect(message).to.match(/user not found/)
done()
})
this.OwnershipTransferHandler.promises.transferOwnership.rejects(
new Errors.UserNotFoundError()
)
this.CollaboratorsController.transferOwnership(this.req, this.res)
})
it('invokes HTTP forbidden error handler if the user is not a collaborator', function (done) {
this.HttpErrorHandler.forbidden = sinon.spy(() => done())
this.OwnershipTransferHandler.promises.transferOwnership.rejects(
new Errors.UserNotCollaboratorError()
)
this.CollaboratorsController.transferOwnership(this.req, this.res)
})
})
})

View File

@@ -0,0 +1,567 @@
const Path = require('path')
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const { Project } = require('../helpers/models/Project')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const MODULE_PATH = Path.join(
__dirname,
'../../../../app/src/Features/Collaborators/CollaboratorsGetter'
)
describe('CollaboratorsGetter', function () {
beforeEach(function () {
this.userId = 'efb93a186e9a06f15fea5abd'
this.ownerRef = new ObjectId()
this.readOnlyRef1 = new ObjectId()
this.readOnlyRef2 = new ObjectId()
this.pendingEditorRef = new ObjectId()
this.pendingReviewerRef = new ObjectId()
this.readWriteRef1 = new ObjectId()
this.readWriteRef2 = new ObjectId()
this.reviewer1Ref = new ObjectId()
this.reviewer2Ref = new ObjectId()
this.readOnlyTokenRef = new ObjectId()
this.readWriteTokenRef = new ObjectId()
this.nonMemberRef = new ObjectId()
this.project = {
_id: new ObjectId(),
owner_ref: [this.ownerRef],
readOnly_refs: [
this.readOnlyRef1,
this.readOnlyRef2,
this.pendingEditorRef,
this.pendingReviewerRef,
],
pendingEditor_refs: [this.pendingEditorRef],
pendingReviewer_refs: [this.pendingReviewerRef],
collaberator_refs: [this.readWriteRef1, this.readWriteRef2],
reviewer_refs: [this.reviewer1Ref, this.reviewer2Ref],
tokenAccessReadAndWrite_refs: [this.readWriteTokenRef],
tokenAccessReadOnly_refs: [this.readOnlyTokenRef],
publicAccesLevel: 'tokenBased',
tokens: {
readOnly: 'ro',
readAndWrite: 'rw',
readAndWritePrefix: 'pre',
},
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
},
}
this.ProjectMock = sinon.mock(Project)
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.ProjectEditorHandler = {
buildOwnerAndMembersViews: sinon.stub(),
}
this.CollaboratorsGetter = SandboxedModule.require(MODULE_PATH, {
requires: {
'mongodb-legacy': { ObjectId },
'../User/UserGetter': this.UserGetter,
'../../models/Project': { Project },
'../Project/ProjectGetter': this.ProjectGetter,
'../Project/ProjectEditorHandler': this.ProjectEditorHandler,
},
})
})
afterEach(function () {
this.ProjectMock.verify()
})
describe('getMemberIdsWithPrivilegeLevels', function () {
describe('with project', function () {
it('should return an array of member ids with their privilege levels', async function () {
const result =
await this.CollaboratorsGetter.promises.getMemberIdsWithPrivilegeLevels(
this.project._id
)
expect(result).to.have.deep.members([
{
id: this.ownerRef.toString(),
privilegeLevel: 'owner',
source: 'owner',
},
{
id: this.readWriteRef1.toString(),
privilegeLevel: 'readAndWrite',
source: 'invite',
},
{
id: this.readWriteRef2.toString(),
privilegeLevel: 'readAndWrite',
source: 'invite',
},
{
id: this.readOnlyRef1.toString(),
privilegeLevel: 'readOnly',
source: 'invite',
},
{
id: this.readOnlyRef2.toString(),
privilegeLevel: 'readOnly',
source: 'invite',
},
{
id: this.pendingEditorRef.toString(),
privilegeLevel: 'readOnly',
source: 'invite',
pendingEditor: true,
},
{
id: this.pendingReviewerRef.toString(),
privilegeLevel: 'readOnly',
source: 'invite',
pendingReviewer: true,
},
{
id: this.readOnlyTokenRef.toString(),
privilegeLevel: 'readOnly',
source: 'token',
},
{
id: this.readWriteTokenRef.toString(),
privilegeLevel: 'readAndWrite',
source: 'token',
},
{
id: this.reviewer1Ref.toString(),
privilegeLevel: 'review',
source: 'invite',
},
{
id: this.reviewer2Ref.toString(),
privilegeLevel: 'review',
source: 'invite',
},
])
})
})
describe('with a missing project', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject.resolves(null)
})
it('should return a NotFoundError', async function () {
await expect(
this.CollaboratorsGetter.promises.getMemberIdsWithPrivilegeLevels(
this.project._id
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
})
describe('getMemberIds', function () {
it('should return the ids', async function () {
const memberIds = await this.CollaboratorsGetter.promises.getMemberIds(
this.project._id
)
expect(memberIds).to.have.members([
this.ownerRef.toString(),
this.readOnlyRef1.toString(),
this.readOnlyRef2.toString(),
this.readWriteRef1.toString(),
this.readWriteRef2.toString(),
this.pendingEditorRef.toString(),
this.pendingReviewerRef.toString(),
this.readWriteTokenRef.toString(),
this.readOnlyTokenRef.toString(),
this.reviewer1Ref.toString(),
this.reviewer2Ref.toString(),
])
})
})
describe('getInvitedMemberIds', function () {
it('should return the invited ids', async function () {
const memberIds =
await this.CollaboratorsGetter.promises.getInvitedMemberIds(
this.project._id
)
expect(memberIds).to.have.members([
this.ownerRef.toString(),
this.readOnlyRef1.toString(),
this.readOnlyRef2.toString(),
this.readWriteRef1.toString(),
this.readWriteRef2.toString(),
this.pendingEditorRef.toString(),
this.pendingReviewerRef.toString(),
this.reviewer1Ref.toString(),
this.reviewer2Ref.toString(),
])
})
})
describe('getInvitedMembersWithPrivilegeLevels', function () {
beforeEach(function () {
this.UserGetter.promises.getUser
.withArgs(this.readOnlyRef1.toString())
.resolves({ _id: this.readOnlyRef1 })
this.UserGetter.promises.getUser
.withArgs(this.readOnlyTokenRef.toString())
.resolves({ _id: this.readOnlyTokenRef })
this.UserGetter.promises.getUser
.withArgs(this.readWriteRef2.toString())
.resolves({ _id: this.readWriteRef2 })
this.UserGetter.promises.getUser
.withArgs(this.readWriteTokenRef.toString())
.resolves({ _id: this.readWriteTokenRef })
this.UserGetter.promises.getUser
.withArgs(this.reviewer1Ref.toString())
.resolves({ _id: this.reviewer1Ref })
})
it('should return an array of invited members with their privilege levels', async function () {
const result =
await this.CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels(
this.project._id
)
expect(result).to.have.deep.members([
{ user: { _id: this.readOnlyRef1 }, privilegeLevel: 'readOnly' },
{ user: { _id: this.readWriteRef2 }, privilegeLevel: 'readAndWrite' },
{ user: { _id: this.reviewer1Ref }, privilegeLevel: 'review' },
])
})
})
describe('getMemberIdPrivilegeLevel', function () {
it('should return the privilege level if it exists', async function () {
const level =
await this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
this.readOnlyRef1,
this.project._id
)
expect(level).to.equal('readOnly')
})
it('should return review privilege level', async function () {
const level =
await this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
this.reviewer1Ref,
this.project._id
)
expect(level).to.equal('review')
})
it('should return false if the member has no privilege level', async function () {
const level =
await this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
this.nonMemberRef,
this.project._id
)
expect(level).to.equal(false)
})
})
describe('isUserInvitedMemberOfProject', function () {
describe('when user is a member of the project', function () {
it('should return true and the privilegeLevel', async function () {
const isMember =
await this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
this.readOnlyRef1
)
expect(isMember).to.equal(true)
})
})
describe('when user is not a member of the project', function () {
it('should return false', async function () {
const isMember =
await this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
this.nonMemberRef
)
expect(isMember).to.equal(false)
})
})
})
describe('isUserInvitedReadWriteMemberOfProject', function () {
describe('when user is a read write member of the project', function () {
it('should return true', async function () {
const isMember =
await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
this.readWriteRef1
)
expect(isMember).to.equal(true)
})
})
describe('when user is a read only member of the project', function () {
it('should return false', async function () {
const isMember =
await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
this.readOnlyRef1
)
expect(isMember).to.equal(false)
})
})
describe('when user is not a member of the project', function () {
it('should return false', async function () {
const isMember =
await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
this.nonMemberRef
)
expect(isMember).to.equal(false)
})
})
})
describe('getProjectsUserIsMemberOf', function () {
beforeEach(function () {
this.fields = 'mock fields'
this.ProjectMock.expects('find')
.withArgs({ collaberator_refs: this.userId }, this.fields)
.chain('exec')
.resolves(['mock-read-write-project-1', 'mock-read-write-project-2'])
this.ProjectMock.expects('find')
.withArgs({ readOnly_refs: this.userId }, this.fields)
.chain('exec')
.resolves(['mock-read-only-project-1', 'mock-read-only-project-2'])
this.ProjectMock.expects('find')
.withArgs({ reviewer_refs: this.userId }, this.fields)
.chain('exec')
.resolves(['mock-review-project-1', 'mock-review-project-2'])
this.ProjectMock.expects('find')
.withArgs(
{
tokenAccessReadAndWrite_refs: this.userId,
publicAccesLevel: 'tokenBased',
},
this.fields
)
.chain('exec')
.resolves([
'mock-token-read-write-project-1',
'mock-token-read-write-project-2',
])
this.ProjectMock.expects('find')
.withArgs(
{
tokenAccessReadOnly_refs: this.userId,
publicAccesLevel: 'tokenBased',
},
this.fields
)
.chain('exec')
.resolves([
'mock-token-read-only-project-1',
'mock-token-read-only-project-2',
])
})
it('should call the callback with the projects', async function () {
const projects =
await this.CollaboratorsGetter.promises.getProjectsUserIsMemberOf(
this.userId,
this.fields
)
expect(projects).to.deep.equal({
readAndWrite: [
'mock-read-write-project-1',
'mock-read-write-project-2',
],
readOnly: ['mock-read-only-project-1', 'mock-read-only-project-2'],
tokenReadAndWrite: [
'mock-token-read-write-project-1',
'mock-token-read-write-project-2',
],
tokenReadOnly: [
'mock-token-read-only-project-1',
'mock-token-read-only-project-2',
],
review: ['mock-review-project-1', 'mock-review-project-2'],
})
})
})
describe('getAllInvitedMembers', function () {
beforeEach(async function () {
this.owningUser = {
_id: this.ownerRef,
email: 'owner@example.com',
features: { a: 1 },
}
this.readWriteUser = {
_id: this.readWriteRef1,
email: 'readwrite@example.com',
}
this.reviewUser = {
_id: this.reviewer1Ref,
email: 'review@example.com',
}
this.members = [
{ user: this.owningUser, privilegeLevel: 'owner' },
{ user: this.readWriteUser, privilegeLevel: 'readAndWrite' },
{ user: this.reviewUser, privilegeLevel: 'review' },
]
this.views = {
owner: this.owningUser,
ownerFeatures: this.owningUser.features,
members: [
{ _id: this.readWriteUser._id, email: this.readWriteUser.email },
{ _id: this.reviewUser._id, email: this.reviewUser.email },
],
}
this.UserGetter.promises.getUser
.withArgs(this.owningUser._id.toString())
.resolves(this.owningUser)
this.UserGetter.promises.getUser
.withArgs(this.readWriteUser._id.toString())
.resolves(this.readWriteUser)
this.UserGetter.promises.getUser
.withArgs(this.reviewUser._id.toString())
.resolves(this.reviewUser)
this.ProjectEditorHandler.buildOwnerAndMembersViews.returns(this.views)
this.result =
await this.CollaboratorsGetter.promises.getAllInvitedMembers(
this.project._id
)
})
it('should produce a list of members', function () {
expect(this.result).to.deep.equal(this.views.members)
})
it('should call ProjectEditorHandler.buildOwnerAndMembersViews', function () {
expect(this.ProjectEditorHandler.buildOwnerAndMembersViews).to.have.been
.calledOnce
expect(
this.ProjectEditorHandler.buildOwnerAndMembersViews
).to.have.been.calledWith(this.members)
})
})
describe('userIsTokenMember', function () {
it('should return true when the project is found', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(this.project)
const isMember =
await this.CollaboratorsGetter.promises.userIsTokenMember(
this.userId,
this.project._id
)
expect(isMember).to.be.true
})
it('should return false when the project is not found', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(null)
const isMember =
await this.CollaboratorsGetter.promises.userIsTokenMember(
this.userId,
this.project._id
)
expect(isMember).to.be.false
})
})
describe('userIsReadWriteTokenMember', function () {
it('should return true when the project is found', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(this.project)
const isMember =
await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
this.userId,
this.project._id
)
expect(isMember).to.be.true
})
it('should return false when the project is not found', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(null)
const isMember =
await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
this.userId,
this.project._id
)
expect(isMember).to.be.false
})
})
describe('getPublicShareTokens', function () {
const userMock = new ObjectId()
it('should return null when the project is not found', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(undefined)
const tokens =
await this.CollaboratorsGetter.promises.getPublicShareTokens(
userMock,
this.project._id
)
expect(tokens).to.be.null
})
it('should return an empty object when the user is not owner or read-only collaborator', async function () {
this.ProjectMock.expects('findOne').chain('exec').resolves(this.project)
const tokens =
await this.CollaboratorsGetter.promises.getPublicShareTokens(
userMock,
this.project._id
)
expect(tokens).to.deep.equal({})
})
describe('when the user is a read-only token collaborator', function () {
it('should return the read-only token', async function () {
this.ProjectMock.expects('findOne')
.chain('exec')
.resolves({ hasTokenReadOnlyAccess: true, ...this.project })
const tokens =
await this.CollaboratorsGetter.promises.getPublicShareTokens(
userMock,
this.project._id
)
expect(tokens).to.deep.equal({ readOnly: tokens.readOnly })
})
})
describe('when the user is the owner of the project', function () {
beforeEach(function () {
this.ProjectMock.expects('findOne')
.chain('exec')
.resolves({ isOwner: true, ...this.project })
})
it('should return all the tokens', async function () {
const tokens =
await this.CollaboratorsGetter.promises.getPublicShareTokens(
userMock,
this.project._id
)
expect(tokens).to.deep.equal(tokens)
})
})
})
describe('getInvitedEditCollaboratorCount', function () {
it('should return the count of invited edit collaborators (readAndWrite, review)', async function () {
const count =
await this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount(
this.project._id
)
expect(count).to.equal(4)
})
})
describe('getInvitedPendingEditorCount', function () {
it('should return the count of pending editors and reviewers', async function () {
const count =
await this.CollaboratorsGetter.promises.getInvitedPendingEditorCount(
this.project._id
)
expect(count).to.equal(2)
})
})
})

View File

@@ -0,0 +1,914 @@
const { promisify } = require('util')
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const { Project } = require('../helpers/models/Project')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Collaborators/CollaboratorsHandler'
)
const sleep = promisify(setTimeout)
describe('CollaboratorsHandler', function () {
beforeEach(function () {
this.userId = new ObjectId()
this.addingUserId = new ObjectId()
this.project = {
_id: new ObjectId(),
owner_ref: this.addingUserId,
name: 'Foo',
}
this.archivedProject = {
_id: new ObjectId(),
archived: [new ObjectId(this.userId)],
}
this.oldArchivedProject = {
_id: new ObjectId(),
archived: true,
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
},
}
this.ContactManager = {
addContact: sinon.stub(),
}
this.ProjectMock = sinon.mock(Project)
this.TpdsProjectFlusher = {
promises: {
flushProjectToTpds: sinon.stub().resolves(),
},
}
this.TpdsUpdateSender = {
promises: {
createProject: sinon.stub().resolves(),
},
}
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.ProjectHelper = {
calculateArchivedArray: sinon.stub(),
}
this.CollaboratorsGetter = {
promises: {
dangerouslyGetAllProjectsUserIsMemberOf: sinon.stub(),
getMemberIdsWithPrivilegeLevels: sinon.stub().resolves([]),
},
}
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
this.CollaboratorsHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../User/UserGetter': this.UserGetter,
'../Contacts/ContactManager': this.ContactManager,
'../../models/Project': { Project },
'../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
'../Project/ProjectGetter': this.ProjectGetter,
'../Project/ProjectHelper': this.ProjectHelper,
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
'./CollaboratorsGetter': this.CollaboratorsGetter,
},
})
// Helper function to set up mock expectations for null reference cleanup
this.expectNullReferenceCleanup = projectId => {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: projectId,
pendingReviewer_refs: { $type: 'null' },
},
{
$set: { pendingReviewer_refs: [] },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: projectId,
readOnly_refs: { $type: 'null' },
},
{
$set: { readOnly_refs: [] },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: projectId,
reviewer_refs: { $type: 'null' },
},
{
$set: { reviewer_refs: [] },
}
)
.chain('exec')
.resolves()
}
})
afterEach(function () {
this.ProjectMock.verify()
})
describe('removeUserFromProject', function () {
describe('a non-archived project', function () {
beforeEach(function () {
this.ProjectMock.expects('findOne')
.withArgs({
_id: this.project._id,
})
.chain('exec')
.resolves(this.project)
})
it('should remove the user from mongo', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId,
trashed: this.userId,
},
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.removeUserFromProject(
this.project._id,
this.userId
)
})
})
describe('an archived project, archived with a boolean value', function () {
beforeEach(function () {
const archived = [new ObjectId(this.userId)]
this.ProjectHelper.calculateArchivedArray.returns(archived)
this.ProjectMock.expects('findOne')
.withArgs({
_id: this.oldArchivedProject._id,
})
.chain('exec')
.resolves(this.oldArchivedProject)
})
it('should remove the user from mongo', async function () {
this.expectNullReferenceCleanup(this.oldArchivedProject._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.oldArchivedProject._id,
},
{
$set: {
archived: [],
},
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId,
trashed: this.userId,
},
}
)
.resolves()
await this.CollaboratorsHandler.promises.removeUserFromProject(
this.oldArchivedProject._id,
this.userId
)
})
})
describe('an archived project, archived with an array value', function () {
beforeEach(function () {
this.ProjectMock.expects('findOne')
.withArgs({
_id: this.archivedProject._id,
})
.chain('exec')
.resolves(this.archivedProject)
})
it('should remove the user from mongo', async function () {
this.expectNullReferenceCleanup(this.archivedProject._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.archivedProject._id,
},
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId,
trashed: this.userId,
},
}
)
.resolves()
await this.CollaboratorsHandler.promises.removeUserFromProject(
this.archivedProject._id,
this.userId
)
})
})
})
describe('addUserIdToProject', function () {
describe('as readOnly', function () {
beforeEach(async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$addToSet: { readOnly_refs: this.userId },
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'readOnly'
)
})
it('should create the project folder in dropbox', function () {
expect(
this.TpdsUpdateSender.promises.createProject
).to.have.been.calledWith({
projectId: this.project._id,
projectName: this.project.name,
ownerId: this.addingUserId,
userId: this.userId,
})
})
it('should flush the project to the TPDS', function () {
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(this.project._id)
})
it('should add the user as a contact for the adding user', function () {
expect(this.ContactManager.addContact).to.have.been.calledWith(
this.addingUserId,
this.userId
)
})
describe('and with pendingEditor flag', function () {
it('should add them to the pending editor refs', async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$addToSet: {
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
},
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'readOnly',
{ pendingEditor: true }
)
})
})
describe('with pendingReviewer flag', function () {
it('should add them to the pending reviewer refs', async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$addToSet: {
readOnly_refs: this.userId,
pendingReviewer_refs: this.userId,
},
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'readOnly',
{ pendingReviewer: true }
)
})
})
})
describe('as readAndWrite', function () {
beforeEach(async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$addToSet: { collaberator_refs: this.userId },
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'readAndWrite'
)
})
it('should flush the project to the TPDS', function () {
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(this.project._id)
})
})
describe('as reviewer', function () {
beforeEach(async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
track_changes: { [this.userId]: true },
$addToSet: { reviewer_refs: this.userId },
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'review'
)
})
it('should update the client with new track changes settings', function () {
return this.EditorRealTimeController.emitToRoom
.calledWith(this.project._id, 'toggle-track-changes', {
[this.userId]: true,
})
.should.equal(true)
})
it('should flush the project to the TPDS', function () {
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(this.project._id)
})
})
describe('with invalid privilegeLevel', function () {
it('should call the callback with an error', async function () {
await expect(
this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'notValid'
)
).to.be.rejected
})
})
describe('when user already exists as a collaborator', function () {
beforeEach(function () {
this.project.collaberator_refs = [this.userId]
})
it('should not add the user again', async function () {
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'readAndWrite'
)
// Project.updateOne() should not be called. If it is, it will fail because
// the mock is not set up.
})
})
describe('with null addingUserId', function () {
beforeEach(async function () {
this.project.collaberator_refs = []
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$addToSet: { collaberator_refs: this.userId },
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
null,
this.userId,
'readAndWrite'
)
})
it('should not add the adding user as a contact', function () {
expect(this.ContactManager.addContact).not.to.have.been.called
})
})
})
describe('removeUserFromAllProjects', function () {
it('should remove the user from each project', async function () {
this.CollaboratorsGetter.promises.dangerouslyGetAllProjectsUserIsMemberOf
.withArgs(this.userId, { _id: 1 })
.resolves({
readAndWrite: [
{ _id: 'read-and-write-0' },
{ _id: 'read-and-write-1' },
],
readOnly: [{ _id: 'read-only-0' }, { _id: 'read-only-1' }],
tokenReadAndWrite: [
{ _id: 'token-read-and-write-0' },
{ _id: 'token-read-and-write-1' },
],
tokenReadOnly: [
{ _id: 'token-read-only-0' },
{ _id: 'token-read-only-1' },
],
})
const expectedProjects = [
'read-and-write-0',
'read-and-write-1',
'read-only-0',
'read-only-1',
'token-read-and-write-0',
'token-read-and-write-1',
'token-read-only-0',
'token-read-only-1',
]
for (const projectId of expectedProjects) {
this.ProjectMock.expects('findOne')
.withArgs({
_id: projectId,
})
.chain('exec')
.resolves({ _id: projectId })
this.expectNullReferenceCleanup(projectId)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: projectId,
},
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId,
trashed: this.userId,
},
}
)
.resolves()
}
await this.CollaboratorsHandler.promises.removeUserFromAllProjects(
this.userId
)
})
})
describe('transferProjects', function () {
beforeEach(function () {
this.fromUserId = new ObjectId()
this.toUserId = new ObjectId()
this.projects = [
{
_id: new ObjectId(),
},
{
_id: new ObjectId(),
},
]
this.ProjectMock.expects('find')
.withArgs({
$or: [
{ owner_ref: this.fromUserId },
{ collaberator_refs: this.fromUserId },
{ readOnly_refs: this.fromUserId },
],
})
.chain('exec')
.resolves(this.projects)
this.ProjectMock.expects('updateMany')
.withArgs(
{ owner_ref: this.fromUserId },
{ $set: { owner_ref: this.toUserId } }
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ collaberator_refs: this.fromUserId },
{
$addToSet: { collaberator_refs: this.toUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ collaberator_refs: this.fromUserId },
{
$pull: { collaberator_refs: this.fromUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ readOnly_refs: this.fromUserId },
{
$addToSet: { readOnly_refs: this.toUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ readOnly_refs: this.fromUserId },
{
$pull: { readOnly_refs: this.fromUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingEditor_refs: this.fromUserId },
{
$addToSet: { pendingEditor_refs: this.toUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingEditor_refs: this.fromUserId },
{
$pull: { pendingEditor_refs: this.fromUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingReviewer_refs: this.fromUserId },
{
$addToSet: { pendingReviewer_refs: this.toUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingReviewer_refs: this.fromUserId },
{
$pull: { pendingReviewer_refs: this.fromUserId },
}
)
.chain('exec')
.resolves()
})
describe('successfully', function () {
it('should flush each project to the TPDS', async function () {
await this.CollaboratorsHandler.promises.transferProjects(
this.fromUserId,
this.toUserId
)
await sleep(10) // let the background tasks run
for (const project of this.projects) {
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(project._id)
}
})
})
describe('when flushing to TPDS fails', function () {
it('should log an error but not fail', async function () {
this.TpdsProjectFlusher.promises.flushProjectToTpds.rejects(
new Error('oops')
)
await this.CollaboratorsHandler.promises.transferProjects(
this.fromUserId,
this.toUserId
)
await sleep(10) // let the background tasks run
expect(this.logger.err).to.have.been.called
})
})
})
describe('setCollaboratorPrivilegeLevel', function () {
it('sets a collaborator to read-only', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$pull: {
collaberator_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
reviewer_refs: this.userId,
},
$addToSet: { readOnly_refs: this.userId },
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'readOnly'
)
})
it('sets a collaborator to read-write', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$addToSet: { collaberator_refs: this.userId },
$pull: {
readOnly_refs: this.userId,
reviewer_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'readAndWrite'
)
})
describe('sets a collaborator to reviewer when track changes is enabled for everyone', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
_id: new ObjectId(),
owner_ref: this.addingUserId,
name: 'Foo',
track_changes: true,
})
})
it('should correctly update the project', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$addToSet: { reviewer_refs: this.userId },
$set: { track_changes: { [this.userId]: true } },
$pull: {
readOnly_refs: this.userId,
collaberator_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'review'
)
})
})
describe('sets a collaborator to reviewer when track changes is not enabled for everyone', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
_id: new ObjectId(),
owner_ref: this.addingUserId,
name: 'Foo',
track_changes: {
[this.userId]: true,
},
})
})
it('should correctly update the project', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$addToSet: { reviewer_refs: this.userId },
$set: { [`track_changes.${this.userId}`]: true },
$pull: {
readOnly_refs: this.userId,
collaberator_refs: this.userId,
pendingEditor_refs: this.userId,
pendingReviewer_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'review'
)
})
})
it('sets a collaborator to read-only as a pendingEditor', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$addToSet: {
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
},
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
pendingReviewer_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'readOnly',
{ pendingEditor: true }
)
})
it('sets a collaborator to read-only as a pendingReviewer', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
{ reviewer_refs: this.userId },
],
},
{
$addToSet: {
readOnly_refs: this.userId,
pendingReviewer_refs: this.userId,
},
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
pendingEditor_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'readOnly',
{ pendingReviewer: true }
)
})
it('throws a NotFoundError if the project or collaborator does not exist', async function () {
this.expectNullReferenceCleanup(this.project._id)
this.ProjectMock.expects('updateOne')
.chain('exec')
.resolves({ matchedCount: 0 })
await expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.project._id,
this.userId,
'readAndWrite'
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
const sinon = require('sinon')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const Crypto = require('crypto')
const MODULE_PATH =
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js'
describe('CollaboratorsInviteGetter', function () {
beforeEach(function () {
this.ProjectInvite = class ProjectInvite {
constructor(options) {
if (options == null) {
options = {}
}
this._id = new ObjectId()
for (const k in options) {
const v = options[k]
this[k] = v
}
}
}
this.ProjectInvite.prototype.save = sinon.stub()
this.ProjectInvite.findOne = sinon.stub()
this.ProjectInvite.find = sinon.stub()
this.ProjectInvite.deleteOne = sinon.stub()
this.ProjectInvite.findOneAndDelete = sinon.stub()
this.ProjectInvite.countDocuments = sinon.stub()
this.Crypto = {
randomBytes: sinon.stub().callsFake(Crypto.randomBytes),
}
this.CollaboratorsInviteHelper = {
generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)),
hashInviteToken: sinon.stub().returns(this.tokenHmac),
}
this.CollaboratorsInviteGetter = SandboxedModule.require(MODULE_PATH, {
requires: {
'../../models/ProjectInvite': { ProjectInvite: this.ProjectInvite },
'./CollaboratorsInviteHelper': this.CollaboratorsInviteHelper,
},
})
this.projectId = new ObjectId()
this.sendingUserId = new ObjectId()
this.email = 'user@example.com'
this.userId = new ObjectId()
this.inviteId = new ObjectId()
this.token = 'hnhteaosuhtaeosuahs'
this.privileges = 'readAndWrite'
this.fakeInvite = {
_id: this.inviteId,
email: this.email,
token: this.token,
tokenHmac: this.tokenHmac,
sendingUserId: this.sendingUserId,
projectId: this.projectId,
privileges: this.privileges,
createdAt: new Date(),
}
})
describe('getEditInviteCount', function () {
beforeEach(function () {
this.ProjectInvite.countDocuments.returns({
exec: sinon.stub().resolves(2),
})
this.call = async () => {
return await this.CollaboratorsInviteGetter.promises.getEditInviteCount(
this.projectId
)
}
})
it('should produce the count of documents', async function () {
const count = await this.call()
expect(this.ProjectInvite.countDocuments).to.be.calledWith({
projectId: this.projectId,
privileges: { $ne: 'readOnly' },
})
expect(count).to.equal(2)
})
describe('when model.countDocuments produces an error', function () {
beforeEach(function () {
this.ProjectInvite.countDocuments.returns({
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('getAllInvites', function () {
beforeEach(function () {
this.fakeInvites = [
{ _id: new ObjectId(), one: 1 },
{ _id: new ObjectId(), two: 2 },
]
this.ProjectInvite.find.returns({
select: sinon.stub().returnsThis(),
exec: sinon.stub().resolves(this.fakeInvites),
})
this.call = async () => {
return await this.CollaboratorsInviteGetter.promises.getAllInvites(
this.projectId
)
}
})
describe('when all goes well', function () {
beforeEach(function () {})
it('should produce a list of invite objects', async function () {
const invites = await this.call()
expect(invites).to.not.be.oneOf([null, undefined])
expect(invites).to.deep.equal(this.fakeInvites)
})
it('should have called ProjectInvite.find', async function () {
await this.call()
this.ProjectInvite.find.callCount.should.equal(1)
this.ProjectInvite.find
.calledWith({ projectId: this.projectId })
.should.equal(true)
})
})
describe('when ProjectInvite.find produces an error', function () {
beforeEach(function () {
this.ProjectInvite.find.returns({
select: sinon.stub().returnsThis(),
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('getInviteByToken', function () {
beforeEach(function () {
this.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(this.fakeInvite),
})
this.call = async () => {
return await this.CollaboratorsInviteGetter.promises.getInviteByToken(
this.projectId,
this.token
)
}
})
describe('when all goes well', function () {
it('should produce the invite object', async function () {
const invite = await this.call()
expect(invite).to.deep.equal(this.fakeInvite)
})
it('should call ProjectInvite.findOne', async function () {
await this.call()
this.ProjectInvite.findOne.callCount.should.equal(1)
this.ProjectInvite.findOne
.calledWith({ projectId: this.projectId, tokenHmac: this.tokenHmac })
.should.equal(true)
})
})
describe('when findOne produces an error', function () {
beforeEach(function () {
this.ProjectInvite.findOne.returns({
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
describe('when findOne does not find an invite', function () {
beforeEach(function () {
this.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(null),
})
})
it('should not produce an invite object', async function () {
const invite = await this.call()
expect(invite).to.be.oneOf([null, undefined])
})
})
})
})

View File

@@ -0,0 +1,833 @@
import sinon from 'sinon'
import { expect } from 'chai'
import esmock from 'esmock'
import mongodb from 'mongodb-legacy'
import Crypto from 'crypto'
const ObjectId = mongodb.ObjectId
const MODULE_PATH =
'../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs'
describe('CollaboratorsInviteHandler', function () {
beforeEach(async function () {
this.ProjectInvite = class ProjectInvite {
constructor(options) {
if (options == null) {
options = {}
}
this._id = new ObjectId()
for (const k in options) {
const v = options[k]
this[k] = v
}
}
}
this.ProjectInvite.prototype.save = sinon.stub()
this.ProjectInvite.findOne = sinon.stub()
this.ProjectInvite.find = sinon.stub()
this.ProjectInvite.deleteOne = sinon.stub()
this.ProjectInvite.findOneAndDelete = sinon.stub()
this.ProjectInvite.countDocuments = sinon.stub()
this.Crypto = {
randomBytes: sinon.stub().callsFake(Crypto.randomBytes),
}
this.settings = {}
this.CollaboratorsEmailHandler = { promises: {} }
this.CollaboratorsHandler = {
promises: {
addUserIdToProject: sinon.stub(),
},
}
this.UserGetter = { promises: { getUser: sinon.stub() } }
this.ProjectGetter = { promises: { getProject: sinon.stub().resolves() } }
this.NotificationsBuilder = { promises: {} }
this.tokenHmac = 'jkhajkefhaekjfhkfg'
this.CollaboratorsInviteHelper = {
generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)),
hashInviteToken: sinon.stub().returns(this.tokenHmac),
}
this.CollaboratorsInviteGetter = {
promises: {
getAllInvites: sinon.stub(),
},
}
this.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves(),
},
}
this.LimitationsManager = {
promises: {
canAcceptEditCollaboratorInvite: sinon.stub().resolves(),
},
}
this.ProjectAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
addEntryInBackground: sinon.stub(),
}
this.logger = {
debug: sinon.stub(),
warn: sinon.stub(),
err: sinon.stub(),
}
this.CollaboratorsInviteHandler = await esmock.strict(MODULE_PATH, {
'@overleaf/settings': this.settings,
'../../../../app/src/models/ProjectInvite.js': {
ProjectInvite: this.ProjectInvite,
},
'@overleaf/logger': this.logger,
'../../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs':
this.CollaboratorsEmailHandler,
'../../../../app/src/Features/Collaborators/CollaboratorsHandler.js':
this.CollaboratorsHandler,
'../../../../app/src/Features/User/UserGetter.js': this.UserGetter,
'../../../../app/src/Features/Project/ProjectGetter.js':
this.ProjectGetter,
'../../../../app/src/Features/Notifications/NotificationsBuilder.js':
this.NotificationsBuilder,
'../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.js':
this.CollaboratorsInviteHelper,
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter':
this.CollaboratorsInviteGetter,
'../../../../app/src/Features/SplitTests/SplitTestHandler.js':
this.SplitTestHandler,
'../../../../app/src/Features/Subscription/LimitationsManager.js':
this.LimitationsManager,
'../../../../app/src/Features/Project/ProjectAuditLogHandler.js':
this.ProjectAuditLogHandler,
crypto: this.CryptogetAssignmentForUser,
})
this.projectId = new ObjectId()
this.sendingUserId = new ObjectId()
this.sendingUser = {
_id: this.sendingUserId,
name: 'Bob',
}
this.email = 'user@example.com'
this.userId = new ObjectId()
this.user = {
_id: this.userId,
email: 'someone@example.com',
}
this.inviteId = new ObjectId()
this.token = 'hnhteaosuhtaeosuahs'
this.privileges = 'readAndWrite'
this.fakeInvite = {
_id: this.inviteId,
email: this.email,
token: this.token,
tokenHmac: this.tokenHmac,
sendingUserId: this.sendingUserId,
projectId: this.projectId,
privileges: this.privileges,
createdAt: new Date(),
}
})
describe('inviteToProject', function () {
beforeEach(function () {
this.ProjectInvite.prototype.save.callsFake(async function () {
Object.defineProperty(this, 'toObject', {
value: function () {
return this
},
writable: true,
configurable: true,
enumerable: false,
})
return this
})
this.CollaboratorsInviteHandler.promises._sendMessages = sinon
.stub()
.resolves()
this.call = async () => {
return await this.CollaboratorsInviteHandler.promises.inviteToProject(
this.projectId,
this.sendingUser,
this.email,
this.privileges
)
}
})
describe('when all goes well', function () {
it('should produce the invite object', async function () {
const invite = await this.call()
expect(invite).to.not.equal(null)
expect(invite).to.not.equal(undefined)
expect(invite).to.be.instanceof(Object)
expect(invite).to.have.all.keys(['_id', 'email', 'privileges'])
})
it('should have generated a random token', async function () {
await this.call()
this.Crypto.randomBytes.callCount.should.equal(1)
})
it('should have generated a HMAC token', async function () {
await this.call()
this.CollaboratorsInviteHelper.hashInviteToken.callCount.should.equal(1)
})
it('should have called ProjectInvite.save', async function () {
await this.call()
this.ProjectInvite.prototype.save.callCount.should.equal(1)
})
it('should have called _sendMessages', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises._sendMessages.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises._sendMessages
.calledWith(this.projectId, this.sendingUser)
.should.equal(true)
})
})
describe('when saving model produces an error', function () {
beforeEach(function () {
this.ProjectInvite.prototype.save.rejects(new Error('woops'))
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('_sendMessages', function () {
beforeEach(function () {
this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite = sinon
.stub()
.resolves()
this.CollaboratorsInviteHandler.promises._trySendInviteNotification =
sinon.stub().resolves()
this.call = async () => {
await this.CollaboratorsInviteHandler.promises._sendMessages(
this.projectId,
this.sendingUser,
this.fakeInvite
)
}
})
describe('when all goes well', function () {
it('should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', async function () {
await this.call()
this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite.callCount.should.equal(
1
)
this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite
.calledWith(this.projectId, this.fakeInvite.email, this.fakeInvite)
.should.equal(true)
})
it('should call _trySendInviteNotification', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises._trySendInviteNotification.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises._trySendInviteNotification
.calledWith(this.projectId, this.sendingUser, this.fakeInvite)
.should.equal(true)
})
})
describe('when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', function () {
beforeEach(function () {
this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite =
sinon.stub().rejects(new Error('woops'))
})
it('should not produce an error', async function () {
await expect(this.call()).to.be.fulfilled
expect(this.logger.err).to.be.calledOnce
})
})
describe('when _trySendInviteNotification produces an error', function () {
beforeEach(function () {
this.CollaboratorsInviteHandler.promises._trySendInviteNotification =
sinon.stub().rejects(new Error('woops'))
})
it('should not produce an error', async function () {
await expect(this.call()).to.be.fulfilled
expect(this.logger.err).to.be.calledOnce
})
})
})
describe('revokeInviteForUser', function () {
beforeEach(function () {
this.targetInvite = {
_id: new ObjectId(),
email: 'fake2@example.org',
two: 2,
}
this.fakeInvites = [
{ _id: new ObjectId(), email: 'fake1@example.org', one: 1 },
this.targetInvite,
]
this.fakeInvitesWithoutUser = [
{ _id: new ObjectId(), email: 'fake1@example.org', one: 1 },
{ _id: new ObjectId(), email: 'fake3@example.org', two: 2 },
]
this.targetEmail = [{ email: 'fake2@example.org' }]
this.CollaboratorsInviteGetter.promises.getAllInvites.resolves(
this.fakeInvites
)
this.CollaboratorsInviteHandler.promises.revokeInvite = sinon
.stub()
.resolves(this.targetInvite)
this.call = async () => {
return await this.CollaboratorsInviteHandler.promises.revokeInviteForUser(
this.projectId,
this.targetEmail
)
}
})
describe('for a valid user', function () {
it('should have called CollaboratorsInviteGetter.getAllInvites', async function () {
await this.call()
this.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal(
1
)
this.CollaboratorsInviteGetter.promises.getAllInvites
.calledWith(this.projectId)
.should.equal(true)
})
it('should have called revokeInvite', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises.revokeInvite
.calledWith(this.projectId, this.targetInvite._id)
.should.equal(true)
})
})
describe('for a user without an invite in the project', function () {
beforeEach(function () {
this.CollaboratorsInviteGetter.promises.getAllInvites.resolves(
this.fakeInvitesWithoutUser
)
})
it('should not have called CollaboratorsInviteHandler.revokeInvite', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal(
0
)
})
})
})
describe('revokeInvite', function () {
beforeEach(function () {
this.ProjectInvite.findOneAndDelete.returns({
exec: sinon.stub().resolves(this.fakeInvite),
})
this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification =
sinon.stub().resolves()
this.call = async () => {
return await this.CollaboratorsInviteHandler.promises.revokeInvite(
this.projectId,
this.inviteId
)
}
})
describe('when all goes well', function () {
it('should call ProjectInvite.findOneAndDelete', async function () {
await this.call()
this.ProjectInvite.findOneAndDelete.should.have.been.calledOnce
this.ProjectInvite.findOneAndDelete.should.have.been.calledWith({
projectId: this.projectId,
_id: this.inviteId,
})
})
it('should call _tryCancelInviteNotification', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification
.calledWith(this.inviteId)
.should.equal(true)
})
it('should return the deleted invite', async function () {
const invite = await this.call()
expect(invite).to.deep.equal(this.fakeInvite)
})
})
describe('when remove produces an error', function () {
beforeEach(function () {
this.ProjectInvite.findOneAndDelete.returns({
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('generateNewInvite', function () {
beforeEach(function () {
this.fakeInviteToProjectObject = {
_id: new ObjectId(),
email: this.email,
privileges: this.privileges,
}
this.CollaboratorsInviteHandler.promises.revokeInvite = sinon
.stub()
.resolves(this.fakeInvite)
this.CollaboratorsInviteHandler.promises.inviteToProject = sinon
.stub()
.resolves(this.fakeInviteToProjectObject)
this.call = async () => {
return await this.CollaboratorsInviteHandler.promises.generateNewInvite(
this.projectId,
this.sendingUser,
this.inviteId
)
}
})
describe('when all goes well', function () {
it('should call revokeInvite', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises.revokeInvite
.calledWith(this.projectId, this.inviteId)
.should.equal(true)
})
it('should have called inviteToProject', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal(
1
)
this.CollaboratorsInviteHandler.promises.inviteToProject
.calledWith(
this.projectId,
this.sendingUser,
this.fakeInvite.email,
this.fakeInvite.privileges
)
.should.equal(true)
})
it('should return the invite', async function () {
const invite = await this.call()
expect(invite).to.deep.equal(this.fakeInviteToProjectObject)
})
})
describe('when revokeInvite produces an error', function () {
beforeEach(function () {
this.CollaboratorsInviteHandler.promises.revokeInvite = sinon
.stub()
.rejects(new Error('woops'))
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
it('should not have called inviteToProject', async function () {
await expect(this.call()).to.be.rejected
this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal(
0
)
})
})
describe('when findOne does not find an invite', function () {
beforeEach(function () {
this.CollaboratorsInviteHandler.promises.revokeInvite = sinon
.stub()
.resolves(null)
})
it('should not have called inviteToProject', async function () {
await this.call()
this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal(
0
)
})
})
})
describe('acceptInvite', function () {
beforeEach(function () {
this.fakeProject = {
_id: this.projectId,
owner_ref: this.sendingUserId,
}
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves(this.fakeProject)
this.CollaboratorsHandler.promises.addUserIdToProject.resolves()
this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification =
sinon.stub().resolves()
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
true
)
this.ProjectInvite.deleteOne.returns({ exec: sinon.stub().resolves() })
this.call = async () => {
await this.CollaboratorsInviteHandler.promises.acceptInvite(
this.fakeInvite,
this.projectId,
this.user
)
}
})
describe('when all goes well', function () {
it('should add readAndWrite invitees to the project as normal', async function () {
await this.call()
this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith(
this.projectId,
this.sendingUserId,
this.userId,
this.fakeInvite.privileges
)
})
it('should have called ProjectInvite.deleteOne', async function () {
await this.call()
this.ProjectInvite.deleteOne.callCount.should.equal(1)
this.ProjectInvite.deleteOne
.calledWith({ _id: this.inviteId })
.should.equal(true)
})
})
describe('when the invite is for readOnly access', function () {
beforeEach(function () {
this.fakeInvite.privileges = 'readOnly'
})
it('should have called CollaboratorsHandler.addUserIdToProject', async function () {
await this.call()
this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal(
1
)
this.CollaboratorsHandler.promises.addUserIdToProject
.calledWith(
this.projectId,
this.sendingUserId,
this.userId,
this.fakeInvite.privileges
)
.should.equal(true)
})
})
describe('when the project has no more edit collaborator slots', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
false
)
})
it('should add readAndWrite invitees to the project as readOnly (pendingEditor) users', async function () {
await this.call()
this.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith(
this.projectId,
'editor-moved-to-pending',
null,
null,
{ userId: this.userId.toString(), role: 'editor' }
)
this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith(
this.projectId,
this.sendingUserId,
this.userId,
'readOnly',
{ pendingEditor: true }
)
})
})
describe('when addUserIdToProject produces an error', function () {
beforeEach(function () {
this.CollaboratorsHandler.promises.addUserIdToProject.callsArgWith(
4,
new Error('woops')
)
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
it('should have called CollaboratorsHandler.addUserIdToProject', async function () {
await expect(this.call()).to.be.rejected
this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal(
1
)
this.CollaboratorsHandler.promises.addUserIdToProject
.calledWith(
this.projectId,
this.sendingUserId,
this.userId,
this.fakeInvite.privileges
)
.should.equal(true)
})
it('should not have called ProjectInvite.deleteOne', async function () {
await expect(this.call()).to.be.rejected
this.ProjectInvite.deleteOne.callCount.should.equal(0)
})
})
describe('when ProjectInvite.deleteOne produces an error', function () {
beforeEach(function () {
this.ProjectInvite.deleteOne.returns({
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
it('should have called CollaboratorsHandler.addUserIdToProject', async function () {
await expect(this.call()).to.be.rejected
this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal(
1
)
this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith(
this.projectId,
this.sendingUserId,
this.userId,
this.fakeInvite.privileges
)
})
it('should have called ProjectInvite.deleteOne', async function () {
await expect(this.call()).to.be.rejected
this.ProjectInvite.deleteOne.callCount.should.equal(1)
})
})
})
describe('_tryCancelInviteNotification', function () {
beforeEach(function () {
this.inviteId = new ObjectId()
this.currentUser = { _id: new ObjectId() }
this.notification = { read: sinon.stub().resolves() }
this.NotificationsBuilder.promises.projectInvite = sinon
.stub()
.returns(this.notification)
this.call = async () => {
await this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification(
this.inviteId
)
}
})
it('should call notification.read', async function () {
await this.call()
this.notification.read.callCount.should.equal(1)
})
describe('when notification.read produces an error', function () {
beforeEach(function () {
this.notification = {
read: sinon.stub().rejects(new Error('woops')),
}
this.NotificationsBuilder.promises.projectInvite = sinon
.stub()
.returns(this.notification)
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejected
})
})
})
describe('_trySendInviteNotification', function () {
beforeEach(function () {
this.invite = {
_id: new ObjectId(),
token: 'some_token',
sendingUserId: new ObjectId(),
projectId: this.project_id,
targetEmail: 'user@example.com',
createdAt: new Date(),
}
this.sendingUser = {
_id: new ObjectId(),
first_name: 'jim',
}
this.existingUser = { _id: new ObjectId() }
this.UserGetter.promises.getUserByAnyEmail = sinon
.stub()
.resolves(this.existingUser)
this.fakeProject = {
_id: this.project_id,
name: 'some project',
}
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves(this.fakeProject)
this.notification = { create: sinon.stub().resolves() }
this.NotificationsBuilder.promises.projectInvite = sinon
.stub()
.returns(this.notification)
this.call = async () => {
await this.CollaboratorsInviteHandler.promises._trySendInviteNotification(
this.project_id,
this.sendingUser,
this.invite
)
}
})
describe('when the user exists', function () {
beforeEach(function () {})
it('should call getUser', async function () {
await this.call()
this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1)
this.UserGetter.promises.getUserByAnyEmail
.calledWith(this.invite.email)
.should.equal(true)
})
it('should call getProject', async function () {
await this.call()
this.ProjectGetter.promises.getProject.callCount.should.equal(1)
this.ProjectGetter.promises.getProject
.calledWith(this.project_id)
.should.equal(true)
})
it('should call NotificationsBuilder.projectInvite.create', async function () {
await this.call()
this.NotificationsBuilder.promises.projectInvite.callCount.should.equal(
1
)
this.notification.create.callCount.should.equal(1)
})
describe('when getProject produces an error', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject.callsArgWith(
2,
new Error('woops')
)
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
it('should not call NotificationsBuilder.projectInvite.create', async function () {
await expect(this.call()).to.be.rejected
this.NotificationsBuilder.promises.projectInvite.callCount.should.equal(
0
)
this.notification.create.callCount.should.equal(0)
})
})
describe('when projectInvite.create produces an error', function () {
beforeEach(function () {
this.notification.create.callsArgWith(0, new Error('woops'))
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('when the user does not exist', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail = sinon.stub().resolves(null)
})
it('should call getUser', async function () {
await this.call()
this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1)
this.UserGetter.promises.getUserByAnyEmail
.calledWith(this.invite.email)
.should.equal(true)
})
it('should not call getProject', async function () {
await this.call()
this.ProjectGetter.promises.getProject.callCount.should.equal(0)
})
it('should not call NotificationsBuilder.projectInvite.create', async function () {
await this.call()
this.NotificationsBuilder.promises.projectInvite.callCount.should.equal(
0
)
this.notification.create.callCount.should.equal(0)
})
})
describe('when the getUser produces an error', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail = sinon
.stub()
.rejects(new Error('woops'))
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
it('should call getUser', async function () {
await expect(this.call()).to.be.rejected
this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1)
this.UserGetter.promises.getUserByAnyEmail
.calledWith(this.invite.email)
.should.equal(true)
})
it('should not call getProject', async function () {
await expect(this.call()).to.be.rejected
this.ProjectGetter.promises.getProject.callCount.should.equal(0)
})
it('should not call NotificationsBuilder.projectInvite.create', async function () {
await expect(this.call()).to.be.rejected
this.NotificationsBuilder.promises.projectInvite.callCount.should.equal(
0
)
this.notification.create.callCount.should.equal(0)
})
})
})
})

View File

@@ -0,0 +1,21 @@
const sinon = require('sinon')
const { expect } = require('chai')
const path = require('path')
const CollaboratorsInviteHelper = require(
path.join(
__dirname,
'/../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper'
)
)
const Crypto = require('crypto')
describe('CollaboratorsInviteHelper', function () {
it('should generate a HMAC token', function () {
const CryptoCreateHmac = sinon.spy(Crypto, 'createHmac')
const tokenHmac = CollaboratorsInviteHelper.hashInviteToken('abc')
CryptoCreateHmac.callCount.should.equal(1)
expect(tokenHmac).to.eq(
'3f76e274d83ffba85149f6850c095ce8481454d7446ca4e25beee01e08beb383'
)
})
})

View File

@@ -0,0 +1,295 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH =
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler'
describe('OwnershipTransferHandler', function () {
beforeEach(function () {
this.user = { _id: new ObjectId(), email: 'owner@example.com' }
this.collaborator = {
_id: new ObjectId(),
email: 'collaborator@example.com',
}
this.readOnlyCollaborator = {
_id: new ObjectId(),
email: 'readonly@example.com',
}
this.project = {
_id: new ObjectId(),
name: 'project',
owner_ref: this.user._id,
collaberator_refs: [this.collaborator._id],
readOnly_refs: [this.readOnlyCollaborator._id],
}
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.ProjectModel = {
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
},
}
this.TpdsUpdateSender = {
promises: {
moveEntity: sinon.stub().resolves(),
},
}
this.TpdsProjectFlusher = {
promises: {
flushProjectToTpds: sinon.stub().resolves(),
},
}
this.CollaboratorsHandler = {
promises: {
removeUserFromProject: sinon.stub().resolves(),
addUserIdToProject: sinon.stub().resolves(),
},
}
this.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
this.ProjectAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.handler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../Project/ProjectGetter': this.ProjectGetter,
'../../models/Project': {
Project: this.ProjectModel,
},
'../User/UserGetter': this.UserGetter,
'../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
'../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
'../Email/EmailHandler': this.EmailHandler,
'./CollaboratorsHandler': this.CollaboratorsHandler,
'../Analytics/AnalyticsManager': {
recordEventForUserInBackground: (this.recordEventForUserInBackground =
sinon.stub()),
},
},
})
})
describe('transferOwnership', function () {
beforeEach(function () {
this.UserGetter.promises.getUser
.withArgs(this.user._id)
.resolves(this.user)
this.UserGetter.promises.getUser
.withArgs(this.collaborator._id)
.resolves(this.collaborator)
this.UserGetter.promises.getUser
.withArgs(this.readOnlyCollaborator._id)
.resolves(this.readOnlyCollaborator)
})
it("should return a not found error if the project can't be found", async function () {
this.ProjectGetter.promises.getProject.resolves(null)
await expect(
this.handler.promises.transferOwnership('abc', this.collaborator._id)
).to.be.rejectedWith(Errors.ProjectNotFoundError)
})
it("should return a not found error if the user can't be found", async function () {
this.UserGetter.promises.getUser
.withArgs(this.collaborator._id)
.resolves(null)
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotFoundError)
})
it('should return an error if user cannot be removed as collaborator ', async function () {
this.CollaboratorsHandler.promises.removeUserFromProject.rejects(
new Error('user-cannot-be-removed')
)
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejected
})
it('should transfer ownership of the project', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
sinon.match({ $set: { owner_ref: this.collaborator._id } })
)
})
it('should transfer ownership of the project to a read-only collaborator', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.readOnlyCollaborator._id
)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
sinon.match({ $set: { owner_ref: this.readOnlyCollaborator._id } })
)
})
it('gives old owner read-only permissions if new owner was previously a viewer', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.readOnlyCollaborator._id
)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
this.readOnlyCollaborator._id,
this.user._id,
PrivilegeLevels.READ_ONLY
)
})
it('should do nothing if transferring back to the owner', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.user._id
)
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
it("should remove the user from the project's collaborators", async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(this.project._id, this.collaborator._id)
})
it('should add the former project owner as a read/write collaborator', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
this.collaborator._id,
this.user._id,
PrivilegeLevels.READ_AND_WRITE
)
})
it('should flush the project to tpds', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(this.project._id)
})
it('should send an email notification', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationPreviousOwner',
{
to: this.user.email,
project: this.project,
newOwner: this.collaborator,
}
)
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationNewOwner',
{
to: this.collaborator.email,
project: this.project,
previousOwner: this.user,
}
)
})
it('should not send an email notification with the skipEmails option', async function () {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id,
{ skipEmails: true }
)
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
})
it('should track the change in BigQuery', async function () {
const sessionUserId = new ObjectId()
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id,
{ sessionUserId }
)
expect(this.recordEventForUserInBackground).to.have.been.calledWith(
this.user._id,
'project-ownership-transfer',
{
projectId: this.project._id,
newOwnerId: this.collaborator._id,
}
)
})
it('should write an entry in the audit log', async function () {
const sessionUserId = new ObjectId()
const ipAddress = '1.2.3.4'
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id,
{ sessionUserId, ipAddress }
)
expect(
this.ProjectAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.project._id,
'transfer-ownership',
sessionUserId,
ipAddress,
{
previousOwnerId: this.user._id,
newOwnerId: this.collaborator._id,
}
)
})
it('should decline to transfer ownership to a non-collaborator', async function () {
this.project.collaberator_refs = []
this.project.readOnly_refs = []
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotCollaboratorError)
})
})
})

View File

@@ -0,0 +1,366 @@
const sinon = require('sinon')
const { assert, expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.js'
const SandboxedModule = require('sandboxed-module')
const realRequst = require('request')
describe('ClsiCookieManager', function () {
beforeEach(function () {
this.redis = {
auth() {},
get: sinon.stub(),
setex: sinon.stub().callsArg(3),
}
this.project_id = '123423431321-proj-id'
this.user_id = 'abc-user-id'
this.request = {
post: sinon.stub(),
cookie: realRequst.cookie,
jar: realRequst.jar,
defaults: () => {
return this.request
},
}
this.settings = {
redis: {
web: 'redis.something',
},
apis: {
clsi: {
url: 'http://clsi.example.com',
},
},
clsiCookie: {
ttlInSeconds: Math.random().toString(),
ttlInSecondsRegular: Math.random().toString(),
key: 'coooookie',
},
}
this.requires = {
'../../infrastructure/RedisWrapper': (this.RedisWrapper = {
client: () => this.redis,
}),
'@overleaf/settings': this.settings,
request: this.request,
}
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
requires: this.requires,
})()
})
describe('getServerId', function () {
it('should call get for the key', function (done) {
this.redis.get.callsArgWith(1, null, 'clsi-7')
this.ClsiCookieManager.getServerId(
this.project_id,
this.user_id,
'',
'e2',
(err, serverId) => {
if (err) {
return done(err)
}
this.redis.get
.calledWith(`clsiserver:${this.project_id}:${this.user_id}`)
.should.equal(true)
serverId.should.equal('clsi-7')
done()
}
)
})
it('should _populateServerIdViaRequest if no key is found', function (done) {
this.ClsiCookieManager._populateServerIdViaRequest = sinon
.stub()
.yields(null)
this.redis.get.callsArgWith(1, null)
this.ClsiCookieManager.getServerId(
this.project_id,
this.user_id,
'',
(err, serverId) => {
if (err) {
return done(err)
}
this.ClsiCookieManager._populateServerIdViaRequest
.calledWith(this.project_id, this.user_id)
.should.equal(true)
done()
}
)
})
it('should _populateServerIdViaRequest if no key is blank', function (done) {
this.ClsiCookieManager._populateServerIdViaRequest = sinon
.stub()
.yields(null)
this.redis.get.callsArgWith(1, null, '')
this.ClsiCookieManager.getServerId(
this.project_id,
this.user_id,
'',
'e2',
(err, serverId) => {
if (err) {
return done(err)
}
this.ClsiCookieManager._populateServerIdViaRequest
.calledWith(this.project_id, this.user_id)
.should.equal(true)
done()
}
)
})
})
describe('_populateServerIdViaRequest', function () {
beforeEach(function () {
this.clsiServerId = 'server-id'
this.ClsiCookieManager.setServerId = sinon.stub().yields()
})
describe('with a server id in the response', function () {
beforeEach(function () {
this.response = {
headers: {
'set-cookie': [
`${this.settings.clsiCookie.key}=${this.clsiServerId}`,
],
},
}
this.request.post.callsArgWith(1, null, this.response)
})
it('should make a request to the clsi', function (done) {
this.ClsiCookieManager._populateServerIdViaRequest(
this.project_id,
this.user_id,
'standard',
'e2',
(err, serverId) => {
if (err) {
return done(err)
}
const args = this.ClsiCookieManager.setServerId.args[0]
args[0].should.equal(this.project_id)
args[1].should.equal(this.user_id)
args[2].should.equal('standard')
args[3].should.equal('e2')
args[4].should.deep.equal(this.clsiServerId)
done()
}
)
})
it('should return the server id', function (done) {
this.ClsiCookieManager._populateServerIdViaRequest(
this.project_id,
this.user_id,
'',
'e2',
(err, serverId) => {
if (err) {
return done(err)
}
serverId.should.equal(this.clsiServerId)
done()
}
)
})
})
describe('without a server id in the response', function () {
beforeEach(function () {
this.response = { headers: {} }
this.request.post.yields(null, this.response)
})
it('should not set the server id there is no server id in the response', function (done) {
this.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns(null)
this.ClsiCookieManager.setServerId(
this.project_id,
this.user_id,
'standard',
'e2',
this.clsiServerId,
null,
err => {
if (err) {
return done(err)
}
this.redis.setex.called.should.equal(false)
done()
}
)
})
})
})
describe('setServerId', function () {
beforeEach(function () {
this.clsiServerId = 'server-id'
this.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
})
it('should set the server id with a ttl', function (done) {
this.ClsiCookieManager.setServerId(
this.project_id,
this.user_id,
'standard',
'e2',
this.clsiServerId,
null,
err => {
if (err) {
return done(err)
}
this.redis.setex.should.have.been.calledWith(
`clsiserver:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSeconds,
this.clsiServerId
)
done()
}
)
})
it('should set the server id with the regular ttl for reg instance', function (done) {
this.clsiServerId = 'clsi-reg-8'
this.ClsiCookieManager.setServerId(
this.project_id,
this.user_id,
'standard',
'e2',
this.clsiServerId,
null,
err => {
if (err) {
return done(err)
}
expect(this.redis.setex).to.have.been.calledWith(
`clsiserver:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSecondsRegular,
this.clsiServerId
)
done()
}
)
})
it('should not set the server id if clsiCookies are not enabled', function (done) {
delete this.settings.clsiCookie.key
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
globals: {
console,
},
requires: this.requires,
})()
this.ClsiCookieManager.setServerId(
this.project_id,
this.user_id,
'standard',
'e2',
this.clsiServerId,
null,
err => {
if (err) {
return done(err)
}
this.redis.setex.called.should.equal(false)
done()
}
)
})
it('should also set in the secondary if secondary redis is enabled', function (done) {
this.redis_secondary = { setex: sinon.stub().callsArg(3) }
this.settings.redis.clsi_cookie_secondary = {}
this.RedisWrapper.client = sinon.stub()
this.RedisWrapper.client.withArgs('clsi_cookie').returns(this.redis)
this.RedisWrapper.client
.withArgs('clsi_cookie_secondary')
.returns(this.redis_secondary)
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
globals: {
console,
},
requires: this.requires,
})()
this.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
this.ClsiCookieManager.setServerId(
this.project_id,
this.user_id,
'standard',
'e2',
this.clsiServerId,
null,
err => {
if (err) {
return done(err)
}
this.redis_secondary.setex.should.have.been.calledWith(
`clsiserver:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSeconds,
this.clsiServerId
)
done()
}
)
})
})
describe('getCookieJar', function () {
beforeEach(function () {
this.ClsiCookieManager.getServerId = sinon.stub().yields(null, 'clsi-11')
})
it('should return a jar with the cookie set populated from redis', function (done) {
this.ClsiCookieManager.getCookieJar(
this.project_id,
this.user_id,
'',
'e2',
(err, jar) => {
if (err) {
return done(err)
}
jar._jar.store.idx['clsi.example.com']['/'][
this.settings.clsiCookie.key
].key.should.equal
jar._jar.store.idx['clsi.example.com']['/'][
this.settings.clsiCookie.key
].value.should.equal('clsi-11')
done()
}
)
})
it('should return empty cookie jar if clsiCookies are not enabled', function (done) {
delete this.settings.clsiCookie.key
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
globals: {
console,
},
requires: this.requires,
})()
this.ClsiCookieManager.getCookieJar(
this.project_id,
this.user_id,
'',
'e2',
(err, jar) => {
if (err) {
return done(err)
}
assert.deepEqual(jar, realRequst.jar())
done()
}
)
})
})
})

View File

@@ -0,0 +1,236 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.js'
const SandboxedModule = require('sandboxed-module')
describe('ClsiFormatChecker', function () {
beforeEach(function () {
this.ClsiFormatChecker = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {
compileBodySizeLimitMb: 5,
}),
},
})
return (this.project_id = 'project-id')
})
describe('checkRecoursesForProblems', function () {
beforeEach(function () {
return (this.resources = [
{
path: 'main.tex',
content: 'stuff',
},
{
path: 'chapters/chapter1',
content: 'other stuff',
},
{
path: 'stuff/image/image.png',
url: `http:somewhere.com/project/${this.project_id}/file/1234124321312`,
modified: 'more stuff',
},
])
})
it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', function (done) {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null)
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1)
return this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources,
(err, problems) => {
this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(
true
)
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
true
)
return done()
}
)
})
it('should remove undefined errors', function (done) {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1, null, {})
return this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources,
(err, problems) => {
expect(problems).to.not.exist
expect(problems).to.not.exist
return done()
}
)
})
it('should keep populated arrays', function (done) {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [{ path: 'somewhere/main.tex' }])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1, null, {})
return this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources,
(err, problems) => {
problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
expect(problems.sizeCheck).to.not.exist
return done()
}
)
})
it('should keep populated object', function (done) {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1, null, {
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
totalSize: 1000000,
})
return this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources,
(err, problems) => {
problems.sizeCheck.resources.length.should.equal(2)
problems.sizeCheck.totalSize.should.equal(1000000)
expect(problems.conflictedPaths).to.not.exist
return done()
}
)
})
describe('_checkForConflictingPaths', function () {
beforeEach(function () {
this.resources.push({
path: 'chapters/chapter1.tex',
content: 'other stuff',
})
return this.resources.push({
path: 'chapters.tex',
content: 'other stuff',
})
})
it('should flag up when a nested file has folder with same subpath as file elsewhere', function (done) {
this.resources.push({
path: 'stuff/image',
url: 'http://somwhere.com',
})
return this.ClsiFormatChecker._checkForConflictingPaths(
this.resources,
(err, conflictPathErrors) => {
conflictPathErrors.length.should.equal(1)
conflictPathErrors[0].path.should.equal('stuff/image')
return done()
}
)
})
it('should flag up when a root level file has folder with same subpath as file elsewhere', function (done) {
this.resources.push({
path: 'stuff',
content: 'other stuff',
})
return this.ClsiFormatChecker._checkForConflictingPaths(
this.resources,
(err, conflictPathErrors) => {
conflictPathErrors.length.should.equal(1)
conflictPathErrors[0].path.should.equal('stuff')
return done()
}
)
})
it('should not flag up when the file is a substring of a path', function (done) {
this.resources.push({
path: 'stuf',
content: 'other stuff',
})
return this.ClsiFormatChecker._checkForConflictingPaths(
this.resources,
(err, conflictPathErrors) => {
conflictPathErrors.length.should.equal(0)
return done()
}
)
})
})
describe('_checkDocsAreUnderSizeLimit', function () {
it('should error when there is more than 5mb of data', function (done) {
this.resources.push({
path: 'massive.tex',
content: 'hello world'.repeat(833333), // over 5mb limit
})
while (this.resources.length < 20) {
this.resources.push({
path: 'chapters/chapter1.tex',
url: 'http://somwhere.com',
})
}
return this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
this.resources,
(err, sizeError) => {
sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
sizeError.resources.length.should.equal(10)
sizeError.resources[0].path.should.equal('massive.tex')
sizeError.resources[0].size.should.equal(833333 * 11)
return done()
}
)
})
it('should return nothing when project is correct size', function (done) {
this.resources.push({
path: 'massive.tex',
content: 'x'.repeat(2 * 1000 * 1000),
})
while (this.resources.length < 20) {
this.resources.push({
path: 'chapters/chapter1.tex',
url: 'http://somwhere.com',
})
}
return this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
this.resources,
(err, sizeError) => {
expect(sizeError).to.not.exist
return done()
}
)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ClsiStateManager', function () {
beforeEach(function () {
this.ClsiStateManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {}),
},
})
this.project = 'project'
this.options = { draft: true, isAutoCompile: false }
return (this.callback = sinon.stub())
})
describe('computeHash', function () {
beforeEach(function () {
this.docs = [
{ path: '/main.tex', doc: { _id: 'doc-id-1' } },
{ path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
]
this.files = [
{
path: '/figure.pdf',
file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
},
{
path: '/folder/fig2.pdf',
file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
},
]
this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
.stub()
.returns({ docs: this.docs, files: this.files })
this.hash0 = this.ClsiStateManager.computeHash(this.project, this.options)
})
describe('with a sample project', function () {
beforeEach(function () {})
it('should return a hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
})
})
describe('when the files and docs are in a different order', function () {
beforeEach(function () {
;[this.docs[0], this.docs[1]] = Array.from([this.docs[1], this.docs[0]])
;[this.files[0], this.files[1]] = Array.from([
this.files[1],
this.files[0],
])
})
it('should return the same hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal(this.hash0)
})
})
describe('when a doc is renamed', function () {
beforeEach(function () {
this.docs[0].path = '/new.tex'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is renamed', function () {
beforeEach(function () {
this.files[0].path = '/newfigure.pdf'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a doc is added', function () {
beforeEach(function () {
this.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is added', function () {
beforeEach(function () {
this.files.push({
path: '/newfile.tex',
file: { _id: 'newfile-id', rev: 123 },
})
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a doc is removed', function () {
beforeEach(function () {
this.docs.pop()
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is removed', function () {
beforeEach(function () {
this.files.pop()
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe("when a file's revision is updated", function () {
beforeEach(function () {
this.files[0].file.rev++
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe("when a file's date is updated", function () {
beforeEach(function () {
this.files[0].file.created = 'zzzzzz'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when the compile options are changed', function () {
beforeEach(function () {
this.options.draft = !this.options.draft
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when the isAutoCompile option is changed', function () {
beforeEach(function () {
this.options.isAutoCompile = !this.options.isAutoCompile
})
it('should return the same hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal(this.hash0)
})
})
})
})

View File

@@ -0,0 +1,929 @@
/* eslint-disable mocha/handle-done-callback */
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/CompileController.js'
const SandboxedModule = require('sandboxed-module')
const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const { Headers } = require('node-fetch')
const { ReadableString } = require('@overleaf/stream-utils')
describe('CompileController', function () {
beforeEach(function () {
this.user_id = 'wat'
this.user = {
_id: this.user_id,
email: 'user@example.com',
features: {
compileGroup: 'premium',
compileTimeout: 100,
},
}
this.CompileManager = { compile: sinon.stub() }
this.ClsiManager = {}
this.UserGetter = { getUser: sinon.stub() }
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.settings = {
apis: {
clsi: {
url: 'http://clsi.example.com',
submissionBackendClass: 'n2d',
},
clsi_priority: {
url: 'http://clsi-priority.example.com',
},
},
defaultFeatures: {
compileGroup: 'standard',
compileTimeout: 60,
},
clsiCookie: {
key: 'cookie-key',
},
}
this.ClsiCookieManager = {
getServerId: sinon.stub().yields(null, 'clsi-server-id-from-redis'),
}
this.SessionManager = {
getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user),
getLoggedInUserId: sinon.stub().returns(this.user_id),
getSessionUser: sinon.stub().returns(this.user),
isUserLoggedIn: sinon.stub().returns(true),
}
this.pipeline = sinon.stub().callsFake(async (stream, res) => {
if (res.callback) res.callback()
})
this.clsiStream = new ReadableString('{}')
this.clsiResponse = {
headers: new Headers({
'Content-Length': '2',
'Content-Type': 'application/json',
}),
}
this.fetchUtils = {
fetchStreamWithResponse: sinon.stub().resolves({
stream: this.clsiStream,
response: this.clsiResponse,
}),
}
this.CompileController = SandboxedModule.require(modulePath, {
requires: {
'stream/promises': { pipeline: this.pipeline },
'@overleaf/settings': this.settings,
'@overleaf/fetch-utils': this.fetchUtils,
request: (this.request = sinon.stub()),
'../Project/ProjectGetter': (this.ProjectGetter = {}),
'@overleaf/metrics': (this.Metrics = {
inc: sinon.stub(),
Timer: class {
constructor() {
this.labels = {}
}
done() {}
},
}),
'./CompileManager': this.CompileManager,
'../User/UserGetter': this.UserGetter,
'./ClsiManager': this.ClsiManager,
'../Authentication/SessionManager': this.SessionManager,
'../../infrastructure/RateLimiter': this.RateLimiter,
'./ClsiCookieManager': () => this.ClsiCookieManager,
'../SplitTests/SplitTestHandler': {
getAssignment: (this.getAssignment = sinon.stub().yields(null, {
variant: 'default',
})),
promises: {
getAssignment: sinon.stub().resolves({
variant: 'default',
}),
},
},
'../Analytics/AnalyticsManager': {
recordEventForSession: sinon.stub(),
},
},
})
this.projectId = 'project-id'
this.build_id = '18fbe9e7564-30dcb2f71250c690'
this.next = sinon.stub()
this.req = new MockRequest()
this.res = new MockResponse()
this.res = new MockResponse()
})
describe('compile', function () {
beforeEach(function () {
this.req.params = { Project_id: this.projectId }
this.req.session = {}
this.CompileManager.compile = sinon.stub().callsArgWith(
3,
null,
(this.status = 'success'),
(this.outputFiles = [
{
path: 'output.pdf',
url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
type: 'pdf',
},
]),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
this.build_id
)
})
describe('pdfDownloadDomain', function () {
beforeEach(function () {
this.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
})
describe('when clsi does not emit zone prefix', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileController.compile(this.req, this.res, this.next)
})
it('should add domain verbatim', function () {
this.res.statusCode.should.equal(200)
this.res.body.should.equal(
JSON.stringify({
status: this.status,
outputFiles: [
{
path: 'output.pdf',
url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
type: 'pdf',
},
],
outputFilesArchive: {
path: 'output.zip',
url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
type: 'zip',
},
pdfDownloadDomain: 'https://compiles.overleaf.test',
})
)
})
})
describe('when clsi emits a zone prefix', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileManager.compile = sinon.stub().callsArgWith(
3,
null,
(this.status = 'success'),
(this.outputFiles = [
{
path: 'output.pdf',
url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
type: 'pdf',
},
]),
undefined, // clsiServerId
undefined, // limits
undefined, // validationProblems
undefined, // stats
undefined, // timings
'/zone/b',
this.build_id
)
this.CompileController.compile(this.req, this.res, this.next)
})
it('should add the zone prefix', function () {
this.res.statusCode.should.equal(200)
this.res.body.should.equal(
JSON.stringify({
status: this.status,
outputFiles: [
{
path: 'output.pdf',
url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
type: 'pdf',
},
],
outputFilesArchive: {
path: 'output.zip',
url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
type: 'zip',
},
outputUrlPrefix: '/zone/b',
pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
})
)
})
})
})
describe('when not an auto compile', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileController.compile(this.req, this.res, this.next)
})
it('should look up the user id', function () {
this.SessionManager.getLoggedInUserId
.calledWith(this.req.session)
.should.equal(true)
})
it('should do the compile without the auto compile flag', function () {
this.CompileManager.compile.should.have.been.calledWith(
this.projectId,
this.user_id,
{
isAutoCompile: false,
compileFromClsiCache: false,
populateClsiCache: false,
enablePdfCaching: false,
fileLineErrors: false,
stopOnFirstError: false,
editorId: undefined,
}
)
})
it('should set the content-type of the response to application/json', function () {
this.res.type.should.equal('application/json')
})
it('should send a successful response reporting the status and files', function () {
this.res.statusCode.should.equal(200)
this.res.body.should.equal(
JSON.stringify({
status: this.status,
outputFiles: this.outputFiles,
outputFilesArchive: {
path: 'output.zip',
url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
type: 'zip',
},
})
)
})
})
describe('when an auto compile', function () {
beforeEach(function (done) {
this.res.callback = done
this.req.query = { auto_compile: 'true' }
this.CompileController.compile(this.req, this.res, this.next)
})
it('should do the compile with the auto compile flag', function () {
this.CompileManager.compile.should.have.been.calledWith(
this.projectId,
this.user_id,
{
isAutoCompile: true,
compileFromClsiCache: false,
populateClsiCache: false,
enablePdfCaching: false,
fileLineErrors: false,
stopOnFirstError: false,
editorId: undefined,
}
)
})
})
describe('with the draft attribute', function () {
beforeEach(function (done) {
this.res.callback = done
this.req.body = { draft: true }
this.CompileController.compile(this.req, this.res, this.next)
})
it('should do the compile without the draft compile flag', function () {
this.CompileManager.compile.should.have.been.calledWith(
this.projectId,
this.user_id,
{
isAutoCompile: false,
compileFromClsiCache: false,
populateClsiCache: false,
enablePdfCaching: false,
draft: true,
fileLineErrors: false,
stopOnFirstError: false,
editorId: undefined,
}
)
})
})
describe('with an editor id', function () {
beforeEach(function (done) {
this.res.callback = done
this.req.body = { editorId: 'the-editor-id' }
this.CompileController.compile(this.req, this.res, this.next)
})
it('should pass the editor id to the compiler', function () {
this.CompileManager.compile.should.have.been.calledWith(
this.projectId,
this.user_id,
{
isAutoCompile: false,
compileFromClsiCache: false,
populateClsiCache: false,
enablePdfCaching: false,
fileLineErrors: false,
stopOnFirstError: false,
editorId: 'the-editor-id',
}
)
})
})
})
describe('compileSubmission', function () {
beforeEach(function () {
this.submission_id = 'sub-1234'
this.req.params = { submission_id: this.submission_id }
this.req.body = {}
this.ClsiManager.sendExternalRequest = sinon
.stub()
.callsArgWith(
3,
null,
(this.status = 'success'),
(this.outputFiles = ['mock-output-files']),
(this.clsiServerId = 'mock-server-id'),
(this.validationProblems = null)
)
})
it('should set the content-type of the response to application/json', function () {
this.CompileController.compileSubmission(this.req, this.res, this.next)
this.res.contentType.calledWith('application/json').should.equal(true)
})
it('should send a successful response reporting the status and files', function () {
this.CompileController.compileSubmission(this.req, this.res, this.next)
this.res.statusCode.should.equal(200)
this.res.body.should.equal(
JSON.stringify({
status: this.status,
outputFiles: this.outputFiles,
clsiServerId: 'mock-server-id',
validationProblems: null,
})
)
})
describe('with compileGroup and timeout', function () {
beforeEach(function () {
this.req.body = {
compileGroup: 'special',
timeout: 600,
}
this.CompileController.compileSubmission(this.req, this.res, this.next)
})
it('should use the supplied values', function () {
this.ClsiManager.sendExternalRequest.should.have.been.calledWith(
this.submission_id,
{ compileGroup: 'special', timeout: 600 },
{ compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 }
)
})
})
describe('with other supported options but not compileGroup and timeout', function () {
beforeEach(function () {
this.req.body = {
rootResourcePath: 'main.tex',
compiler: 'lualatex',
draft: true,
check: 'validate',
}
this.CompileController.compileSubmission(this.req, this.res, this.next)
})
it('should use the other options but default values for compileGroup and timeout', function () {
this.ClsiManager.sendExternalRequest.should.have.been.calledWith(
this.submission_id,
{
rootResourcePath: 'main.tex',
compiler: 'lualatex',
draft: true,
check: 'validate',
},
{
rootResourcePath: 'main.tex',
compiler: 'lualatex',
draft: true,
check: 'validate',
compileGroup: 'standard',
compileBackendClass: 'n2d',
timeout: 60,
}
)
})
})
})
describe('downloadPdf', function () {
beforeEach(function () {
this.req.params = { Project_id: this.projectId }
this.project = { name: 'test namè; 1' }
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(2, null, this.project)
})
describe('when downloading for embedding', function () {
beforeEach(function (done) {
this.CompileController.proxyToClsi = sinon
.stub()
.callsFake(() => done())
this.CompileController.downloadPdf(this.req, this.res, this.next)
})
it('should look up the project', function () {
this.ProjectGetter.getProject
.calledWith(this.projectId, { name: 1 })
.should.equal(true)
})
it('should set the content-type of the response to application/pdf', function () {
this.res.contentType.calledWith('application/pdf').should.equal(true)
})
it('should set the content-disposition header with a safe version of the project name', function () {
this.res.setContentDisposition.should.be.calledWith('inline', {
filename: 'test_namè__1.pdf',
})
})
it('should increment the pdf-downloads metric', function () {
this.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
})
it('should proxy the PDF from the CLSI', function () {
this.CompileController.proxyToClsi
.calledWith(
this.projectId,
'output-file',
`/project/${this.projectId}/user/${this.user_id}/output/output.pdf`,
{},
this.req,
this.res,
this.next
)
.should.equal(true)
})
})
describe('when a build-id is provided', function () {
beforeEach(function (done) {
this.req.params.build_id = this.build_id
this.CompileController.proxyToClsi = sinon
.stub()
.callsFake(() => done())
this.CompileController.downloadPdf(this.req, this.res, this.next)
})
it('should proxy the PDF from the CLSI, with a build-id', function () {
this.CompileController.proxyToClsi
.calledWith(
this.projectId,
'output-file',
`/project/${this.projectId}/user/${this.user_id}/build/${this.build_id}/output/output.pdf`,
{},
this.req,
this.res,
this.next
)
.should.equal(true)
})
})
})
describe('getFileFromClsiWithoutUser', function () {
beforeEach(function () {
this.submission_id = 'sub-1234'
this.file = 'project.pdf'
this.req.params = {
submission_id: this.submission_id,
build_id: this.build_id,
file: this.file,
}
this.req.body = {}
this.expected_url = `/project/${this.submission_id}/build/${this.build_id}/output/${this.file}`
this.CompileController.proxyToClsiWithLimits = sinon.stub()
})
describe('without limits specified', function () {
beforeEach(function () {
this.CompileController.getFileFromClsiWithoutUser(
this.req,
this.res,
this.next
)
})
it('should proxy to CLSI with correct URL and default limits', function () {
this.CompileController.proxyToClsiWithLimits.should.have.been.calledWith(
this.submission_id,
'output-file',
this.expected_url,
{},
{
compileGroup: 'standard',
compileBackendClass: 'n2d',
}
)
})
})
describe('with limits specified', function () {
beforeEach(function () {
this.req.body = { compileTimeout: 600, compileGroup: 'special' }
this.CompileController.getFileFromClsiWithoutUser(
this.req,
this.res,
this.next
)
})
it('should proxy to CLSI with correct URL and specified limits', function () {
this.CompileController.proxyToClsiWithLimits.should.have.been.calledWith(
this.submission_id,
'output-file',
this.expected_url,
{},
{
compileGroup: 'special',
compileBackendClass: 'n2d',
}
)
})
})
})
describe('proxySyncCode', function () {
let file, line, column, imageName, editorId, buildId
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
file = 'main.tex'
line = String(Date.now())
column = String(Date.now() + 1)
editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
buildId = '195b4a3f9e7-03e5be430a9e7796'
this.req.query = { file, line, column, editorId, buildId }
imageName = 'foo/bar:tag-0'
this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName })
this.next.callsFake(done)
this.res.callback = done
this.CompileController.proxyToClsi = sinon.stub().callsFake(() => done())
this.CompileController.proxySyncCode(this.req, this.res, this.next)
})
it('should proxy the request with an imageName', function () {
expect(this.CompileController.proxyToClsi).to.have.been.calledWith(
this.projectId,
'sync-to-code',
`/project/${this.projectId}/user/${this.user_id}/sync/code`,
{
file,
line,
column,
imageName,
editorId,
buildId,
compileFromClsiCache: false,
},
this.req,
this.res,
this.next
)
})
})
describe('proxySyncPdf', function () {
let page, h, v, imageName, editorId, buildId
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
page = String(Date.now())
h = String(Math.random())
v = String(Math.random())
editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
buildId = '195b4a3f9e7-03e5be430a9e7796'
this.req.query = { page, h, v, editorId, buildId }
imageName = 'foo/bar:tag-1'
this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName })
this.next.callsFake(done)
this.res.callback = done
this.CompileController.proxyToClsi = sinon.stub().callsFake(() => done())
this.CompileController.proxySyncPdf(this.req, this.res, this.next)
})
it('should proxy the request with an imageName', function () {
expect(this.CompileController.proxyToClsi).to.have.been.calledWith(
this.projectId,
'sync-to-pdf',
`/project/${this.projectId}/user/${this.user_id}/sync/pdf`,
{
page,
h,
v,
imageName,
editorId,
buildId,
compileFromClsiCache: false,
},
this.req,
this.res,
this.next
)
})
})
describe('proxyToClsi', function () {
beforeEach(function () {
this.req.method = 'mock-method'
this.req.headers = {
Mock: 'Headers',
Range: '123-456',
'If-Range': 'abcdef',
'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT',
}
})
describe('old pdf viewer', function () {
describe('user with standard priority', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileManager.getProjectCompileLimits = sinon
.stub()
.callsArgWith(1, null, {
compileGroup: 'standard',
compileBackendClass: 'e2',
})
this.CompileController.proxyToClsi(
this.projectId,
'output-file',
(this.url = '/test'),
{ query: 'foo' },
this.req,
this.res,
this.next
)
})
it('should open a request to the CLSI', function () {
this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=e2&query=foo`
)
})
it('should pass the request on to the client', function () {
this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
})
})
describe('user with priority compile', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileManager.getProjectCompileLimits = sinon
.stub()
.callsArgWith(1, null, {
compileGroup: 'priority',
compileBackendClass: 'c2d',
})
this.CompileController.proxyToClsi(
this.projectId,
'output-file',
(this.url = '/test'),
{},
this.req,
this.res,
this.next
)
})
it('should open a request to the CLSI', function () {
this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.clsi.url}${this.url}?compileGroup=priority&compileBackendClass=c2d`
)
})
})
describe('user with standard priority via query string', function () {
beforeEach(function (done) {
this.res.callback = done
this.req.query = { compileGroup: 'standard' }
this.CompileManager.getProjectCompileLimits = sinon
.stub()
.callsArgWith(1, null, {
compileGroup: 'standard',
compileBackendClass: 'e2',
})
this.CompileController.proxyToClsi(
this.projectId,
'output-file',
(this.url = '/test'),
{},
this.req,
this.res,
this.next
)
})
it('should open a request to the CLSI', function () {
this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=e2`
)
})
it('should pass the request on to the client', function () {
this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
})
})
describe('user with non-existent priority via query string', function () {
beforeEach(function (done) {
this.res.callback = done
this.req.query = { compileGroup: 'foobar' }
this.CompileManager.getProjectCompileLimits = sinon
.stub()
.callsArgWith(1, null, {
compileGroup: 'standard',
compileBackendClass: 'e2',
})
this.CompileController.proxyToClsi(
this.projectId,
'output-file',
(this.url = '/test'),
{},
this.req,
this.res,
this.next
)
})
it('should proxy to the standard url', function () {
this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=e2`
)
})
})
describe('user with build parameter via query string', function () {
beforeEach(function (done) {
this.res.callback = done
this.CompileManager.getProjectCompileLimits = sinon
.stub()
.callsArgWith(1, null, {
compileGroup: 'standard',
compileBackendClass: 'e2',
})
this.req.query = { build: 1234 }
this.CompileController.proxyToClsi(
this.projectId,
'output-file',
(this.url = '/test'),
{},
this.req,
this.res,
this.next
)
})
it('should proxy to the standard url without the build parameter', function () {
this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=e2`
)
})
})
})
})
describe('deleteAuxFiles', function () {
beforeEach(function () {
this.CompileManager.deleteAuxFiles = sinon.stub().yields()
this.req.params = { Project_id: this.projectId }
this.req.query = { clsiserverid: 'node-1' }
this.res.sendStatus = sinon.stub()
this.CompileController.deleteAuxFiles(this.req, this.res, this.next)
})
it('should proxy to the CLSI', function () {
this.CompileManager.deleteAuxFiles
.calledWith(this.projectId, this.user_id, 'node-1')
.should.equal(true)
})
it('should return a 200', function () {
this.res.sendStatus.calledWith(200).should.equal(true)
})
})
describe('compileAndDownloadPdf', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.projectId,
},
}
this.downloadPath = `/project/${this.projectId}/build/123/output/output.pdf`
this.CompileManager.compile.callsArgWith(3, null, 'success', [
{
path: 'output.pdf',
url: this.downloadPath,
},
])
this.CompileController.proxyToClsi = sinon.stub()
this.res = { send: () => {}, sendStatus: sinon.stub() }
})
it('should call compile in the compile manager', function (done) {
this.CompileController.compileAndDownloadPdf(this.req, this.res)
this.CompileManager.compile.calledWith(this.projectId).should.equal(true)
done()
})
it('should proxy the res to the clsi with correct url', function (done) {
this.CompileController.compileAndDownloadPdf(this.req, this.res)
sinon.assert.calledWith(
this.CompileController.proxyToClsi,
this.projectId,
'output-file',
this.downloadPath,
{},
this.req,
this.res
)
this.CompileController.proxyToClsi
.calledWith(
this.projectId,
'output-file',
this.downloadPath,
{},
this.req,
this.res
)
.should.equal(true)
done()
})
it('should not download anything on compilation failures', function () {
this.CompileManager.compile.yields(new Error('failed'))
this.CompileController.compileAndDownloadPdf(this.req, this.res)
this.res.sendStatus.should.have.been.calledWith(500)
this.CompileController.proxyToClsi.should.not.have.been.called
})
it('should not download anything on missing pdf', function () {
this.CompileManager.compile.yields(null, 'success', [])
this.CompileController.compileAndDownloadPdf(this.req, this.res)
this.res.sendStatus.should.have.been.calledWith(500)
this.CompileController.proxyToClsi.should.not.have.been.called
})
})
describe('wordCount', function () {
beforeEach(function () {
this.CompileManager.wordCount = sinon
.stub()
.yields(null, { content: 'body' })
this.req.params = { Project_id: this.projectId }
this.req.query = { clsiserverid: 'node-42' }
this.res.json = sinon.stub()
this.res.contentType = sinon.stub()
this.CompileController.wordCount(this.req, this.res, this.next)
})
it('should proxy to the CLSI', function () {
this.CompileManager.wordCount
.calledWith(this.projectId, this.user_id, false, 'node-42')
.should.equal(true)
})
it('should return a 200 and body', function () {
this.res.json.calledWith({ content: 'body' }).should.equal(true)
})
})
})

View File

@@ -0,0 +1,431 @@
const { expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.js'
describe('CompileManager', function () {
beforeEach(function () {
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.timer = {
done: sinon.stub(),
}
this.Metrics = {
Timer: sinon.stub().returns(this.timer),
inc: sinon.stub(),
}
this.CompileManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': (this.settings = {
apis: {
clsi: { submissionBackendClass: 'n2d' },
},
redis: { web: { host: '127.0.0.1', port: 42 } },
rateLimit: { autoCompile: {} },
}),
'../../infrastructure/RedisWrapper': {
client: () =>
(this.rclient = {
auth() {},
}),
},
'../Project/ProjectRootDocManager': (this.ProjectRootDocManager = {
promises: {},
}),
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
'../User/UserGetter': (this.UserGetter = { promises: {} }),
'./ClsiManager': (this.ClsiManager = { promises: {} }),
'../../infrastructure/RateLimiter': this.RateLimiter,
'@overleaf/metrics': this.Metrics,
'../Analytics/UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
get: sinon.stub().resolves('abc'),
}),
},
})
this.project_id = 'mock-project-id-123'
this.user_id = 'mock-user-id-123'
this.callback = sinon.stub()
this.limits = {
timeout: 42,
compileGroup: 'standard',
}
})
describe('compile', function () {
beforeEach(function () {
this.CompileManager._checkIfRecentlyCompiled = sinon
.stub()
.resolves(false)
this.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon
.stub()
.resolves()
this.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves(this.limits)
this.ClsiManager.promises.sendRequest = sinon.stub().resolves({
status: (this.status = 'mock-status'),
outputFiles: (this.outputFiles = []),
clsiServerId: (this.output = 'mock output'),
})
})
describe('succesfully', function () {
let result
beforeEach(async function () {
this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
isAutoCompile,
compileGroup
) => true
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves(
(this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
)
this.UserGetter.promises.getUser = sinon.stub().resolves(
(this.user = {
features: { compileTimeout: '20s', compileGroup: 'standard' },
analyticsId: 'abc',
})
)
result = await this.CompileManager.promises.compile(
this.project_id,
this.user_id,
{}
)
})
it('should check the project has not been recently compiled', function () {
this.CompileManager._checkIfRecentlyCompiled
.calledWith(this.project_id, this.user_id)
.should.equal(true)
})
it('should ensure that the root document is set', function () {
this.ProjectRootDocManager.promises.ensureRootDocumentIsSet
.calledWith(this.project_id)
.should.equal(true)
})
it('should get the project compile limits', function () {
this.CompileManager.promises.getProjectCompileLimits
.calledWith(this.project_id)
.should.equal(true)
})
it('should run the compile with the compile limits', function () {
this.ClsiManager.promises.sendRequest
.calledWith(this.project_id, this.user_id, {
timeout: this.limits.timeout,
compileGroup: 'standard',
buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
})
.should.equal(true)
})
it('should resolve with the output', function () {
expect(result).to.haveOwnProperty('status', this.status)
expect(result).to.haveOwnProperty('clsiServerId', this.output)
expect(result).to.haveOwnProperty('outputFiles', this.outputFiles)
})
it('should time the compile', function () {
this.timer.done.called.should.equal(true)
})
})
describe('when the project has been recently compiled', function () {
it('should return', function (done) {
this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
isAutoCompile,
compileGroup
) => true
this.CompileManager._checkIfRecentlyCompiled = sinon
.stub()
.resolves(true)
this.CompileManager.promises
.compile(this.project_id, this.user_id, {})
.then(({ status }) => {
status.should.equal('too-recently-compiled')
done()
})
.catch(error => {
// Catch any errors and fail the test
true.should.equal(false)
done(error)
})
})
})
describe('should check the rate limit', function () {
it('should return', function (done) {
this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
.stub()
.resolves(false)
this.CompileManager.promises
.compile(this.project_id, this.user_id, {})
.then(({ status }) => {
expect(status).to.equal('autocompile-backoff')
done()
})
.catch(err => done(err))
})
})
})
describe('getProjectCompileLimits', function () {
beforeEach(async function () {
this.features = {
compileTimeout: (this.timeout = 42),
compileGroup: (this.group = 'priority'),
}
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves(
(this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
)
this.UserGetter.promises.getUser = sinon
.stub()
.resolves((this.user = { features: this.features, analyticsId: 'abc' }))
try {
const result =
await this.CompileManager.promises.getProjectCompileLimits(
this.project_id
)
this.callback(null, result)
} catch (error) {
this.callback(error)
}
})
it('should look up the owner of the project', function () {
this.ProjectGetter.promises.getProject
.calledWith(this.project_id, { owner_ref: 1 })
.should.equal(true)
})
it("should look up the owner's features", function () {
this.UserGetter.promises.getUser
.calledWith(this.project.owner_ref, {
_id: 1,
alphaProgram: 1,
analyticsId: 1,
betaProgram: 1,
features: 1,
})
.should.equal(true)
})
it('should return the limits', function () {
this.callback
.calledWith(null, {
timeout: this.timeout,
compileGroup: this.group,
compileBackendClass: 'c2d',
ownerAnalyticsId: 'abc',
})
.should.equal(true)
})
})
describe('compileBackendClass', function () {
beforeEach(function () {
this.features = {
compileTimeout: 42,
compileGroup: 'standard',
}
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves({ owner_ref: 'owner-id-123' })
this.UserGetter.promises.getUser = sinon
.stub()
.resolves({ features: this.features, analyticsId: 'abc' })
})
describe('with priority compile', function () {
beforeEach(function () {
this.features.compileGroup = 'priority'
})
it('should return the default class', function (done) {
this.CompileManager.getProjectCompileLimits(
this.project_id,
(err, { compileBackendClass }) => {
if (err) return done(err)
expect(compileBackendClass).to.equal('c2d')
done()
}
)
})
})
})
describe('deleteAuxFiles', function () {
let result
beforeEach(async function () {
this.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves((this.limits = { compileGroup: 'mock-compile-group' }))
this.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test')
result = await this.CompileManager.promises.deleteAuxFiles(
this.project_id,
this.user_id
)
})
it('should look up the compile group to use', function () {
this.CompileManager.promises.getProjectCompileLimits
.calledWith(this.project_id)
.should.equal(true)
})
it('should delete the aux files', function () {
this.ClsiManager.promises.deleteAuxFiles
.calledWith(this.project_id, this.user_id, this.limits)
.should.equal(true)
})
it('should resolve', function () {
expect(result).not.to.be.undefined
})
})
describe('_checkIfRecentlyCompiled', function () {
describe('when the key exists in redis', function () {
let result
beforeEach(async function () {
this.rclient.set = sinon.stub().resolves(null)
result = await this.CompileManager._checkIfRecentlyCompiled(
this.project_id,
this.user_id
)
})
it('should try to set the key', function () {
this.rclient.set
.calledWith(
`compile:${this.project_id}:${this.user_id}`,
true,
'EX',
this.CompileManager.COMPILE_DELAY,
'NX'
)
.should.equal(true)
})
it('should resolve with true', function () {
result.should.equal(true)
})
})
describe('when the key does not exist in redis', function () {
let result
beforeEach(async function () {
this.rclient.set = sinon.stub().resolves('OK')
result = await this.CompileManager._checkIfRecentlyCompiled(
this.project_id,
this.user_id
)
})
it('should try to set the key', function () {
this.rclient.set
.calledWith(
`compile:${this.project_id}:${this.user_id}`,
true,
'EX',
this.CompileManager.COMPILE_DELAY,
'NX'
)
.should.equal(true)
})
it('should resolve with false', function () {
result.should.equal(false)
})
})
})
describe('_checkIfAutoCompileLimitHasBeenHit', function () {
it('should be able to compile if it is not an autocompile', async function () {
const canCompile =
await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
false,
'everyone'
)
expect(canCompile).to.equal(true)
})
it('should be able to compile if rate limit has remaining', async function () {
const canCompile =
await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
true,
'everyone'
)
expect(this.rateLimiter.consume).to.have.been.calledWith('global')
expect(canCompile).to.equal(true)
})
it('should be not able to compile if rate limit has no remianing', async function () {
this.rateLimiter.consume.rejects({ remainingPoints: 0 })
const canCompile =
await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
true,
'everyone'
)
expect(canCompile).to.equal(false)
})
it('should return false if there is an error in the rate limit', async function () {
this.rateLimiter.consume.rejects(new Error('BOOM!'))
const canCompile =
await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
true,
'everyone'
)
expect(canCompile).to.equal(false)
})
})
describe('wordCount', function () {
let result
const wordCount = 1
beforeEach(async function () {
this.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves((this.limits = { compileGroup: 'mock-compile-group' }))
this.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount)
result = await this.CompileManager.promises.wordCount(
this.project_id,
this.user_id,
false
)
})
it('should look up the compile group to use', function () {
this.CompileManager.promises.getProjectCompileLimits
.calledWith(this.project_id)
.should.equal(true)
})
it('should call wordCount for project', function () {
this.ClsiManager.promises.wordCount
.calledWith(this.project_id, this.user_id, false, this.limits)
.should.equal(true)
})
it('should resolve with the wordCount from the ClsiManager', function () {
expect(result).to.equal(wordCount)
})
})
})

View File

@@ -0,0 +1,129 @@
import sinon from 'sinon'
import { expect } from 'chai'
import esmock from 'esmock'
import MockResponse from '../helpers/MockResponse.js'
const modulePath = '../../../../app/src/Features/Contacts/ContactController.mjs'
describe('ContactController', function () {
beforeEach(async function () {
this.SessionManager = { getLoggedInUserId: sinon.stub() }
this.ContactController = await esmock.strict(modulePath, {
'../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
promises: {},
}),
'../../../../app/src/Features/Contacts/ContactManager':
(this.ContactManager = { promises: {} }),
'../../../../app/src/Features/Authentication/SessionManager':
(this.SessionManager = {}),
'../../../../app/src/infrastructure/Modules': (this.Modules = {
promises: { hooks: {} },
}),
})
this.req = {}
this.res = new MockResponse()
})
describe('getContacts', function () {
beforeEach(function () {
this.user_id = 'mock-user-id'
this.contact_ids = ['contact-1', 'contact-2', 'contact-3']
this.contacts = [
{
_id: 'contact-1',
email: 'joe@example.com',
first_name: 'Joe',
last_name: 'Example',
unsued: 'foo',
},
{
_id: 'contact-2',
email: 'jane@example.com',
first_name: 'Jane',
last_name: 'Example',
unsued: 'foo',
holdingAccount: true,
},
{
_id: 'contact-3',
email: 'jim@example.com',
first_name: 'Jim',
last_name: 'Example',
unsued: 'foo',
},
]
this.SessionManager.getLoggedInUserId = sinon.stub().returns(this.user_id)
this.ContactManager.promises.getContactIds = sinon
.stub()
.resolves(this.contact_ids)
this.UserGetter.promises.getUsers = sinon.stub().resolves(this.contacts)
this.Modules.promises.hooks.fire = sinon.stub()
})
it('should look up the logged in user id', async function () {
this.ContactController.getContacts(this.req, this.res)
this.SessionManager.getLoggedInUserId
.calledWith(this.req.session)
.should.equal(true)
})
it('should get the users contact ids', async function () {
this.res.callback = () => {
expect(
this.ContactManager.promises.getContactIds
).to.have.been.calledWith(this.user_id, { limit: 50 })
}
this.ContactController.getContacts(this.req, this.res)
})
it('should populate the users contacts ids', function (done) {
this.res.callback = () => {
expect(this.UserGetter.promises.getUsers).to.have.been.calledWith(
this.contact_ids,
{
email: 1,
first_name: 1,
last_name: 1,
holdingAccount: 1,
}
)
done()
}
this.ContactController.getContacts(this.req, this.res, done)
})
it('should fire the getContact module hook', function (done) {
this.res.callback = () => {
expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
'getContacts',
this.user_id
)
done()
}
this.ContactController.getContacts(this.req, this.res, done)
})
it('should return a formatted list of contacts in contact list order, without holding accounts', function (done) {
this.res.callback = () => {
this.res.json.args[0][0].contacts.should.deep.equal([
{
id: 'contact-1',
email: 'joe@example.com',
first_name: 'Joe',
last_name: 'Example',
type: 'user',
},
{
id: 'contact-3',
email: 'jim@example.com',
first_name: 'Jim',
last_name: 'Example',
type: 'user',
},
])
done()
}
this.ContactController.getContacts(this.req, this.res, done)
})
})
})

View File

@@ -0,0 +1,104 @@
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Contacts/ContactManager'
const SandboxedModule = require('sandboxed-module')
describe('ContactManager', function () {
beforeEach(function () {
this.user_id = 'user-id-123'
this.contact_id = 'contact-id-123'
this.contact_ids = ['mock', 'contact_ids']
this.FetchUtils = {
fetchJson: sinon.stub(),
}
this.ContactManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/fetch-utils': this.FetchUtils,
'@overleaf/settings': (this.settings = {
apis: {
contacts: {
url: 'http://contacts.overleaf.com',
},
},
}),
},
})
})
describe('getContacts', function () {
describe('with a successful response code', function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids })
this.result = await this.ContactManager.promises.getContactIds(
this.user_id,
{ limit: 42 }
)
})
it('should get the contacts from the contacts api', function () {
this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.contacts.url}/user/${this.user_id}/contacts?limit=42`
)
)
})
it('should return the contacts', function () {
this.result.should.equal(this.contact_ids)
})
})
describe('when an error occurs', function () {
beforeEach(async function () {
this.response = {
ok: false,
statusCode: 500,
json: sinon.stub().resolves({ contact_ids: this.contact_ids }),
}
this.FetchUtils.fetchJson.rejects(new Error('request error'))
})
it('should reject the promise', async function () {
await expect(
this.ContactManager.promises.getContactIds(this.user_id, {
limit: 42,
})
).to.be.rejected
})
})
})
describe('addContact', function () {
describe('with a successful response code', function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids })
this.result = await this.ContactManager.promises.addContact(
this.user_id,
this.contact_id
)
})
it('should add the contacts for the user in the contacts api', function () {
this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
sinon.match(
url =>
url.toString() ===
`${this.settings.apis.contacts.url}/user/${this.user_id}/contacts`
),
sinon.match({
method: 'POST',
json: { contact_id: this.contact_id },
})
)
})
it('should call the callback', function () {
this.result.should.equal(this.contact_ids)
})
})
})
})

View File

@@ -0,0 +1,181 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Cooldown/CooldownManager'
)
describe('CooldownManager', function () {
beforeEach(function () {
this.projectId = 'abcdefg'
this.rclient = { set: sinon.stub(), get: sinon.stub() }
this.RedisWrapper = { client: () => this.rclient }
return (this.CooldownManager = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/RedisWrapper': this.RedisWrapper,
},
}))
})
describe('_buildKey', function () {
it('should build a properly formatted redis key', function () {
return expect(this.CooldownManager._buildKey('ABC')).to.equal(
'Cooldown:{ABC}'
)
})
})
describe('isProjectOnCooldown', function () {
beforeEach(function () {
return (this.call = cb => {
return this.CooldownManager.isProjectOnCooldown(this.projectId, cb)
})
})
describe('when project is on cooldown', function () {
beforeEach(function () {
return (this.rclient.get = sinon.stub().callsArgWith(1, null, '1'))
})
it('should fetch key from redis', function (done) {
return this.call((err, result) => {
this.rclient.get.callCount.should.equal(1)
this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
return done()
})
})
it('should not produce an error', function (done) {
return this.call((err, result) => {
expect(err).to.equal(null)
return done()
})
})
it('should produce a true result', function (done) {
return this.call((err, result) => {
expect(result).to.equal(true)
return done()
})
})
})
describe('when project is not on cooldown', function () {
beforeEach(function () {
return (this.rclient.get = sinon.stub().callsArgWith(1, null, null))
})
it('should fetch key from redis', function (done) {
return this.call((err, result) => {
this.rclient.get.callCount.should.equal(1)
this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
return done()
})
})
it('should not produce an error', function (done) {
return this.call((err, result) => {
expect(err).to.equal(null)
return done()
})
})
it('should produce a false result', function (done) {
return this.call((err, result) => {
expect(result).to.equal(false)
return done()
})
})
})
describe('when rclient.get produces an error', function () {
beforeEach(function () {
return (this.rclient.get = sinon
.stub()
.callsArgWith(1, new Error('woops')))
})
it('should fetch key from redis', function (done) {
return this.call((err, result) => {
this.rclient.get.callCount.should.equal(1)
this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
return done()
})
})
it('should produce an error', function (done) {
return this.call((err, result) => {
expect(err).to.not.equal(null)
expect(err).to.be.instanceof(Error)
return done()
})
})
})
})
describe('putProjectOnCooldown', function () {
beforeEach(function () {
return (this.call = cb => {
return this.CooldownManager.putProjectOnCooldown(this.projectId, cb)
})
})
describe('when rclient.set does not produce an error', function () {
beforeEach(function () {
return (this.rclient.set = sinon.stub().callsArgWith(4, null))
})
it('should set a key in redis', function (done) {
return this.call(err => {
this.rclient.set.callCount.should.equal(1)
this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
return done()
})
})
it('should not produce an error', function (done) {
return this.call(err => {
expect(err).to.equal(null)
return done()
})
})
})
describe('when rclient.set produces an error', function () {
beforeEach(function () {
return (this.rclient.set = sinon
.stub()
.callsArgWith(4, new Error('woops')))
})
it('should set a key in redis', function (done) {
return this.call(err => {
this.rclient.set.callCount.should.equal(1)
this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
return done()
})
})
it('produce an error', function (done) {
return this.call(err => {
expect(err).to.not.equal(null)
expect(err).to.be.instanceof(Error)
return done()
})
})
})
})
})

View File

@@ -0,0 +1,134 @@
/* eslint-disable
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import esmock from 'esmock'
import sinon from 'sinon'
import { expect } from 'chai'
const modulePath = new URL(
'../../../../app/src/Features/Cooldown/CooldownMiddleware.mjs',
import.meta.url
).pathname
describe('CooldownMiddleware', function () {
beforeEach(async function () {
this.CooldownManager = { isProjectOnCooldown: sinon.stub() }
return (this.CooldownMiddleware = await esmock.strict(modulePath, {
'../../../../app/src/Features/Cooldown/CooldownManager.js':
this.CooldownManager,
}))
})
describe('freezeProject', function () {
describe('when project is on cooldown', function () {
beforeEach(function () {
this.CooldownManager.isProjectOnCooldown = sinon
.stub()
.callsArgWith(1, null, true)
this.req = { params: { Project_id: 'abc' } }
this.res = { sendStatus: sinon.stub() }
return (this.next = sinon.stub())
})
it('should call CooldownManager.isProjectOnCooldown', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
return this.CooldownManager.isProjectOnCooldown
.calledWith('abc')
.should.equal(true)
})
it('should not produce an error', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
return this.next.callCount.should.equal(0)
})
it('should send a 429 status', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.res.sendStatus.callCount.should.equal(1)
return this.res.sendStatus.calledWith(429).should.equal(true)
})
})
describe('when project is not on cooldown', function () {
beforeEach(function () {
this.CooldownManager.isProjectOnCooldown = sinon
.stub()
.callsArgWith(1, null, false)
this.req = { params: { Project_id: 'abc' } }
this.res = { sendStatus: sinon.stub() }
return (this.next = sinon.stub())
})
it('should call CooldownManager.isProjectOnCooldown', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
return this.CooldownManager.isProjectOnCooldown
.calledWith('abc')
.should.equal(true)
})
it('call next with no arguments', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.next.callCount.should.equal(1)
return expect(this.next.lastCall.args.length).to.equal(0)
})
})
describe('when isProjectOnCooldown produces an error', function () {
beforeEach(function () {
this.CooldownManager.isProjectOnCooldown = sinon
.stub()
.callsArgWith(1, new Error('woops'))
this.req = { params: { Project_id: 'abc' } }
this.res = { sendStatus: sinon.stub() }
return (this.next = sinon.stub())
})
it('should call CooldownManager.isProjectOnCooldown', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
return this.CooldownManager.isProjectOnCooldown
.calledWith('abc')
.should.equal(true)
})
it('call next with an error', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.next.callCount.should.equal(1)
return expect(this.next.lastCall.args[0]).to.be.instanceof(Error)
})
})
describe('when projectId is not part of route', function () {
beforeEach(function () {
this.CooldownManager.isProjectOnCooldown = sinon
.stub()
.callsArgWith(1, null, true)
this.req = { params: { lol: 'abc' } }
this.res = { sendStatus: sinon.stub() }
return (this.next = sinon.stub())
})
it('call next with an error', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
this.next.callCount.should.equal(1)
return expect(this.next.lastCall.args[0]).to.be.instanceof(Error)
})
it('should not call CooldownManager.isProjectOnCooldown', function () {
this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
return this.CooldownManager.isProjectOnCooldown.callCount.should.equal(
0
)
})
})
})
})

View File

@@ -0,0 +1,631 @@
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Docstore/DocstoreManager'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const tk = require('timekeeper')
describe('DocstoreManager', function () {
beforeEach(function () {
this.requestDefaults = sinon.stub().returns((this.request = sinon.stub()))
this.DocstoreManager = SandboxedModule.require(modulePath, {
requires: {
request: {
defaults: this.requestDefaults,
},
'@overleaf/settings': (this.settings = {
apis: {
docstore: {
url: 'docstore.overleaf.com',
},
},
}),
},
})
this.requestDefaults.calledWith({ jar: false }).should.equal(true)
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-123'
this.callback = sinon.stub()
})
describe('deleteDoc', function () {
describe('with a successful response code', function () {
// for assertions on the deletedAt timestamp, we need to freeze the clock.
before(function () {
tk.freeze(Date.now())
})
after(function () {
tk.reset()
})
beforeEach(function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, '')
this.DocstoreManager.deleteDoc(
this.project_id,
this.doc_id,
'wombat.tex',
new Date(),
this.callback
)
})
it('should delete the doc in the docstore api', function () {
this.request.patch
.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
json: { deleted: true, deletedAt: new Date(), name: 'wombat.tex' },
timeout: 30 * 1000,
})
.should.equal(true)
})
it('should call the callback without an error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
this.DocstoreManager.deleteDoc(
this.project_id,
this.doc_id,
'main.tex',
new Date(),
this.callback
)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
describe('with a missing (404) response code', function () {
beforeEach(function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 404 }, '')
this.DocstoreManager.deleteDoc(
this.project_id,
this.doc_id,
'main.tex',
new Date(),
this.callback
)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Errors.NotFoundError)
.and(
sinon.match.has(
'message',
'tried to delete doc not in docstore'
)
)
)
.should.equal(true)
})
})
})
describe('updateDoc', function () {
beforeEach(function () {
this.lines = ['mock', 'doc', 'lines']
this.rev = 5
this.version = 42
this.ranges = { mock: 'ranges' }
this.modified = true
})
describe('with a successful response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(
1,
null,
{ statusCode: 204 },
{ modified: this.modified, rev: this.rev }
)
this.DocstoreManager.updateDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.callback
)
})
it('should update the doc in the docstore api', function () {
this.request.post
.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
timeout: 30 * 1000,
json: {
lines: this.lines,
version: this.version,
ranges: this.ranges,
},
})
.should.equal(true)
})
it('should call the callback with the modified status and revision', function () {
this.callback
.calledWith(null, this.modified, this.rev)
.should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
this.DocstoreManager.updateDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.callback
)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('getDoc', function () {
beforeEach(function () {
this.doc = {
lines: (this.lines = ['mock', 'doc', 'lines']),
rev: (this.rev = 5),
version: (this.version = 42),
ranges: (this.ranges = { mock: 'ranges' }),
}
})
describe('with a successful response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
})
it('should get the doc from the docstore api', function () {
this.request.get.should.have.been.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
timeout: 30 * 1000,
json: true,
})
})
it('should call the callback with the lines, version and rev', function () {
this.callback
.calledWith(null, this.lines, this.rev, this.version, this.ranges)
.should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
describe('with include_deleted=true', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
this.DocstoreManager.getDoc(
this.project_id,
this.doc_id,
{ include_deleted: true },
this.callback
)
})
it('should get the doc from the docstore api (including deleted)', function () {
this.request.get.should.have.been.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
qs: { include_deleted: 'true' },
timeout: 30 * 1000,
json: true,
})
})
it('should call the callback with the lines, version and rev', function () {
this.callback
.calledWith(null, this.lines, this.rev, this.version, this.ranges)
.should.equal(true)
})
})
describe('with peek=true', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
this.DocstoreManager.getDoc(
this.project_id,
this.doc_id,
{ peek: true },
this.callback
)
})
it('should call the docstore peek url', function () {
this.request.get.should.have.been.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}/peek`,
timeout: 30 * 1000,
json: true,
})
})
})
describe('with a missing (404) response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 404 }, '')
this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Errors.NotFoundError)
.and(sinon.match.has('message', 'doc not found in docstore'))
)
.should.equal(true)
})
})
})
describe('getAllDocs', function () {
describe('with a successful response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(
1,
null,
{ statusCode: 204 },
(this.docs = [{ _id: 'mock-doc-id' }])
)
this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
it('should get all the project docs in the docstore api', function () {
this.request.get
.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc`,
timeout: 30 * 1000,
json: true,
})
.should.equal(true)
})
it('should call the callback with the docs', function () {
this.callback.calledWith(null, this.docs).should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('getAllDeletedDocs', function () {
describe('with a successful response code', function () {
beforeEach(function (done) {
this.callback.callsFake(done)
this.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.docs)
this.DocstoreManager.getAllDeletedDocs(this.project_id, this.callback)
})
it('should get all the project docs in the docstore api', function () {
this.request.get.should.have.been.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc-deleted`,
timeout: 30 * 1000,
json: true,
})
})
it('should call the callback with the docs', function () {
this.callback.should.have.been.calledWith(null, this.docs)
})
})
describe('with an error', function () {
beforeEach(function (done) {
this.callback.callsFake(() => done())
this.request.get = sinon
.stub()
.callsArgWith(1, new Error('connect failed'))
this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback.should.have.been.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'connect failed'))
)
})
})
describe('with a failed response code', function () {
beforeEach(function (done) {
this.callback.callsFake(() => done())
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback.should.have.been.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
})
})
})
describe('getAllRanges', function () {
describe('with a successful response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(
1,
null,
{ statusCode: 204 },
(this.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }])
)
this.DocstoreManager.getAllRanges(this.project_id, this.callback)
})
it('should get all the project doc ranges in the docstore api', function () {
this.request.get
.calledWith({
url: `${this.settings.apis.docstore.url}/project/${this.project_id}/ranges`,
timeout: 30 * 1000,
json: true,
})
.should.equal(true)
})
it('should call the callback with the docs', function () {
this.callback.calledWith(null, this.docs).should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
this.DocstoreManager.getAllRanges(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('archiveProject', function () {
describe('with a successful response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
this.DocstoreManager.archiveProject(this.project_id, this.callback)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
this.DocstoreManager.archiveProject(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('unarchiveProject', function () {
describe('with a successful response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
this.DocstoreManager.unarchiveProject(this.project_id, this.callback)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
this.DocstoreManager.unarchiveProject(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('destroyProject', function () {
describe('with a successful response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
this.DocstoreManager.destroyProject(this.project_id, this.callback)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('with a failed response code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
this.DocstoreManager.destroyProject(this.project_id, this.callback)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'docstore api responded with non-success code: 500'
)
)
)
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,93 @@
import sinon from 'sinon'
import { expect } from 'chai'
import esmock from 'esmock'
import MockResponse from '../helpers/MockResponse.js'
const MODULE_PATH =
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs'
describe('DocumentUpdaterController', function () {
beforeEach(async function () {
this.DocumentUpdaterHandler = {
promises: {
getDocument: sinon.stub(),
},
}
this.ProjectLocator = {
promises: {
findElement: sinon.stub(),
},
}
this.controller = await esmock.strict(MODULE_PATH, {
'@overleaf/settings': this.settings,
'../../../../app/src/Features/Project/ProjectLocator.js':
this.ProjectLocator,
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js':
this.DocumentUpdaterHandler,
})
this.projectId = '2k3j1lk3j21lk3j'
this.fileId = '12321kklj1lk3jk12'
this.req = {
params: {
Project_id: this.projectId,
Doc_id: this.docId,
},
get(key) {
return undefined
},
}
this.lines = ['test', '', 'testing']
this.res = new MockResponse()
this.next = sinon.stub()
this.doc = { name: 'myfile.tex' }
})
describe('getDoc', function () {
beforeEach(function () {
this.DocumentUpdaterHandler.promises.getDocument.resolves({
lines: this.lines,
})
this.ProjectLocator.promises.findElement.resolves({
element: this.doc,
})
this.res = new MockResponse()
})
it('should call the document updater handler with the project_id and doc_id', async function () {
await this.controller.getDoc(this.req, this.res, this.next)
expect(
this.DocumentUpdaterHandler.promises.getDocument
).to.have.been.calledOnceWith(
this.req.params.Project_id,
this.req.params.Doc_id,
-1
)
})
it('should return the content', async function () {
await this.controller.getDoc(this.req, this.res)
expect(this.next).to.not.have.been.called
expect(this.res.statusCode).to.equal(200)
expect(this.res.body).to.equal('test\n\ntesting')
})
it('should find the doc in the project', async function () {
await this.controller.getDoc(this.req, this.res)
expect(
this.ProjectLocator.promises.findElement
).to.have.been.calledOnceWith({
project_id: this.projectId,
element_id: this.docId,
type: 'doc',
})
})
it('should set the Content-Disposition header', async function () {
await this.controller.getDoc(this.req, this.res)
expect(this.res.setContentDisposition).to.have.been.calledWith(
'attachment',
{ filename: this.doc.name }
)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
import sinon from 'sinon'
import esmock from 'esmock'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const MODULE_PATH =
'../../../../app/src/Features/Documents/DocumentController.mjs'
describe('DocumentController', function () {
beforeEach(async function () {
this.res = new MockResponse()
this.req = new MockRequest()
this.next = sinon.stub()
this.doc = { _id: 'doc-id-123' }
this.doc_lines = ['one', 'two', 'three']
this.version = 42
this.ranges = {
comments: [
{
id: 'comment1',
op: {
c: 'foo',
p: 123,
t: 'comment1',
},
},
{
id: 'comment2',
op: {
c: 'bar',
p: 456,
t: 'comment2',
},
},
],
}
this.pathname = '/a/b/c/file.tex'
this.lastUpdatedAt = new Date().getTime()
this.lastUpdatedBy = 'fake-last-updater-id'
this.rev = 5
this.project = {
_id: 'project-id-123',
overleaf: {
history: {
id: 1234,
display: true,
},
},
}
this.resolvedThreadIds = [
'comment2',
'comment4', // Comment in project but not in doc
]
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.ProjectLocator = {
promises: {
findElement: sinon
.stub()
.resolves({ element: this.doc, path: { fileSystem: this.pathname } }),
},
}
this.ProjectEntityHandler = {
promises: {
getDoc: sinon.stub().resolves({
lines: this.doc_lines,
rev: this.rev,
version: this.version,
ranges: this.ranges,
}),
},
}
this.ProjectEntityUpdateHandler = {
promises: {
updateDocLines: sinon.stub().resolves(),
},
}
this.ChatApiHandler = {
promises: {
getResolvedThreadIds: sinon.stub().resolves(this.resolvedThreadIds),
},
}
this.DocumentController = await esmock.strict(MODULE_PATH, {
'../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter,
'../../../../app/src/Features/Project/ProjectLocator':
this.ProjectLocator,
'../../../../app/src/Features/Project/ProjectEntityHandler':
this.ProjectEntityHandler,
'../../../../app/src/Features/Project/ProjectEntityUpdateHandler':
this.ProjectEntityUpdateHandler,
'../../../../app/src/Features/Chat/ChatApiHandler': this.ChatApiHandler,
})
})
describe('getDocument', function () {
beforeEach(function () {
this.req.params = {
Project_id: this.project._id,
doc_id: this.doc._id,
}
})
describe('when project exists with project history enabled', function () {
beforeEach(function (done) {
this.res.callback = err => {
done(err)
}
this.DocumentController.getDocument(this.req, this.res, this.next)
})
it('should return the history id and display setting to the client as JSON', function () {
this.res.type.should.equal('application/json')
JSON.parse(this.res.body).should.deep.equal({
lines: this.doc_lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.project.overleaf.history.id,
projectHistoryType: 'project-history',
resolvedCommentIds: ['comment2'],
historyRangesSupport: false,
})
})
})
describe('when the project does not exist', function () {
beforeEach(function (done) {
this.ProjectGetter.promises.getProject.resolves(null)
this.res.callback = err => {
done(err)
}
this.DocumentController.getDocument(this.req, this.res, this.next)
})
it('returns a 404', function () {
this.res.statusCode.should.equal(404)
})
})
})
describe('setDocument', function () {
beforeEach(function () {
this.req.params = {
Project_id: this.project._id,
doc_id: this.doc._id,
}
})
describe('when the document exists', function () {
beforeEach(function (done) {
this.req.body = {
lines: this.doc_lines,
version: this.version,
ranges: this.ranges,
lastUpdatedAt: this.lastUpdatedAt,
lastUpdatedBy: this.lastUpdatedBy,
}
this.res.callback = err => {
done(err)
}
this.DocumentController.setDocument(this.req, this.res, this.next)
})
it('should update the document in Mongo', function () {
sinon.assert.calledWith(
this.ProjectEntityUpdateHandler.promises.updateDocLines,
this.project._id,
this.doc._id,
this.doc_lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy
)
})
it('should return a successful response', function () {
this.res.success.should.equal(true)
})
})
describe("when the document doesn't exist", function () {
beforeEach(function (done) {
this.ProjectEntityUpdateHandler.promises.updateDocLines.rejects(
new Errors.NotFoundError('document does not exist')
)
this.req.body = { lines: this.doc_lines }
this.next.callsFake(() => {
done()
})
this.DocumentController.setDocument(this.req, this.res, this.next)
})
it('should call next with the NotFoundError', function () {
this.next
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,162 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Documents/DocumentHelper.js'
const SandboxedModule = require('sandboxed-module')
describe('DocumentHelper', function () {
beforeEach(function () {
return (this.DocumentHelper = SandboxedModule.require(modulePath))
})
describe('getTitleFromTexContent', function () {
it('should return the title', function () {
const document = '\\begin{document}\n\\title{foo}\n\\end{document}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('foo')
})
it('should return the title if surrounded by space', function () {
const document = '\\begin{document}\n \\title{foo} \n\\end{document}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('foo')
})
it('should return null if there is no title', function () {
const document = '\\begin{document}\n\\end{document}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.eql(null)
})
it('should accept an array', function () {
const document = ['\\begin{document}', '\\title{foo}', '\\end{document}']
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('foo')
})
it('should parse out formatting elements from the title', function () {
const document = '\\title{\\textbf{\\large{Second Year LaTeX Exercise}}}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('Second Year LaTeX Exercise')
})
it('should ignore junk after the title', function () {
const document = '\\title{wombat} potato'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('wombat')
})
it('should ignore junk before the title', function () {
const document =
'% this is something that v1 relied on, even though it seems odd \\title{wombat}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('wombat')
})
// NICETOHAVE: Current implementation doesn't do this
// it "should keep content that surrounds formatting elements", ->
// document = "\\title{Second Year \\large{LaTeX} Exercise}"
// expect(@DocumentHelper.getTitleFromTexContent(document)).to.equal "Second Year LaTeX Exercise"
it('should collapse whitespace', function () {
const document = '\\title{Second Year LaTeX Exercise}'
return expect(
this.DocumentHelper.getTitleFromTexContent(document)
).to.equal('Second Year LaTeX Exercise')
})
})
describe('detex', function () {
// note, there are a number of tests for getTitleFromTexContent that also test cases here
it('leaves a non-TeX string unchanged', function () {
expect(this.DocumentHelper.detex('')).to.equal('')
expect(this.DocumentHelper.detex('a')).to.equal('a')
return expect(this.DocumentHelper.detex('a a')).to.equal('a a')
})
it('collapses spaces', function () {
expect(this.DocumentHelper.detex('a a')).to.equal('a a')
return expect(this.DocumentHelper.detex('a \n a')).to.equal('a \n a')
})
it('replaces named commands', function () {
expect(this.DocumentHelper.detex('\\LaTeX')).to.equal('LaTeX')
expect(this.DocumentHelper.detex('\\TikZ')).to.equal('TikZ')
expect(this.DocumentHelper.detex('\\TeX')).to.equal('TeX')
return expect(this.DocumentHelper.detex('\\BibTeX')).to.equal('BibTeX')
})
it('removes general commands', function () {
expect(this.DocumentHelper.detex('\\foo')).to.equal('')
expect(this.DocumentHelper.detex('\\foo{}')).to.equal('')
expect(this.DocumentHelper.detex('\\foo~Test')).to.equal('Test')
expect(this.DocumentHelper.detex('\\"e')).to.equal('e')
return expect(this.DocumentHelper.detex('\\textit{e}')).to.equal('e')
})
it('leaves basic math', function () {
return expect(this.DocumentHelper.detex('$\\cal{O}(n^2)$')).to.equal(
'O(n^2)'
)
})
it('removes line spacing commands', function () {
return expect(this.DocumentHelper.detex('a \\\\[1.50cm] b')).to.equal(
'a b'
)
})
})
describe('contentHasDocumentclass', function () {
it('should return true if the content has a documentclass', function () {
const document = ['% line', '% line', '% line', '\\documentclass']
return expect(
this.DocumentHelper.contentHasDocumentclass(document)
).to.equal(true)
})
it('should allow whitespace before the documentclass', function () {
const document = ['% line', '% line', '% line', ' \\documentclass']
return expect(
this.DocumentHelper.contentHasDocumentclass(document)
).to.equal(true)
})
it('should not allow non-whitespace before the documentclass', function () {
const document = [
'% line',
'% line',
'% line',
' asdf \\documentclass',
]
return expect(
this.DocumentHelper.contentHasDocumentclass(document)
).to.equal(false)
})
it('should return false when there is no documentclass', function () {
const document = ['% line', '% line', '% line']
return expect(
this.DocumentHelper.contentHasDocumentclass(document)
).to.equal(false)
})
})
})

View File

@@ -0,0 +1,152 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import esmock from 'esmock'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
const modulePath =
'../../../../app/src/Features/Downloads/ProjectDownloadsController.mjs'
describe('ProjectDownloadsController', function () {
beforeEach(async function () {
this.project_id = 'project-id-123'
this.req = new MockRequest()
this.res = new MockResponse()
this.next = sinon.stub()
this.DocumentUpdaterHandler = sinon.stub()
return (this.ProjectDownloadsController = await esmock.strict(modulePath, {
'../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs':
(this.ProjectZipStreamManager = {}),
'../../../../app/src/Features/Project/ProjectGetter.js':
(this.ProjectGetter = {}),
'@overleaf/metrics': (this.metrics = {}),
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js':
this.DocumentUpdaterHandler,
}))
})
describe('downloadProject', function () {
beforeEach(function () {
this.stream = { pipe: sinon.stub() }
this.ProjectZipStreamManager.createZipStreamForProject = sinon
.stub()
.callsArgWith(1, null, this.stream)
this.req.params = { Project_id: this.project_id }
this.project_name = 'project name with accênts'
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(2, null, { name: this.project_name })
this.DocumentUpdaterHandler.flushProjectToMongo = sinon
.stub()
.callsArgWith(1)
this.metrics.inc = sinon.stub()
return this.ProjectDownloadsController.downloadProject(
this.req,
this.res,
this.next
)
})
it('should create a zip from the project', function () {
return this.ProjectZipStreamManager.createZipStreamForProject
.calledWith(this.project_id)
.should.equal(true)
})
it('should stream the zip to the request', function () {
return this.stream.pipe.calledWith(this.res).should.equal(true)
})
it('should set the correct content type on the request', function () {
return this.res.contentType
.calledWith('application/zip')
.should.equal(true)
})
it('should flush the project to mongo', function () {
return this.DocumentUpdaterHandler.flushProjectToMongo
.calledWith(this.project_id)
.should.equal(true)
})
it("should look up the project's name", function () {
return this.ProjectGetter.getProject
.calledWith(this.project_id, { name: true })
.should.equal(true)
})
it('should name the downloaded file after the project', function () {
this.res.headers.should.deep.equal({
'Content-Disposition': `attachment; filename="${this.project_name}.zip"`,
'Content-Type': 'application/zip',
'X-Content-Type-Options': 'nosniff',
})
})
it('should record the action via Metrics', function () {
return this.metrics.inc.calledWith('zip-downloads').should.equal(true)
})
})
describe('downloadMultipleProjects', function () {
beforeEach(function () {
this.stream = { pipe: sinon.stub() }
this.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
.stub()
.callsArgWith(1, null, this.stream)
this.project_ids = ['project-1', 'project-2']
this.req.query = { project_ids: this.project_ids.join(',') }
this.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon
.stub()
.callsArgWith(1)
this.metrics.inc = sinon.stub()
return this.ProjectDownloadsController.downloadMultipleProjects(
this.req,
this.res,
this.next
)
})
it('should create a zip from the project', function () {
return this.ProjectZipStreamManager.createZipStreamForMultipleProjects
.calledWith(this.project_ids)
.should.equal(true)
})
it('should stream the zip to the request', function () {
return this.stream.pipe.calledWith(this.res).should.equal(true)
})
it('should set the correct content type on the request', function () {
return this.res.contentType
.calledWith('application/zip')
.should.equal(true)
})
it('should flush the projects to mongo', function () {
return this.DocumentUpdaterHandler.flushMultipleProjectsToMongo
.calledWith(this.project_ids)
.should.equal(true)
})
it('should name the downloaded file after the project', function () {
this.res.headers.should.deep.equal({
'Content-Disposition':
'attachment; filename="Overleaf Projects (2 items).zip"',
'Content-Type': 'application/zip',
'X-Content-Type-Options': 'nosniff',
})
})
it('should record the action via Metrics', function () {
return this.metrics.inc
.calledWith('zip-downloads-multiple')
.should.equal(true)
})
})
})

View File

@@ -0,0 +1,477 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS201: Simplify complex destructure assignments
* DS205: Consider reworking code to avoid use of IIFEs
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import esmock from 'esmock'
import { EventEmitter } from 'events'
const modulePath =
'../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs'
describe('ProjectZipStreamManager', function () {
beforeEach(async function () {
this.project_id = 'project-id-123'
this.callback = sinon.stub()
this.archive = {
on() {},
append: sinon.stub(),
}
this.logger = {
error: sinon.stub(),
info: sinon.stub(),
debug: sinon.stub(),
}
return (this.ProjectZipStreamManager = await esmock.strict(modulePath, {
archiver: (this.archiver = sinon.stub().returns(this.archive)),
'@overleaf/logger': this.logger,
'../../../../app/src/Features/Project/ProjectEntityHandler':
(this.ProjectEntityHandler = {}),
'../../../../app/src/Features/History/HistoryManager.js':
(this.HistoryManager = {}),
'../../../../app/src/Features/Project/ProjectGetter':
(this.ProjectGetter = {}),
'../../../../app/src/Features/FileStore/FileStoreHandler':
(this.FileStoreHandler = {}),
'../../../../app/src/infrastructure/Features': (this.Features = {
hasFeature: sinon
.stub()
.withArgs('project-history-blobs')
.returns(true),
}),
}))
})
describe('createZipStreamForMultipleProjects', function () {
describe('successfully', function () {
beforeEach(function (done) {
this.project_ids = ['project-1', 'project-2']
this.zip_streams = {
'project-1': new EventEmitter(),
'project-2': new EventEmitter(),
}
this.project_names = {
'project-1': 'Project One Name',
'project-2': 'Project Two Name',
}
this.ProjectZipStreamManager.createZipStreamForProject = (
projectId,
callback
) => {
callback(null, this.zip_streams[projectId])
setTimeout(() => {
return this.zip_streams[projectId].emit('end')
})
return 0
}
sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject')
this.ProjectGetter.getProject = (projectId, fields, callback) => {
return callback(null, { name: this.project_names[projectId] })
}
sinon.spy(this.ProjectGetter, 'getProject')
this.ProjectZipStreamManager.createZipStreamForMultipleProjects(
this.project_ids,
(...args) => {
return this.callback(...Array.from(args || []))
}
)
return (this.archive.finalize = () => done())
})
it('should create a zip archive', function () {
return this.archiver.calledWith('zip').should.equal(true)
})
it('should return a stream before any processing is done', function () {
this.callback
.calledWith(sinon.match.falsy, this.archive)
.should.equal(true)
return this.callback
.calledBefore(this.ProjectZipStreamManager.createZipStreamForProject)
.should.equal(true)
})
it('should get a zip stream for all of the projects', function () {
return Array.from(this.project_ids).map(projectId =>
this.ProjectZipStreamManager.createZipStreamForProject
.calledWith(projectId)
.should.equal(true)
)
})
it('should get the names of each project', function () {
return Array.from(this.project_ids).map(projectId =>
this.ProjectGetter.getProject
.calledWith(projectId, { name: true })
.should.equal(true)
)
})
it('should add all of the projects to the zip', function () {
return Array.from(this.project_ids).map(projectId =>
this.archive.append
.calledWith(this.zip_streams[projectId], {
name: this.project_names[projectId] + '.zip',
})
.should.equal(true)
)
})
})
describe('with a project not existing', function () {
beforeEach(function (done) {
this.project_ids = ['project-1', 'wrong-id']
this.project_names = {
'project-1': 'Project One Name',
}
this.zip_streams = {
'project-1': new EventEmitter(),
}
this.ProjectZipStreamManager.createZipStreamForProject = (
projectId,
callback
) => {
callback(null, this.zip_streams[projectId])
setTimeout(() => {
this.zip_streams[projectId].emit('end')
})
}
sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject')
this.ProjectGetter.getProject = (projectId, fields, callback) => {
const name = this.project_names[projectId]
callback(null, name ? { name } : undefined)
}
sinon.spy(this.ProjectGetter, 'getProject')
this.ProjectZipStreamManager.createZipStreamForMultipleProjects(
this.project_ids,
this.callback
)
this.archive.finalize = () => done()
})
it('should create a zip archive', function () {
this.archiver.calledWith('zip').should.equal(true)
})
it('should return a stream before any processing is done', function () {
this.callback
.calledWith(sinon.match.falsy, this.archive)
.should.equal(true)
this.callback
.calledBefore(this.ProjectZipStreamManager.createZipStreamForProject)
.should.equal(true)
})
it('should get the names of each project', function () {
this.project_ids.map(projectId =>
this.ProjectGetter.getProject
.calledWith(projectId, { name: true })
.should.equal(true)
)
})
it('should get a zip stream only for the existing project', function () {
this.ProjectZipStreamManager.createZipStreamForProject
.calledWith('project-1')
.should.equal(true)
this.ProjectZipStreamManager.createZipStreamForProject
.calledWith('wrong-id')
.should.equal(false)
})
it('should only add the existing project to the zip', function () {
sinon.assert.calledOnce(this.archive.append)
this.archive.append
.calledWith(this.zip_streams['project-1'], {
name: this.project_names['project-1'] + '.zip',
})
.should.equal(true)
})
})
})
describe('createZipStreamForProject', function () {
describe('successfully', function () {
beforeEach(function () {
this.ProjectZipStreamManager.addAllDocsToArchive = sinon
.stub()
.callsArg(2)
this.ProjectZipStreamManager.addAllFilesToArchive = sinon
.stub()
.callsArg(2)
this.archive.finalize = sinon.stub()
return this.ProjectZipStreamManager.createZipStreamForProject(
this.project_id,
this.callback
)
})
it('should create a zip archive', function () {
return this.archiver.calledWith('zip').should.equal(true)
})
it('should return a stream before any processing is done', function () {
this.callback
.calledWith(sinon.match.falsy, this.archive)
.should.equal(true)
this.callback
.calledBefore(this.ProjectZipStreamManager.addAllDocsToArchive)
.should.equal(true)
return this.callback
.calledBefore(this.ProjectZipStreamManager.addAllFilesToArchive)
.should.equal(true)
})
it('should add all of the project docs to the zip', function () {
return this.ProjectZipStreamManager.addAllDocsToArchive
.calledWith(this.project_id, this.archive)
.should.equal(true)
})
it('should add all of the project files to the zip', function () {
return this.ProjectZipStreamManager.addAllFilesToArchive
.calledWith(this.project_id, this.archive)
.should.equal(true)
})
it('should finalise the stream', function () {
return this.archive.finalize.called.should.equal(true)
})
})
describe('with an error adding docs', function () {
beforeEach(function () {
this.ProjectZipStreamManager.addAllDocsToArchive = sinon
.stub()
.callsArgWith(2, new Error('something went wrong'))
this.ProjectZipStreamManager.addAllFilesToArchive = sinon
.stub()
.callsArg(2)
this.archive.finalize = sinon.stub()
this.ProjectZipStreamManager.createZipStreamForProject(
this.project_id,
this.callback
)
})
it('should log out an error', function () {
return this.logger.error
.calledWith(sinon.match.any, 'error adding docs to zip stream')
.should.equal(true)
})
it('should continue with the process', function () {
this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
true
)
this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
true
)
return this.archive.finalize.called.should.equal(true)
})
})
describe('with an error adding files', function () {
beforeEach(function () {
this.ProjectZipStreamManager.addAllDocsToArchive = sinon
.stub()
.callsArg(2)
this.ProjectZipStreamManager.addAllFilesToArchive = sinon
.stub()
.callsArgWith(2, new Error('something went wrong'))
this.archive.finalize = sinon.stub()
return this.ProjectZipStreamManager.createZipStreamForProject(
this.project_id,
this.callback
)
})
it('should log out an error', function () {
return this.logger.error
.calledWith(sinon.match.any, 'error adding files to zip stream')
.should.equal(true)
})
it('should continue with the process', function () {
this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
true
)
this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
true
)
return this.archive.finalize.called.should.equal(true)
})
})
})
describe('addAllDocsToArchive', function () {
beforeEach(function (done) {
this.docs = {
'/main.tex': {
lines: [
'\\documentclass{article}',
'\\begin{document}',
'Hello world',
'\\end{document}',
],
},
'/chapters/chapter1.tex': {
lines: ['chapter1', 'content'],
},
}
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
return this.ProjectZipStreamManager.addAllDocsToArchive(
this.project_id,
this.archive,
error => {
this.callback(error)
return done()
}
)
})
it('should get the docs for the project', function () {
return this.ProjectEntityHandler.getAllDocs
.calledWith(this.project_id)
.should.equal(true)
})
it('should add each doc to the archive', function () {
return (() => {
const result = []
for (let path in this.docs) {
const doc = this.docs[path]
path = path.slice(1) // remove "/"
result.push(
this.archive.append
.calledWith(doc.lines.join('\n'), { name: path })
.should.equal(true)
)
}
return result
})()
})
})
describe('addAllFilesToArchive', function () {
beforeEach(function () {
this.files = {
'/image.png': {
_id: 'file-id-1',
hash: 'abc',
},
'/folder/picture.png': {
_id: 'file-id-2',
hash: 'def',
},
}
this.streams = {
'file-id-1': new EventEmitter(),
'file-id-2': new EventEmitter(),
}
this.ProjectEntityHandler.getAllFiles = sinon
.stub()
.callsArgWith(1, null, this.files)
})
describe('with project-history-blobs feature enabled', function () {
beforeEach(function () {
this.HistoryManager.requestBlobWithFallback = (
projectId,
hash,
fileId,
callback
) => {
return callback(null, { stream: this.streams[fileId] })
}
sinon.spy(this.HistoryManager, 'requestBlobWithFallback')
this.ProjectZipStreamManager.addAllFilesToArchive(
this.project_id,
this.archive,
this.callback
)
for (const path in this.streams) {
const stream = this.streams[path]
stream.emit('end')
}
})
it('should get the files for the project', function () {
return this.ProjectEntityHandler.getAllFiles
.calledWith(this.project_id)
.should.equal(true)
})
it('should get a stream for each file', function () {
for (const path in this.files) {
const file = this.files[path]
this.HistoryManager.requestBlobWithFallback
.calledWith(this.project_id, file.hash, file._id)
.should.equal(true)
}
})
it('should add each file to the archive', function () {
for (let path in this.files) {
const file = this.files[path]
path = path.slice(1) // remove "/"
this.archive.append
.calledWith(this.streams[file._id], { name: path })
.should.equal(true)
}
})
})
describe('with project-history-blobs feature disabled', function () {
beforeEach(function () {
this.FileStoreHandler.getFileStream = (
projectId,
fileId,
query,
callback
) => callback(null, this.streams[fileId])
sinon.spy(this.FileStoreHandler, 'getFileStream')
this.Features.hasFeature
.withArgs('project-history-blobs')
.returns(false)
this.ProjectZipStreamManager.addAllFilesToArchive(
this.project_id,
this.archive,
this.callback
)
for (const path in this.streams) {
const stream = this.streams[path]
stream.emit('end')
}
})
it('should get a stream for each file', function () {
for (const path in this.files) {
const file = this.files[path]
this.FileStoreHandler.getFileStream
.calledWith(this.project_id, file._id)
.should.equal(true)
}
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
/* eslint-disable mocha/handle-done-callback */
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const MODULE_PATH = '../../../../app/src/Features/Editor/EditorHttpController'
describe('EditorHttpController', function () {
beforeEach(function () {
this.ownerId = new ObjectId()
this.project = {
_id: new ObjectId(),
owner_ref: this.ownerId,
}
this.user = {
_id: new ObjectId(),
projects: {},
}
this.projectView = {
_id: this.project._id,
owner: {
_id: 'owner',
email: 'owner@example.com',
other_property: true,
},
members: [{ one: 1 }, { two: 2 }],
invites: [{ three: 3 }, { four: 4 }],
}
this.reducedProjectView = {
_id: this.projectView._id,
owner: { _id: this.projectView.owner._id },
members: [],
invites: [],
}
this.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' }
this.file = { _id: new ObjectId() }
this.folder = { _id: new ObjectId() }
this.source = 'editor'
this.parentFolderId = 'mock-folder-id'
this.req = new MockRequest()
this.res = new MockResponse()
this.next = sinon.stub()
this.token = null
this.docLines = ['hello', 'overleaf']
this.AuthorizationManager = {
isRestrictedUser: sinon.stub().returns(false),
promises: {
getPrivilegeLevelForProject: sinon.stub().resolves('owner'),
},
}
this.CollaboratorsGetter = {
promises: {
getInvitedMembersWithPrivilegeLevels: sinon
.stub()
.resolves(['members', 'mock']),
isUserInvitedMemberOfProject: sinon.stub().resolves(false),
},
}
this.CollaboratorsHandler = {
promises: {
userIsTokenMember: sinon.stub().resolves(false),
},
}
this.CollaboratorsInviteGetter = {
promises: {
getAllInvites: sinon.stub().resolves([
{
_id: 'invite_one',
email: 'user-one@example.com',
privileges: 'readOnly',
projectId: this.project._id,
},
{
_id: 'invite_two',
email: 'user-two@example.com',
privileges: 'readOnly',
projectId: this.project._id,
},
]),
},
}
this.EditorController = {
promises: {
addDoc: sinon.stub().resolves(this.doc),
addFile: sinon.stub().resolves(this.file),
addFolder: sinon.stub().resolves(this.folder),
renameEntity: sinon.stub().resolves(),
moveEntity: sinon.stub().resolves(),
deleteEntity: sinon.stub().resolves(),
},
}
this.ProjectDeleter = {
promises: {
unmarkAsDeletedByExternalSource: sinon.stub().resolves(),
},
}
this.ProjectGetter = {
promises: {
getProjectWithoutDocLines: sinon.stub().resolves(this.project),
},
}
this.ProjectEditorHandler = {
buildProjectModelView: sinon.stub().returns(this.projectView),
}
this.Metrics = { inc: sinon.stub() }
this.TokenAccessHandler = {
getRequestToken: sinon.stub().returns(this.token),
}
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user._id),
}
this.ProjectEntityUpdateHandler = {
promises: {
convertDocToFile: sinon.stub().resolves(this.file),
},
}
this.DocstoreManager = {
promises: {
getAllDeletedDocs: sinon.stub().resolves([]),
},
}
this.HttpErrorHandler = {
notFound: sinon.stub(),
unprocessableEntity: sinon.stub(),
}
this.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
this.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } }
this.EditorHttpController = SandboxedModule.require(MODULE_PATH, {
requires: {
'../Project/ProjectDeleter': this.ProjectDeleter,
'../Project/ProjectGetter': this.ProjectGetter,
'../Authorization/AuthorizationManager': this.AuthorizationManager,
'../Project/ProjectEditorHandler': this.ProjectEditorHandler,
'./EditorController': this.EditorController,
'@overleaf/metrics': this.Metrics,
'../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
'../Collaborators/CollaboratorsInviteGetter':
this.CollaboratorsInviteGetter,
'../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
'../Authentication/SessionManager': this.SessionManager,
'../../infrastructure/FileWriter': this.FileWriter,
'../Project/ProjectEntityUpdateHandler':
this.ProjectEntityUpdateHandler,
'../Docstore/DocstoreManager': this.DocstoreManager,
'../Errors/HttpErrorHandler': this.HttpErrorHandler,
'../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../Compile/CompileManager': {},
'../User/UserGetter': this.UserGetter,
},
})
})
describe('joinProject', function () {
beforeEach(function () {
this.req.params = { Project_id: this.project._id }
this.req.query = { user_id: this.user._id }
this.req.body = { userId: this.user._id }
})
describe('successfully', function () {
beforeEach(function (done) {
this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
true
)
this.res.callback = done
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should return the project and privilege level', function () {
expect(this.res.json).to.have.been.calledWith({
project: this.projectView,
privilegeLevel: 'owner',
isRestrictedUser: false,
isTokenMember: false,
isInvitedMember: true,
})
})
it('should not try to unmark the project as deleted', function () {
expect(this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not
.to.have.been.called
})
it('should send an inc metric', function () {
expect(this.Metrics.inc).to.have.been.calledWith('editor.join-project')
})
})
describe('when the project is marked as deleted', function () {
beforeEach(function (done) {
this.projectView.deletedByExternalDataSource = true
this.res.callback = done
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should unmark the project as deleted', function () {
expect(
this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource
).to.have.been.calledWith(this.project._id)
})
})
describe('with a restricted user', function () {
beforeEach(function (done) {
this.AuthorizationManager.isRestrictedUser.returns(true)
this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
'readOnly'
)
this.res.callback = done
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should mark the user as restricted, and hide details of owner', function () {
expect(this.res.json).to.have.been.calledWith({
project: this.reducedProjectView,
privilegeLevel: 'readOnly',
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
})
})
})
describe('when not authorized', function () {
beforeEach(function (done) {
this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
null
)
this.res.callback = done
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should send a 403 response', function () {
expect(this.res.statusCode).to.equal(403)
})
})
describe('with an anonymous user', function () {
beforeEach(function (done) {
this.token = 'token'
this.TokenAccessHandler.getRequestToken.returns(this.token)
this.req.body = {
userId: 'anonymous-user',
anonymousAccessToken: this.token,
}
this.res.callback = done
this.AuthorizationManager.isRestrictedUser
.withArgs(null, 'readOnly', false, false)
.returns(true)
this.AuthorizationManager.promises.getPrivilegeLevelForProject
.withArgs(null, this.project._id, this.token)
.resolves('readOnly')
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should mark the user as restricted', function () {
expect(this.res.json).to.have.been.calledWith({
project: this.reducedProjectView,
privilegeLevel: 'readOnly',
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
})
})
})
describe('with a token access user', function () {
beforeEach(function (done) {
this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
false
)
this.CollaboratorsHandler.promises.userIsTokenMember.resolves(true)
this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
'readAndWrite'
)
this.res.callback = done
this.EditorHttpController.joinProject(this.req, this.res)
})
it('should mark the user as being a token-access member', function () {
expect(this.res.json).to.have.been.calledWith({
project: this.projectView,
privilegeLevel: 'readAndWrite',
isRestrictedUser: false,
isTokenMember: true,
isInvitedMember: false,
})
})
})
describe('when project is not found', function () {
beforeEach(function (done) {
this.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null)
this.next.callsFake(() => done())
this.EditorHttpController.joinProject(this.req, this.res, this.next)
})
it('should handle return not found error', function () {
expect(this.next).to.have.been.calledWith(
sinon.match.instanceOf(Errors.NotFoundError)
)
})
})
})
describe('addDoc', function () {
beforeEach(function () {
this.req.params = { Project_id: this.project._id }
this.req.body = {
name: (this.name = 'doc-name'),
parent_folder_id: this.parentFolderId,
}
})
describe('successfully', function () {
beforeEach(function (done) {
this.res.callback = done
this.EditorHttpController.addDoc(this.req, this.res)
})
it('should call EditorController.addDoc', function () {
expect(this.EditorController.promises.addDoc).to.have.been.calledWith(
this.project._id,
this.parentFolderId,
this.name,
[],
'editor',
this.user._id
)
})
it('should send the doc back as JSON', function () {
expect(this.res.json).to.have.been.calledWith(this.doc)
})
})
describe('unsuccesfully', function () {
it('handle name too short', function (done) {
this.req.body.name = ''
this.res.callback = () => {
expect(this.res.statusCode).to.equal(400)
done()
}
this.EditorHttpController.addDoc(this.req, this.res)
})
it('handle too many files', function (done) {
this.EditorController.promises.addDoc.rejects(
new Error('project_has_too_many_files')
)
this.res.callback = () => {
expect(this.res.body).to.equal('"project_has_too_many_files"')
expect(this.res.status).to.have.been.calledWith(400)
done()
}
this.EditorHttpController.addDoc(this.req, this.res)
})
})
})
describe('addFolder', function () {
beforeEach(function () {
this.folderName = 'folder-name'
this.req.params = { Project_id: this.project._id }
this.req.body = {
name: this.folderName,
parent_folder_id: this.parentFolderId,
}
})
describe('successfully', function () {
beforeEach(function (done) {
this.res.callback = done
this.EditorHttpController.addFolder(this.req, this.res)
})
it('should call EditorController.addFolder', function () {
expect(
this.EditorController.promises.addFolder
).to.have.been.calledWith(
this.project._id,
this.parentFolderId,
this.folderName,
'editor'
)
})
it('should send the folder back as JSON', function () {
expect(this.res.json).to.have.been.calledWith(this.folder)
})
})
describe('unsuccesfully', function () {
it('handle name too short', function (done) {
this.req.body.name = ''
this.res.callback = () => {
expect(this.res.statusCode).to.equal(400)
done()
}
this.EditorHttpController.addFolder(this.req, this.res)
})
it('handle too many files', function (done) {
this.EditorController.promises.addFolder.rejects(
new Error('project_has_too_many_files')
)
this.res.callback = () => {
expect(this.res.body).to.equal('"project_has_too_many_files"')
expect(this.res.statusCode).to.equal(400)
done()
}
this.EditorHttpController.addFolder(this.req, this.res)
})
it('handle invalid element name', function (done) {
this.EditorController.promises.addFolder.rejects(
new Error('invalid element name')
)
this.res.callback = () => {
expect(this.res.body).to.equal('"invalid_file_name"')
expect(this.res.statusCode).to.equal(400)
done()
}
this.EditorHttpController.addFolder(this.req, this.res)
})
})
})
describe('renameEntity', function () {
beforeEach(function () {
this.entityId = 'entity-id-123'
this.entityType = 'entity-type'
this.req.params = {
Project_id: this.project._id,
entity_id: this.entityId,
entity_type: this.entityType,
}
})
describe('successfully', function () {
beforeEach(function (done) {
this.newName = 'new-name'
this.req.body = { name: this.newName, source: this.source }
this.res.callback = done
this.EditorHttpController.renameEntity(this.req, this.res)
})
it('should call EditorController.renameEntity', function () {
expect(
this.EditorController.promises.renameEntity
).to.have.been.calledWith(
this.project._id,
this.entityId,
this.entityType,
this.newName,
this.user._id,
this.source
)
})
it('should send back a success response', function () {
expect(this.res.sendStatus).to.have.been.calledWith(204)
})
})
describe('with long name', function () {
beforeEach(function () {
this.newName = 'long'.repeat(100)
this.req.body = { name: this.newName, source: this.source }
this.EditorHttpController.renameEntity(this.req, this.res)
})
it('should send back a bad request status code', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('with 0 length name', function () {
beforeEach(function () {
this.newName = ''
this.req.body = { name: this.newName, source: this.source }
this.EditorHttpController.renameEntity(this.req, this.res)
})
it('should send back a bad request status code', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
describe('moveEntity', function () {
beforeEach(function (done) {
this.entityId = 'entity-id-123'
this.entityType = 'entity-type'
this.folderId = 'folder-id-123'
this.req.params = {
Project_id: this.project._id,
entity_id: this.entityId,
entity_type: this.entityType,
}
this.req.body = { folder_id: this.folderId, source: this.source }
this.res.callback = done
this.EditorHttpController.moveEntity(this.req, this.res)
})
it('should call EditorController.moveEntity', function () {
expect(this.EditorController.promises.moveEntity).to.have.been.calledWith(
this.project._id,
this.entityId,
this.folderId,
this.entityType,
this.user._id,
this.source
)
})
it('should send back a success response', function () {
expect(this.res.statusCode).to.equal(204)
})
})
describe('deleteEntity', function () {
beforeEach(function (done) {
this.entityId = 'entity-id-123'
this.entityType = 'entity-type'
this.req.params = {
Project_id: this.project._id,
entity_id: this.entityId,
entity_type: this.entityType,
}
this.res.callback = done
this.EditorHttpController.deleteEntity(this.req, this.res)
})
it('should call EditorController.deleteEntity', function () {
expect(
this.EditorController.promises.deleteEntity
).to.have.been.calledWith(
this.project._id,
this.entityId,
this.entityType,
'editor',
this.user._id
)
})
it('should send back a success response', function () {
expect(this.res.statusCode).to.equal(204)
})
})
})

View File

@@ -0,0 +1,103 @@
/* eslint-disable
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Editor/EditorRealTimeController'
)
describe('EditorRealTimeController', function () {
beforeEach(function () {
this.rclient = { publish: sinon.stub() }
this.Metrics = { summary: sinon.stub() }
this.EditorRealTimeController = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/RedisWrapper': {
client: () => this.rclient,
},
'../../infrastructure/Server': {
io: (this.io = {}),
},
'@overleaf/settings': { redis: {} },
'@overleaf/metrics': this.Metrics,
crypto: (this.crypto = {
randomBytes: sinon
.stub()
.withArgs(4)
.returns(Buffer.from([0x1, 0x2, 0x3, 0x4])),
}),
os: (this.os = { hostname: sinon.stub().returns('somehost') }),
},
})
this.room_id = 'room-id'
this.message = 'message-to-editor'
return (this.payload = ['argument one', 42])
})
describe('emitToRoom', function () {
beforeEach(function () {
this.message_id = 'web:somehost:01020304-0'
return this.EditorRealTimeController.emitToRoom(
this.room_id,
this.message,
...Array.from(this.payload)
)
})
it('should publish the message to redis', function () {
return this.rclient.publish
.calledWith(
'editor-events',
JSON.stringify({
room_id: this.room_id,
message: this.message,
payload: this.payload,
_id: this.message_id,
})
)
.should.equal(true)
})
it('should track the payload size', function () {
this.Metrics.summary
.calledWith(
'redis.publish.editor-events',
JSON.stringify({
room_id: this.room_id,
message: this.message,
payload: this.payload,
_id: this.message_id,
}).length
)
.should.equal(true)
})
})
describe('emitToAll', function () {
beforeEach(function () {
this.EditorRealTimeController.emitToRoom = sinon.stub()
return this.EditorRealTimeController.emitToAll(
this.message,
...Array.from(this.payload)
)
})
it("should emit to the room 'all'", function () {
return this.EditorRealTimeController.emitToRoom
.calledWith('all', this.message, ...Array.from(this.payload))
.should.equal(true)
})
})
})

View File

@@ -0,0 +1,823 @@
const SandboxedModule = require('sandboxed-module')
const cheerio = require('cheerio')
const path = require('path')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailBuilder'
)
const EmailMessageHelper = require('../../../../app/src/Features/Email/EmailMessageHelper')
const ctaEmailBody = require('../../../../app/src/Features/Email/Bodies/cta-email')
const NoCTAEmailBody = require('../../../../app/src/Features/Email/Bodies/NoCTAEmailBody')
const BaseWithHeaderEmailLayout = require('../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout')
describe('EmailBuilder', function () {
before(function () {
this.settings = {
appName: 'testApp',
siteUrl: 'https://www.overleaf.com',
}
this.EmailBuilder = SandboxedModule.require(MODULE_PATH, {
requires: {
'./EmailMessageHelper': EmailMessageHelper,
'./Bodies/cta-email': ctaEmailBody,
'./Bodies/NoCTAEmailBody': NoCTAEmailBody,
'./Layouts/BaseWithHeaderEmailLayout': BaseWithHeaderEmailLayout,
'@overleaf/settings': this.settings,
},
})
})
describe('projectInvite', function () {
beforeEach(function () {
this.opts = {
to: 'bob@bob.com',
first_name: 'bob',
owner: {
email: 'sally@hally.com',
},
inviteUrl: 'http://example.com/invite',
project: {
url: 'http://www.project.com',
name: 'standard project',
},
}
})
describe('when sending a normal email', function () {
beforeEach(function () {
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
})
it('should have html and text properties', function () {
expect(this.email.html != null).to.equal(true)
expect(this.email.text != null).to.equal(true)
})
it('should not have undefined in it', function () {
this.email.html.indexOf('undefined').should.equal(-1)
this.email.subject.indexOf('undefined').should.equal(-1)
})
})
describe('when someone is up to no good', function () {
it('should not contain the project name at all if unsafe', function () {
this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain('evilsite.com')
expect(this.email.subject).to.not.contain('evilsite.com')
// but email should appear
expect(this.email.html).to.contain(this.opts.owner.email)
expect(this.email.subject).to.contain(this.opts.owner.email)
})
it('should not contain the inviter email at all if unsafe', function () {
this.opts.owner.email =
'verylongemailaddressthatwillfailthecheck@longdomain.domain'
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain(this.opts.owner.email)
expect(this.email.subject).to.not.contain(this.opts.owner.email)
// but title should appear
expect(this.email.html).to.contain(this.opts.project.name)
expect(this.email.subject).to.contain(this.opts.project.name)
})
it('should handle both email and title being unsafe', function () {
this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
this.opts.owner.email =
'verylongemailaddressthatwillfailthecheck@longdomain.domain'
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain('evilsite.com')
expect(this.email.subject).to.not.contain('evilsite.com')
expect(this.email.html).to.not.contain(this.opts.owner.email)
expect(this.email.subject).to.not.contain(this.opts.owner.email)
expect(this.email.html).to.contain(
'Please view the project to find out more'
)
})
})
})
describe('SpamSafe', function () {
beforeEach(function () {
this.opts = {
to: 'bob@joe.com',
first_name: 'bob',
newOwner: {
email: 'sally@hally.com',
},
inviteUrl: 'http://example.com/invite',
project: {
url: 'http://www.project.com',
name: 'come buy my product at http://notascam.com',
},
}
this.email = this.EmailBuilder.buildEmail(
'ownershipTransferConfirmationPreviousOwner',
this.opts
)
})
it('should replace spammy project name', function () {
this.email.html.indexOf('your project').should.not.equal(-1)
})
})
describe('ctaTemplate', function () {
describe('missing required content', function () {
const content = {
title: () => {},
greeting: () => {},
message: () => {},
secondaryMessage: () => {},
ctaText: () => {},
ctaURL: () => {},
gmailGoToAction: () => {},
}
it('should throw an error when missing title', function () {
const { title, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing message', function () {
const { message, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing ctaText', function () {
const { ctaText, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing ctaURL', function () {
const { ctaURL, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
})
})
describe('templates', function () {
describe('CTA', function () {
describe('canceledSubscription', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'canceledSubscription',
this.opts
)
this.expectedUrl =
'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("Leave Feedback")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.expectedUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.expectedUrl)
})
})
})
describe('confirmEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.userId = 'abc123'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: this.userId,
}
this.email = this.EmailBuilder.buildEmail('confirmEmail', this.opts)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("Confirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
})
})
describe('ownershipTransferConfirmationNewOwner', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
previousOwner: {},
project: {
_id: 'abc123',
name: 'example project',
},
}
this.email = this.EmailBuilder.buildEmail(
'ownershipTransferConfirmationNewOwner',
this.opts
)
this.expectedUrl = `${
this.settings.siteUrl
}/project/${this.opts.project._id.toString()}`
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('td a')
expect(buttonLink).to.exist
expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback).to.exist
const fallbackLink = fallback.html().replace(/&amp;/g, '&')
expect(fallbackLink).to.contain(this.expectedUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.expectedUrl)
})
})
})
describe('passwordResetRequested', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
setNewPasswordUrl: `${
this.settings.siteUrl
}/user/password/set?passwordResetToken=aToken&email=${encodeURIComponent(
this.emailAddress
)}`,
}
this.email = this.EmailBuilder.buildEmail(
'passwordResetRequested',
this.opts
)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('td a')
expect(buttonLink).to.exist
expect(buttonLink.attr('href')).to.equal(
this.opts.setNewPasswordUrl
)
const fallback = dom('.force-overleaf-style').last()
expect(fallback).to.exist
const fallbackLink = fallback.html().replace(/&amp;/g, '&')
expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.setNewPasswordUrl)
})
})
})
describe('reconfirmEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.userId = 'abc123'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: this.userId,
}
this.email = this.EmailBuilder.buildEmail('reconfirmEmail', this.opts)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("Reconfirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
})
})
describe('verifyEmailToJoinTeam', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
acceptInviteUrl: `${this.settings.siteUrl}/subscription/invites/aToken123/`,
inviter: {
email: 'deanna@overleaf.com',
first_name: 'Deanna',
last_name: 'Troi',
},
}
this.email = this.EmailBuilder.buildEmail(
'verifyEmailToJoinTeam',
this.opts
)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("Join now")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.acceptInviteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.acceptInviteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.acceptInviteUrl)
})
})
})
describe('reactivatedSubscription', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'reactivatedSubscription',
this.opts
)
this.expectedUrl = `${this.settings.siteUrl}/user/subscription`
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("View Subscription Dashboard")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.expectedUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.expectedUrl)
})
})
})
describe('testEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail('testEmail', this.opts)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom(
`a:contains("Open ${this.settings.appName}")`
)
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.settings.siteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.settings.siteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(
`Open ${this.settings.appName}: ${this.settings.siteUrl}`
)
})
})
})
describe('registered', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
setNewPasswordUrl: `${this.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`,
}
this.email = this.EmailBuilder.buildEmail('registered', this.opts)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("Set password")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(
this.opts.setNewPasswordUrl
)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html().replace(/&amp;/, '&')
expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.setNewPasswordUrl)
})
})
})
describe('projectInvite', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.owner = {
email: 'owner@example.com',
name: 'Bailey',
}
this.projectName = 'Top Secret'
this.opts = {
inviteUrl: `${this.settings.siteUrl}/project/projectId123/invite/token/aToken123`,
owner: {
email: this.owner.email,
},
project: {
name: this.projectName,
},
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const dom = cheerio.load(this.email.html)
const buttonLink = dom('a:contains("View project")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.inviteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html().replace(/&amp;/g, '&')
expect(fallbackLink).to.contain(this.opts.inviteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.inviteUrl)
})
})
})
describe('welcome', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=token123`,
}
this.email = this.EmailBuilder.buildEmail('welcome', this.opts)
this.dom = cheerio.load(this.email.html)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function () {
const buttonLink = this.dom('a:contains("Confirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = this.dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
expect(fallback.html()).to.contain(this.opts.confirmEmailUrl)
})
it('should include help links', function () {
const helpGuidesLink = this.dom('a:contains("Help Guides")')
const templatesLink = this.dom('a:contains("Templates")')
const logInLink = this.dom('a:contains("log in")')
expect(helpGuidesLink.length).to.equal(1)
expect(templatesLink.length).to.equal(1)
expect(logInLink.length).to.equal(1)
})
})
describe('plain text email', function () {
it('should contain the CTA URL', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
it('should include help URL', function () {
expect(this.email.text).to.contain('/learn')
expect(this.email.text).to.contain('/login')
expect(this.email.text).to.contain('/templates')
})
it('should contain HTML links', function () {
expect(this.email.text).to.not.contain('<a')
})
})
})
describe('groupSSODisabled', function () {
it('should build the email for non managed and linked users', function () {
const setNewPasswordUrl = `${this.settings.siteUrl}/user/password/reset`
const emailAddress = 'example@overleaf.com'
const opts = {
to: emailAddress,
setNewPasswordUrl,
userIsManaged: false,
}
const email = this.EmailBuilder.buildEmail('groupSSODisabled', opts)
expect(email.subject).to.equal(
'A change to your Overleaf login options'
)
const dom = cheerio.load(email.html)
expect(email.html).to.exist
expect(email.html).to.contain(
'Your group administrator has disabled single sign-on for your group.'
)
expect(email.html).to.contain(
'You can still log in to Overleaf using one of our other'
)
const links = dom('a')
expect(links[0].attribs.href).to.equal(
`${this.settings.siteUrl}/login`
)
expect(links[1].attribs.href).to.equal(setNewPasswordUrl)
expect(email.html).to.contain(
"If you don't have a password, you can set one now."
)
expect(email.text).to.exist
const expectedPlainText = [
'Hi,',
'',
'Your group administrator has disabled single sign-on for your group.',
'',
'',
'',
'What does this mean for you?',
'',
'You can still log in to Overleaf using one of our other login options or with your email address and password.',
'',
"If you don't have a password, you can set one now.",
'',
`Set your new password: ${setNewPasswordUrl}`,
'',
'',
'',
'Regards,',
`The ${this.settings.appName} Team - ${this.settings.siteUrl}`,
]
expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
})
it('should build the email for managed and linked users', function () {
const emailAddress = 'example@overleaf.com'
const setNewPasswordUrl = `${this.settings.siteUrl}/user/password/reset`
const opts = {
to: emailAddress,
setNewPasswordUrl,
userIsManaged: true,
}
const email = this.EmailBuilder.buildEmail('groupSSODisabled', opts)
expect(email.subject).to.equal(
'Action required: Set your Overleaf password'
)
const dom = cheerio.load(email.html)
expect(email.html).to.exist
expect(email.html).to.contain(
'Your group administrator has disabled single sign-on for your group.'
)
expect(email.html).to.contain(
'You now need an email address and password to sign in to your Overleaf account.'
)
const links = dom('a')
expect(links[0].attribs.href).to.equal(
`${this.settings.siteUrl}/user/password/reset`
)
expect(email.text).to.exist
const expectedPlainText = [
'Hi,',
'',
'Your group administrator has disabled single sign-on for your group.',
'',
'',
'',
'What does this mean for you?',
'',
'You now need an email address and password to sign in to your Overleaf account.',
'',
`Set your new password: ${setNewPasswordUrl}`,
'',
'',
'',
'Regards,',
`The ${this.settings.appName} Team - ${this.settings.siteUrl}`,
]
expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
})
})
})
describe('no CTA', function () {
describe('securityAlert', function () {
before(function () {
this.message = 'more details about the action'
this.messageHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${this.message}</i></b></span>`
this.messageNotAllowedHTML = `<div></div>${this.messageHTML}`
this.actionDescribed = 'an action described'
this.actionDescribedHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${this.actionDescribed}</i></b>`
this.actionDescribedNotAllowedHTML = `<div></div>${this.actionDescribedHTML}`
this.opts = {
to: this.email,
actionDescribed: this.actionDescribedNotAllowedHTML,
action: 'an action',
message: [this.messageNotAllowedHTML],
}
this.email = this.EmailBuilder.buildEmail('securityAlert', this.opts)
})
it('should build the email', function () {
expect(this.email.html != null).to.equal(true)
expect(this.email.text != null).to.equal(true)
})
describe('HTML email', function () {
it('should clean HTML in opts.actionDescribed', function () {
expect(this.email.html).to.not.contain(
this.actionDescribedNotAllowedHTML
)
expect(this.email.html).to.contain(this.actionDescribedHTML)
})
it('should clean HTML in opts.message', function () {
expect(this.email.html).to.not.contain(this.messageNotAllowedHTML)
expect(this.email.html).to.contain(this.messageHTML)
})
})
describe('plain text email', function () {
it('should remove all HTML in opts.actionDescribed', function () {
expect(this.email.text).to.not.contain(this.actionDescribedHTML)
expect(this.email.text).to.contain(this.actionDescribed)
})
it('should remove all HTML in opts.message', function () {
expect(this.email.text).to.not.contain(this.messageHTML)
expect(this.email.text).to.contain(this.message)
})
})
})
describe('welcomeWithoutCTA', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'welcomeWithoutCTA',
this.opts
)
this.dom = cheerio.load(this.email.html)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include help links', function () {
const helpGuidesLink = this.dom('a:contains("Help Guides")')
const templatesLink = this.dom('a:contains("Templates")')
const logInLink = this.dom('a:contains("log in")')
expect(helpGuidesLink.length).to.equal(1)
expect(templatesLink.length).to.equal(1)
expect(logInLink.length).to.equal(1)
})
})
describe('plain text email', function () {
it('should include help URL', function () {
expect(this.email.text).to.contain('/learn')
expect(this.email.text).to.contain('/login')
expect(this.email.text).to.contain('/templates')
})
it('should contain HTML links', function () {
expect(this.email.text).to.not.contain('<a')
})
})
})
})
})
})

View File

@@ -0,0 +1,122 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailHandler'
)
describe('EmailHandler', function () {
beforeEach(function () {
this.html = '<html>hello</html>'
this.Settings = { email: {} }
this.EmailBuilder = {
buildEmail: sinon.stub().returns({ html: this.html }),
}
this.EmailSender = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
this.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
this.EmailHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'./EmailBuilder': this.EmailBuilder,
'./EmailSender': this.EmailSender,
'@overleaf/settings': this.Settings,
'../../infrastructure/Queues': this.Queues,
},
})
})
describe('send email', function () {
it('should use the correct options', async function () {
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({
html: this.html,
})
})
it('should return the error', async function () {
this.EmailSender.promises.sendEmail.rejects(new Error('boom'))
const opts = {
to: 'bob@bob.com',
subject: 'hello bob',
}
await expect(this.EmailHandler.promises.sendEmail('welcome', opts)).to.be
.rejected
})
it('should not send an email if lifecycle is not enabled', async function () {
this.Settings.email.lifecycle = false
this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
await this.EmailHandler.promises.sendEmail('welcome', {})
expect(this.EmailSender.promises.sendEmail).not.to.have.been.called
})
it('should send an email if lifecycle is not enabled but the type is notification', async function () {
this.Settings.email.lifecycle = false
this.EmailBuilder.buildEmail.returns({ type: 'notification' })
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.called
})
it('should send lifecycle email if it is enabled', async function () {
this.Settings.email.lifecycle = true
this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.called
})
describe('with plain-text email content', function () {
beforeEach(function () {
this.text = 'hello there'
})
it('should pass along the text field', async function () {
this.EmailBuilder.buildEmail.returns({
html: this.html,
text: this.text,
})
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(
this.EmailSender.promises.sendEmail
).to.have.been.calledWithMatch({
html: this.html,
text: this.text,
})
})
})
})
describe('send deferred email', function () {
beforeEach(function () {
this.opts = {
to: 'bob@bob.com',
first_name: 'hello bob',
}
this.emailType = 'canceledSubscription'
this.ONE_HOUR_IN_MS = 1000 * 60 * 60
this.EmailHandler.sendDeferredEmail(
this.emailType,
this.opts,
this.ONE_HOUR_IN_MS
)
})
it('should add a email job to the queue', function () {
expect(this.Queues.createScheduledJob).to.have.been.calledWith(
'deferred-emails',
{ data: { emailType: this.emailType, opts: this.opts } },
this.ONE_HOUR_IN_MS
)
})
})
})

View File

@@ -0,0 +1,35 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailMessageHelper'
)
describe('EmailMessageHelper', function () {
beforeEach(function () {
this.EmailMessageHelper = SandboxedModule.require(MODULE_PATH, {})
})
describe('cleanHTML', function () {
beforeEach(function () {
this.text = 'a message'
this.span = `<span style="text-align:center">${this.text}</span>`
this.fullMessage = `${this.span}<div></div>`
})
it('should remove HTML for plainText version', function () {
const processed = this.EmailMessageHelper.cleanHTML(
this.fullMessage,
true
)
expect(processed).to.equal(this.text)
})
it('should keep HTML for HTML version but remove tags not allowed', function () {
const processed = this.EmailMessageHelper.cleanHTML(
this.fullMessage,
false
)
expect(processed).to.equal(this.span)
})
})
})

View File

@@ -0,0 +1,131 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailSender.js'
)
describe('EmailSender', function () {
beforeEach(function () {
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.Settings = {
email: {
transport: 'ses',
parameters: {
AWSAccessKeyID: 'key',
AWSSecretKey: 'secret',
},
fromAddress: 'bob@bob.com',
replyToAddress: 'sally@gmail.com',
},
}
this.sesClient = { sendMail: sinon.stub().resolves() }
this.ses = { createTransport: () => this.sesClient }
this.EmailSender = SandboxedModule.require(MODULE_PATH, {
requires: {
nodemailer: this.ses,
'nodemailer-ses-transport': sinon.stub(),
'@overleaf/settings': this.Settings,
'../../infrastructure/RateLimiter': this.RateLimiter,
'@overleaf/metrics': {
inc() {},
},
},
})
this.opts = {
to: 'bob@bob.com',
subject: 'new email',
html: '<hello></hello>',
}
})
describe('sendEmail', function () {
it('should set the properties on the email to send', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
html: this.opts.html,
to: this.opts.to,
subject: this.opts.subject,
})
})
it('should return a non-specific error', async function () {
this.sesClient.sendMail.rejects(new Error('boom'))
await expect(this.EmailSender.promises.sendEmail({})).to.be.rejectedWith(
'error sending message'
)
})
it('should use the from address from settings', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
from: this.Settings.email.fromAddress,
})
})
it('should use the reply to address from settings', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
replyTo: this.Settings.email.replyToAddress,
})
})
it('should use the reply to address in options as an override', async function () {
this.opts.replyTo = 'someone@else.com'
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
replyTo: this.opts.replyTo,
})
})
it('should not send an email when the rate limiter says no', async function () {
this.opts.sendingUser_id = '12321312321'
this.rateLimiter.consume.rejects({ remainingPoints: 0 })
await expect(this.EmailSender.promises.sendEmail(this.opts)).to.be
.rejected
expect(this.sesClient.sendMail).not.to.have.been.called
})
it('should send the email when the rate limtier says continue', async function () {
this.opts.sendingUser_id = '12321312321'
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.called
})
it('should not check the rate limiter when there is no sendingUser_id', async function () {
this.EmailSender.sendEmail(this.opts, () => {
expect(this.sesClient.sendMail).to.have.been.called
expect(this.rateLimiter.consume).not.to.have.been.called
})
})
describe('with plain-text email content', function () {
beforeEach(function () {
this.opts.text = 'hello there'
})
it('should set the text property on the email to send', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
html: this.opts.html,
text: this.opts.text,
to: this.opts.to,
subject: this.opts.subject,
})
})
})
})
})

View File

@@ -0,0 +1,76 @@
const path = require('path')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Email/SpamSafe'
)
const SpamSafe = require(modulePath)
const { expect } = require('chai')
describe('SpamSafe', function () {
it('should reject spammy names', function () {
expect(SpamSafe.isSafeUserName('Charline Wałęsa')).to.equal(true)
expect(
SpamSafe.isSafeUserName(
"hey come buy this product im selling it's really good for you and it'll make your latex 10x guaranteed"
)
).to.equal(false)
expect(SpamSafe.isSafeUserName('隆太郎 宮本')).to.equal(true)
expect(SpamSafe.isSafeUserName('Visit haxx0red.com')).to.equal(false)
expect(
SpamSafe.isSafeUserName(
'加美汝VXhihi661金沙2001005com the first deposit will be _100%_'
)
).to.equal(false)
expect(
SpamSafe.isSafeProjectName(
'Neural Networks: good for your health and will solve all your problems'
)
).to.equal(false)
expect(
SpamSafe.isSafeProjectName(
'An analysis of the questions of the universe!'
)
).to.equal(true)
expect(SpamSafe.isSafeProjectName("A'p'o's't'r'o'p'h'e gallore")).to.equal(
true
)
expect(
SpamSafe.isSafeProjectName(
'come buy this => http://www.dopeproduct.com/search/?q=user123'
)
).to.equal(false)
expect(
SpamSafe.isSafeEmail('realistic-email+1@domain.sub-hyphen.com')
).to.equal(true)
expect(SpamSafe.isSafeEmail('notquiteRight@evil$.com')).to.equal(false)
expect(SpamSafe.safeUserName('Tammy Weinstįen', 'A User')).to.equal(
'Tammy Weinstįen'
)
expect(SpamSafe.safeUserName('haxx0red.com', 'A User')).to.equal('A User')
expect(SpamSafe.safeUserName('What$ Upp', 'A User')).to.equal('A User')
expect(SpamSafe.safeProjectName('Math-ematics!', 'A Project')).to.equal(
'Math-ematics!'
)
expect(
SpamSafe.safeProjectName(
`A Very long title for a very long book that will never be read${'a'.repeat(
250
)}`,
'A Project'
)
).to.equal('A Project')
expect(
SpamSafe.safeEmail('safe-ëmail@domain.com', 'A collaborator')
).to.equal('safe-ëmail@domain.com')
expect(
SpamSafe.safeEmail('Բարեւ@another.domain', 'A collaborator')
).to.equal('Բարեւ@another.domain')
expect(
SpamSafe.safeEmail(`me+${'a'.repeat(40)}@googoole.con`, 'A collaborator')
).to.equal('A collaborator')
expect(
SpamSafe.safeEmail('sendME$$$@iAmAprince.com', 'A collaborator')
).to.equal('A collaborator')
})
})

View File

@@ -0,0 +1,377 @@
const { expect } = require('chai')
const MockResponse = require('../helpers/MockResponse')
const MockRequest = require('../helpers/MockRequest')
const SandboxedModule = require('sandboxed-module')
const modulePath = '../../../../app/src/Features/Errors/HttpErrorHandler.js'
describe('HttpErrorHandler', function () {
beforeEach(function () {
this.req = new MockRequest()
this.res = new MockResponse()
this.HttpErrorHandler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': {
appName: 'Overleaf',
statusPageUrl: 'https://status.overlaf.com',
},
},
})
})
describe('handleErrorByStatusCode', function () {
it('returns the http status code of 400 errors', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
400
)
expect(this.res.statusCode).to.equal(400)
})
it('returns the http status code of 500 errors', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
500
)
expect(this.res.statusCode).to.equal(500)
})
it('returns the http status code of any 5xx error', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
588
)
expect(this.res.statusCode).to.equal(588)
})
it('returns the http status code of any 4xx error', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
488
)
expect(this.res.statusCode).to.equal(488)
})
it('returns 500 for http status codes smaller than 400', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
302
)
expect(this.res.statusCode).to.equal(500)
})
it('returns 500 for http status codes larger than 600', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
302
)
expect(this.res.statusCode).to.equal(500)
})
it('returns 500 when the error has no http status code', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(this.req, this.res, err)
expect(this.res.statusCode).to.equal(500)
})
it('uses the conflict() error handler', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
409
)
expect(this.res.body).to.equal('conflict')
})
it('uses the forbidden() error handler', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
403
)
expect(this.res.body).to.equal('restricted')
})
it('uses the notFound() error handler', function () {
const err = new Error()
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
404
)
expect(this.res.body).to.equal('not found')
})
it('uses the unprocessableEntity() error handler', function () {
const err = new Error()
err.httpStatusCode = 422
this.HttpErrorHandler.handleErrorByStatusCode(
this.req,
this.res,
err,
422
)
expect(this.res.body).to.equal('unprocessable entity')
})
})
describe('badRequest', function () {
it('returns 400', function () {
this.HttpErrorHandler.badRequest(this.req, this.res)
expect(this.res.statusCode).to.equal(400)
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.badRequest(this.req, this.res)
expect(this.res.body).to.equal('client error')
})
it("should render a template including the error message when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.badRequest(this.req, this.res, 'an error')
expect(this.res.renderedTemplate).to.equal('general/400')
expect(this.res.renderedVariables).to.deep.equal({
title: 'Client Error',
message: 'an error',
})
})
it("should render a default template when content-type is 'html' and no message is provided", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.badRequest(this.req, this.res)
expect(this.res.renderedTemplate).to.equal('general/400')
expect(this.res.renderedVariables).to.deep.equal({
title: 'Client Error',
message: undefined,
})
})
it("should return a json object when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.badRequest(this.req, this.res, 'an error', {
foo: 'bar',
})
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'an error',
foo: 'bar',
})
})
it("should return an empty json object when content-type is 'json' and no message and info are provided", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.badRequest(this.req, this.res)
expect(JSON.parse(this.res.body)).to.deep.equal({})
})
})
describe('conflict', function () {
it('returns 409', function () {
this.HttpErrorHandler.conflict(this.req, this.res)
expect(this.res.statusCode).to.equal(409)
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.conflict(this.req, this.res)
expect(this.res.body).to.equal('conflict')
})
it("should render a template including the error message when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error')
expect(this.res.renderedTemplate).to.equal('general/400')
expect(this.res.renderedVariables).to.deep.equal({
title: 'Client Error',
message: 'an error',
})
})
it("should return a json object when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.unprocessableEntity(
this.req,
this.res,
'an error',
{
foo: 'bar',
}
)
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'an error',
foo: 'bar',
})
})
})
describe('forbidden', function () {
it('returns 403', function () {
this.HttpErrorHandler.forbidden(this.req, this.res)
expect(this.res.statusCode).to.equal(403)
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.forbidden(this.req, this.res)
expect(this.res.body).to.equal('restricted')
})
it("should render a template when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.forbidden(this.req, this.res)
expect(this.res.renderedTemplate).to.equal('user/restricted')
expect(this.res.renderedVariables).to.deep.equal({
title: 'restricted',
})
})
it("should return a json object when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.forbidden(this.req, this.res, 'an error', {
foo: 'bar',
})
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'an error',
foo: 'bar',
})
})
})
describe('notFound', function () {
it('returns 404', function () {
this.HttpErrorHandler.notFound(this.req, this.res)
expect(this.res.statusCode).to.equal(404)
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.notFound(this.req, this.res)
expect(this.res.body).to.equal('not found')
})
it("should render a template when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.notFound(this.req, this.res)
expect(this.res.renderedTemplate).to.equal('general/404')
expect(this.res.renderedVariables).to.deep.equal({
title: 'page_not_found',
})
})
it("should return a json object when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.notFound(this.req, this.res, 'an error', {
foo: 'bar',
})
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'an error',
foo: 'bar',
})
})
})
describe('unprocessableEntity', function () {
it('returns 422', function () {
this.HttpErrorHandler.unprocessableEntity(this.req, this.res)
expect(this.res.statusCode).to.equal(422)
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.unprocessableEntity(this.req, this.res)
expect(this.res.body).to.equal('unprocessable entity')
})
it("should render a template including the error message when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error')
expect(this.res.renderedTemplate).to.equal('general/400')
expect(this.res.renderedVariables).to.deep.equal({
title: 'Client Error',
message: 'an error',
})
})
it("should return a json object when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.unprocessableEntity(
this.req,
this.res,
'an error',
{
foo: 'bar',
}
)
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'an error',
foo: 'bar',
})
})
describe('legacyInternal', function () {
it('returns 500', function () {
this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
expect(this.res.statusCode).to.equal(500)
})
it('should send the error to the logger', function () {
const error = new Error('message')
this.HttpErrorHandler.legacyInternal(
this.req,
this.res,
'message',
error
)
expect(this.req.logger.setLevel).to.have.been.calledWith('error')
expect(this.req.logger.addFields).to.have.been.calledWith({
err: error,
})
})
it('should print a message when no content-type is included', function () {
this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
expect(this.res.body).to.equal('internal server error')
})
it("should render a template when content-type is 'html'", function () {
this.req.accepts = () => 'html'
this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
expect(this.res.renderedTemplate).to.equal('general/500')
expect(this.res.renderedVariables).to.deep.equal({
title: 'Server Error',
})
})
it("should return a json object with a static message when content-type is 'json'", function () {
this.req.accepts = () => 'json'
this.HttpErrorHandler.legacyInternal(
this.req,
this.res,
'a message',
new Error()
)
expect(JSON.parse(this.res.body)).to.deep.equal({
message: 'a message',
})
})
})
})
})

View File

@@ -0,0 +1,196 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import esmock from 'esmock'
import { expect } from 'chai'
import sinon from 'sinon'
const modulePath = new URL(
'../../../../app/src/Features/Exports/ExportsController.mjs',
import.meta.url
).pathname
describe('ExportsController', function () {
const projectId = '123njdskj9jlk'
const userId = '123nd3ijdks'
const brandVariationId = 22
const firstName = 'first'
const lastName = 'last'
const title = 'title'
const description = 'description'
const author = 'author'
const license = 'other'
const showSource = true
beforeEach(async function () {
this.handler = { getUserNotifications: sinon.stub().callsArgWith(1) }
this.req = {
params: {
project_id: projectId,
brand_variation_id: brandVariationId,
},
body: {
firstName,
lastName,
},
session: {
user: {
_id: userId,
},
},
i18n: {
translate() {},
},
}
this.res = {
json: sinon.stub(),
status: sinon.stub(),
}
this.res.status.returns(this.res)
this.next = sinon.stub()
this.AuthenticationController = {
getLoggedInUserId: sinon.stub().returns(this.req.session.user._id),
}
return (this.controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Exports/ExportsHandler.mjs': this.handler,
'../../../../app/src/Features/Authentication/AuthenticationController.js':
this.AuthenticationController,
}))
})
describe('without gallery fields', function () {
it('should ask the handler to perform the export', function (done) {
this.handler.exportProject = sinon
.stub()
.yields(null, { iAmAnExport: true, v1_id: 897 })
const expected = {
project_id: projectId,
user_id: userId,
brand_variation_id: brandVariationId,
first_name: firstName,
last_name: lastName,
}
return this.controller.exportProject(this.req, {
json: body => {
expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
expect(body).to.deep.equal({ export_v1_id: 897, message: undefined })
return done()
},
})
})
})
describe('with a message from v1', function () {
it('should ask the handler to perform the export', function (done) {
this.handler.exportProject = sinon.stub().yields(null, {
iAmAnExport: true,
v1_id: 897,
message: 'RESUBMISSION',
})
const expected = {
project_id: projectId,
user_id: userId,
brand_variation_id: brandVariationId,
first_name: firstName,
last_name: lastName,
}
return this.controller.exportProject(this.req, {
json: body => {
expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
expect(body).to.deep.equal({
export_v1_id: 897,
message: 'RESUBMISSION',
})
return done()
},
})
})
})
describe('with gallery fields', function () {
beforeEach(function () {
this.req.body.title = title
this.req.body.description = description
this.req.body.author = author
this.req.body.license = license
return (this.req.body.showSource = true)
})
it('should ask the handler to perform the export', function (done) {
this.handler.exportProject = sinon
.stub()
.yields(null, { iAmAnExport: true, v1_id: 897 })
const expected = {
project_id: projectId,
user_id: userId,
brand_variation_id: brandVariationId,
first_name: firstName,
last_name: lastName,
title,
description,
author,
license,
show_source: showSource,
}
return this.controller.exportProject(this.req, {
json: body => {
expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
expect(body).to.deep.equal({ export_v1_id: 897, message: undefined })
return done()
},
})
})
})
describe('with an error return from v1 to forward to the publish modal', function () {
it('should forward the response onward', function (done) {
this.error_json = { status: 422, message: 'nope' }
this.handler.exportProject = sinon
.stub()
.yields({ forwardResponse: this.error_json })
this.controller.exportProject(this.req, this.res, this.next)
expect(this.res.json.args[0][0]).to.deep.equal(this.error_json)
expect(this.res.status.args[0][0]).to.equal(this.error_json.status)
return done()
})
})
it('should ask the handler to return the status of an export', function (done) {
this.handler.fetchExport = sinon.stub().yields(
null,
`{
"id":897,
"status_summary":"completed",
"status_detail":"all done",
"partner_submission_id":"abc123",
"v2_user_email":"la@tex.com",
"v2_user_first_name":"Arthur",
"v2_user_last_name":"Author",
"title":"my project",
"token":"token"
}`
)
this.req.params = { project_id: projectId, export_id: 897 }
return this.controller.exportStatus(this.req, {
json: body => {
expect(body).to.deep.equal({
export_json: {
status_summary: 'completed',
status_detail: 'all done',
partner_submission_id: 'abc123',
v2_user_email: 'la@tex.com',
v2_user_first_name: 'Arthur',
v2_user_last_name: 'Author',
title: 'my project',
token: 'token',
},
})
return done()
},
})
})
})

View File

@@ -0,0 +1,698 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import esmock from 'esmock'
import { expect } from 'chai'
const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs'
describe('ExportsHandler', function () {
beforeEach(async function () {
this.stubRequest = {}
this.request = {
defaults: () => {
return this.stubRequest
},
}
this.ExportsHandler = await esmock.strict(modulePath, {
'../../../../app/src/Features/Project/ProjectGetter':
(this.ProjectGetter = {}),
'../../../../app/src/Features/Project/ProjectHistoryHandler':
(this.ProjectHistoryHandler = {}),
'../../../../app/src/Features/Project/ProjectLocator':
(this.ProjectLocator = {}),
'../../../../app/src/Features/Project/ProjectRootDocManager':
(this.ProjectRootDocManager = {}),
'../../../../app/src/Features/User/UserGetter': (this.UserGetter = {}),
'@overleaf/settings': (this.settings = {}),
request: this.request,
})
this.project_id = 'project-id-123'
this.project_history_id = 987
this.user_id = 'user-id-456'
this.brand_variation_id = 789
this.title = 'title'
this.description = 'description'
this.author = 'author'
this.license = 'other'
this.show_source = true
this.export_params = {
project_id: this.project_id,
brand_variation_id: this.brand_variation_id,
user_id: this.user_id,
title: this.title,
description: this.description,
author: this.author,
license: this.license,
show_source: this.show_source,
}
return (this.callback = sinon.stub())
})
describe('exportProject', function () {
beforeEach(function () {
this.export_data = { iAmAnExport: true }
this.response_body = { iAmAResponseBody: true }
this.ExportsHandler._buildExport = sinon
.stub()
.yields(null, this.export_data)
return (this.ExportsHandler._requestExport = sinon
.stub()
.yields(null, this.response_body))
})
describe('when all goes well', function () {
beforeEach(function (done) {
return this.ExportsHandler.exportProject(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should build the export', function () {
return this.ExportsHandler._buildExport
.calledWith(this.export_params)
.should.equal(true)
})
it('should request the export', function () {
return this.ExportsHandler._requestExport
.calledWith(this.export_data)
.should.equal(true)
})
it('should return the export', function () {
return this.callback
.calledWith(null, this.export_data)
.should.equal(true)
})
})
describe("when request can't be built", function () {
beforeEach(function (done) {
this.ExportsHandler._buildExport = sinon
.stub()
.yields(new Error('cannot export project without root doc'))
return this.ExportsHandler.exportProject(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
describe('when export request returns an error to forward to the user', function () {
beforeEach(function (done) {
this.error_json = { status: 422, message: 'nope' }
this.ExportsHandler._requestExport = sinon
.stub()
.yields(null, { forwardResponse: this.error_json })
return this.ExportsHandler.exportProject(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return success and the response to forward', function () {
;(this.callback.args[0][0] instanceof Error).should.equal(false)
return this.callback.calledWith(null, {
forwardResponse: this.error_json,
})
})
})
})
describe('_buildExport', function () {
beforeEach(function (done) {
this.project = {
id: this.project_id,
rootDoc_id: 'doc1_id',
compiler: 'pdflatex',
imageName: 'mock-image-name',
overleaf: {
id: this.project_history_id, // for projects imported from v1
history: {
id: this.project_history_id,
},
},
}
this.user = {
id: this.user_id,
first_name: 'Arthur',
last_name: 'Author',
email: 'arthur.author@arthurauthoring.org',
overleaf: {
id: 876,
},
}
this.rootDocPath = 'main.tex'
this.historyVersion = 777
this.ProjectGetter.getProject = sinon.stub().yields(null, this.project)
this.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon
.stub()
.yields(null)
this.ProjectLocator.findRootDoc = sinon
.stub()
.yields(null, [null, { fileSystem: 'main.tex' }])
this.ProjectRootDocManager.ensureRootDocumentIsValid = sinon
.stub()
.callsArgWith(1, null)
this.UserGetter.getUser = sinon.stub().yields(null, this.user)
this.ExportsHandler._requestVersion = sinon
.stub()
.yields(null, this.historyVersion)
return done()
})
describe('when all goes well', function () {
beforeEach(function (done) {
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should ensure the project has history', function () {
return this.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal(
true
)
})
it('should request the project history version', function () {
return this.ExportsHandler._requestVersion.called.should.equal(true)
})
it('should return export data', function () {
const expectedExportData = {
project: {
id: this.project_id,
rootDocPath: this.rootDocPath,
historyId: this.project_history_id,
historyVersion: this.historyVersion,
v1ProjectId: this.project_history_id,
metadata: {
compiler: 'pdflatex',
imageName: 'mock-image-name',
title: this.title,
description: this.description,
author: this.author,
license: this.license,
showSource: this.show_source,
},
},
user: {
id: this.user_id,
firstName: this.user.first_name,
lastName: this.user.last_name,
email: this.user.email,
orcidId: null,
v1UserId: 876,
},
destination: {
brandVariationId: this.brand_variation_id,
},
options: {
callbackUrl: null,
},
}
return this.callback
.calledWith(null, expectedExportData)
.should.equal(true)
})
})
describe('when we send replacement user first and last name', function () {
beforeEach(function (done) {
this.custom_first_name = 'FIRST'
this.custom_last_name = 'LAST'
this.export_params.first_name = this.custom_first_name
this.export_params.last_name = this.custom_last_name
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should send the data from the user input', function () {
const expectedExportData = {
project: {
id: this.project_id,
rootDocPath: this.rootDocPath,
historyId: this.project_history_id,
historyVersion: this.historyVersion,
v1ProjectId: this.project_history_id,
metadata: {
compiler: 'pdflatex',
imageName: 'mock-image-name',
title: this.title,
description: this.description,
author: this.author,
license: this.license,
showSource: this.show_source,
},
},
user: {
id: this.user_id,
firstName: this.custom_first_name,
lastName: this.custom_last_name,
email: this.user.email,
orcidId: null,
v1UserId: 876,
},
destination: {
brandVariationId: this.brand_variation_id,
},
options: {
callbackUrl: null,
},
}
return this.callback
.calledWith(null, expectedExportData)
.should.equal(true)
})
})
describe('when project is not found', function () {
beforeEach(function (done) {
this.ProjectGetter.getProject = sinon
.stub()
.yields(new Error('project not found'))
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
describe('when project has no root doc', function () {
describe('when a root doc can be set automatically', function () {
beforeEach(function (done) {
this.project.rootDoc_id = null
this.ProjectLocator.findRootDoc = sinon
.stub()
.yields(null, [null, { fileSystem: 'other.tex' }])
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should set a root doc', function () {
return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
true
)
})
it('should return export data', function () {
const expectedExportData = {
project: {
id: this.project_id,
rootDocPath: 'other.tex',
historyId: this.project_history_id,
historyVersion: this.historyVersion,
v1ProjectId: this.project_history_id,
metadata: {
compiler: 'pdflatex',
imageName: 'mock-image-name',
title: this.title,
description: this.description,
author: this.author,
license: this.license,
showSource: this.show_source,
},
},
user: {
id: this.user_id,
firstName: this.user.first_name,
lastName: this.user.last_name,
email: this.user.email,
orcidId: null,
v1UserId: 876,
},
destination: {
brandVariationId: this.brand_variation_id,
},
options: {
callbackUrl: null,
},
}
return this.callback
.calledWith(null, expectedExportData)
.should.equal(true)
})
})
})
describe('when project has an invalid root doc', function () {
describe('when a new root doc can be set automatically', function () {
beforeEach(function (done) {
this.fakeDoc_id = '1a2b3c4d5e6f'
this.project.rootDoc_id = this.fakeDoc_id
this.ProjectLocator.findRootDoc = sinon
.stub()
.yields(null, [null, { fileSystem: 'other.tex' }])
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should set a valid root doc', function () {
return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
true
)
})
it('should return export data', function () {
const expectedExportData = {
project: {
id: this.project_id,
rootDocPath: 'other.tex',
historyId: this.project_history_id,
historyVersion: this.historyVersion,
v1ProjectId: this.project_history_id,
metadata: {
compiler: 'pdflatex',
imageName: 'mock-image-name',
title: this.title,
description: this.description,
author: this.author,
license: this.license,
showSource: this.show_source,
},
},
user: {
id: this.user_id,
firstName: this.user.first_name,
lastName: this.user.last_name,
email: this.user.email,
orcidId: null,
v1UserId: 876,
},
destination: {
brandVariationId: this.brand_variation_id,
},
options: {
callbackUrl: null,
},
}
return this.callback
.calledWith(null, expectedExportData)
.should.equal(true)
})
})
describe('when no root doc can be identified', function () {
beforeEach(function (done) {
this.ProjectLocator.findRootDoc = sinon
.stub()
.yields(null, [null, null])
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
})
describe('when user is not found', function () {
beforeEach(function (done) {
this.UserGetter.getUser = sinon
.stub()
.yields(new Error('user not found'))
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
describe('when project history request fails', function () {
beforeEach(function (done) {
this.ExportsHandler._requestVersion = sinon
.stub()
.yields(new Error('project history call failed'))
return this.ExportsHandler._buildExport(
this.export_params,
(error, exportData) => {
this.callback(error, exportData)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
})
describe('_requestExport', function () {
beforeEach(function (done) {
this.settings.apis = {
v1: {
url: 'http://127.0.0.1:5000',
user: 'overleaf',
pass: 'pass',
timeout: 15000,
},
}
this.export_data = { iAmAnExport: true }
this.export_id = 4096
this.stubPost = sinon
.stub()
.yields(null, { statusCode: 200 }, { exportId: this.export_id })
return done()
})
describe('when all goes well', function () {
beforeEach(function (done) {
this.stubRequest.post = this.stubPost
return this.ExportsHandler._requestExport(
this.export_data,
(error, exportV1Id) => {
this.callback(error, exportV1Id)
return done()
}
)
})
it('should issue the request', function () {
return expect(this.stubPost.getCall(0).args[0]).to.deep.equal({
url: this.settings.apis.v1.url + '/api/v1/overleaf/exports',
auth: {
user: this.settings.apis.v1.user,
pass: this.settings.apis.v1.pass,
},
json: this.export_data,
timeout: 15000,
})
})
it('should return the body with v1 export id', function () {
return this.callback
.calledWith(null, { exportId: this.export_id })
.should.equal(true)
})
})
describe('when the request fails', function () {
beforeEach(function (done) {
this.stubRequest.post = sinon
.stub()
.yields(new Error('export request failed'))
return this.ExportsHandler._requestExport(
this.export_data,
(error, exportV1Id) => {
this.callback(error, exportV1Id)
return done()
}
)
})
it('should return an error', function () {
return (this.callback.args[0][0] instanceof Error).should.equal(true)
})
})
describe('when the request returns an error response to forward', function () {
beforeEach(function (done) {
this.error_code = 422
this.error_json = { status: this.error_code, message: 'nope' }
this.stubRequest.post = sinon
.stub()
.yields(null, { statusCode: this.error_code }, this.error_json)
return this.ExportsHandler._requestExport(
this.export_data,
(error, exportV1Id) => {
this.callback(error, exportV1Id)
return done()
}
)
})
it('should return success and the response to forward', function () {
;(this.callback.args[0][0] instanceof Error).should.equal(false)
return this.callback.calledWith(null, {
forwardResponse: this.error_json,
})
})
})
})
describe('fetchExport', function () {
beforeEach(function (done) {
this.settings.apis = {
v1: {
url: 'http://127.0.0.1:5000',
user: 'overleaf',
pass: 'pass',
timeout: 15000,
},
}
this.export_id = 897
this.body = '{"id":897, "status_summary":"completed"}'
this.stubGet = sinon
.stub()
.yields(null, { statusCode: 200 }, { body: this.body })
return done()
})
describe('when all goes well', function () {
beforeEach(function (done) {
this.stubRequest.get = this.stubGet
return this.ExportsHandler.fetchExport(
this.export_id,
(error, body) => {
this.callback(error, body)
return done()
}
)
})
it('should issue the request', function () {
return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({
url:
this.settings.apis.v1.url +
'/api/v1/overleaf/exports/' +
this.export_id,
auth: {
user: this.settings.apis.v1.user,
pass: this.settings.apis.v1.pass,
},
timeout: 15000,
})
})
it('should return the v1 export id', function () {
return this.callback
.calledWith(null, { body: this.body })
.should.equal(true)
})
})
})
describe('fetchDownload', function () {
beforeEach(function (done) {
this.settings.apis = {
v1: {
url: 'http://127.0.0.1:5000',
user: 'overleaf',
pass: 'pass',
timeout: 15000,
},
}
this.export_id = 897
this.body =
'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee'
this.stubGet = sinon
.stub()
.yields(null, { statusCode: 200 }, { body: this.body })
return done()
})
describe('when all goes well', function () {
beforeEach(function (done) {
this.stubRequest.get = this.stubGet
return this.ExportsHandler.fetchDownload(
this.export_id,
'zip',
(error, body) => {
this.callback(error, body)
return done()
}
)
})
it('should issue the request', function () {
return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({
url:
this.settings.apis.v1.url +
'/api/v1/overleaf/exports/' +
this.export_id +
'/zip_url',
auth: {
user: this.settings.apis.v1.user,
pass: this.settings.apis.v1.pass,
},
timeout: 15000,
})
})
it('should return the v1 export id', function () {
return this.callback
.calledWith(null, { body: this.body })
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,235 @@
import { expect } from 'chai'
import sinon from 'sinon'
import esmock from 'esmock'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import MockResponse from '../helpers/MockResponse.js'
const MODULE_PATH =
'../../../../app/src/Features/FileStore/FileStoreController.mjs'
const expectedFileHeaders = {
'Cache-Control': 'private, max-age=3600',
'X-Served-By': 'filestore',
}
describe('FileStoreController', function () {
beforeEach(async function () {
this.FileStoreHandler = {
promises: {
getFileStream: sinon.stub(),
getFileSize: sinon.stub(),
},
}
this.ProjectLocator = { promises: { findElement: sinon.stub() } }
this.Stream = { pipeline: sinon.stub().resolves() }
this.HistoryManager = {}
this.controller = await esmock.strict(MODULE_PATH, {
'node:stream/promises': this.Stream,
'@overleaf/settings': this.settings,
'../../../../app/src/Features/Project/ProjectLocator':
this.ProjectLocator,
'../../../../app/src/Features/FileStore/FileStoreHandler':
this.FileStoreHandler,
'../../../../app/src/Features/History/HistoryManager':
this.HistoryManager,
})
this.stream = {}
this.projectId = '2k3j1lk3j21lk3j'
this.fileId = '12321kklj1lk3jk12'
this.req = {
params: {
Project_id: this.projectId,
File_id: this.fileId,
},
query: 'query string here',
get(key) {
return undefined
},
logger: {
addFields: sinon.stub(),
},
}
this.res = new MockResponse()
this.next = sinon.stub()
this.file = { name: 'myfile.png' }
})
describe('getFile', function () {
beforeEach(function () {
this.FileStoreHandler.promises.getFileStream.resolves(this.stream)
this.ProjectLocator.promises.findElement.resolves({ element: this.file })
})
it('should call the file store handler with the project_id file_id and any query string', async function () {
await this.controller.getFile(this.req, this.res)
this.FileStoreHandler.promises.getFileStream.should.have.been.calledWith(
this.req.params.Project_id,
this.req.params.File_id,
this.req.query
)
})
it('should pipe to res', async function () {
await this.controller.getFile(this.req, this.res)
this.Stream.pipeline.should.have.been.calledWith(this.stream, this.res)
})
it('should get the file from the db', async function () {
await this.controller.getFile(this.req, this.res)
this.ProjectLocator.promises.findElement.should.have.been.calledWith({
project_id: this.projectId,
element_id: this.fileId,
type: 'file',
})
})
it('should set the Content-Disposition header', async function () {
await this.controller.getFile(this.req, this.res)
this.res.setContentDisposition.should.be.calledWith('attachment', {
filename: this.file.name,
})
})
it('should return a 404 when not found', async function () {
this.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
)
await this.controller.getFile(this.req, this.res)
expect(this.res.statusCode).to.equal(404)
})
// Test behaviour around handling html files
;['.html', '.htm', '.xhtml'].forEach(extension => {
describe(`with a '${extension}' file extension`, function () {
beforeEach(function () {
this.file.name = `bad${extension}`
this.req.get = key => {
if (key === 'User-Agent') {
return 'A generic browser'
}
}
})
describe('from a non-ios browser', function () {
it('should not set Content-Type', async function () {
await this.controller.getFile(this.req, this.res)
this.res.headers.should.deep.equal({
...expectedFileHeaders,
})
})
})
describe('from an iPhone', function () {
beforeEach(function () {
this.req.get = key => {
if (key === 'User-Agent') {
return 'An iPhone browser'
}
}
})
it("should set Content-Type to 'text/plain'", async function () {
await this.controller.getFile(this.req, this.res)
this.res.headers.should.deep.equal({
...expectedFileHeaders,
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
})
})
})
describe('from an iPad', function () {
beforeEach(function () {
this.req.get = key => {
if (key === 'User-Agent') {
return 'An iPad browser'
}
}
})
it("should set Content-Type to 'text/plain'", async function () {
await this.controller.getFile(this.req, this.res)
this.res.headers.should.deep.equal({
...expectedFileHeaders,
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
})
})
})
})
})
;[
// None of these should trigger the iOS/html logic
'x.html-is-rad',
'html.pdf',
'.html-is-good-for-hidden-files',
'somefile',
].forEach(filename => {
describe(`with filename as '${filename}'`, function () {
beforeEach(function () {
this.user_agent = 'A generic browser'
this.file.name = filename
this.req.get = key => {
if (key === 'User-Agent') {
return this.user_agent
}
}
})
;['iPhone', 'iPad', 'Firefox', 'Chrome'].forEach(browser => {
describe(`downloaded from ${browser}`, function () {
beforeEach(function () {
this.user_agent = `Some ${browser} thing`
})
it('Should not set the Content-type', async function () {
await this.controller.getFile(this.req, this.res)
this.res.headers.should.deep.equal({
...expectedFileHeaders,
})
})
})
})
})
})
})
describe('getFileHead', function () {
beforeEach(function () {
this.ProjectLocator.promises.findElement.resolves({ element: this.file })
})
it('reports the file size', function (done) {
const expectedFileSize = 99393
this.FileStoreHandler.promises.getFileSize.rejects(
new Error('getFileSize: unexpected arguments')
)
this.FileStoreHandler.promises.getFileSize
.withArgs(this.projectId, this.fileId)
.resolves(expectedFileSize)
this.res.end = () => {
expect(this.res.status.lastCall.args).to.deep.equal([200])
expect(this.res.header.lastCall.args).to.deep.equal([
'Content-Length',
expectedFileSize,
])
done()
}
this.controller.getFileHead(this.req, this.res)
})
it('returns 404 on NotFoundError', function (done) {
this.FileStoreHandler.promises.getFileSize.rejects(
new Errors.NotFoundError()
)
this.res.end = () => {
expect(this.res.status.lastCall.args).to.deep.equal([404])
done()
}
this.controller.getFileHead(this.req, this.res)
})
})
})

View File

@@ -0,0 +1,722 @@
const { assert, expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const OError = require('@overleaf/o-error')
const MODULE_PATH = '../../../../app/src/Features/FileStore/FileStoreHandler.js'
describe('FileStoreHandler', function () {
beforeEach(function () {
this.fileSize = 999
this.fs = {
createReadStream: sinon.stub(),
lstat: sinon.stub().callsArgWith(1, null, {
isFile() {
return true
},
isDirectory() {
return false
},
size: this.fileSize,
}),
}
this.writeStream = {
my: 'writeStream',
on(type, fn) {
if (type === 'response') {
fn({ statusCode: 200 })
}
},
}
this.readStream = { my: 'readStream', on: sinon.stub() }
this.request = sinon.stub()
this.request.head = sinon.stub()
this.filestoreUrl = 'http://filestore.overleaf.test'
this.settings = {
apis: { filestore: { url: this.filestoreUrl } },
}
this.hashValue = '0123456789'
this.fileArgs = { name: 'upload-filename' }
this.fileId = 'file_id_here'
this.projectId = '1312312312'
this.historyId = 123
this.hashValue = '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'
this.fsPath = 'uploads/myfile.eps'
this.getFileUrl = (projectId, fileId) =>
`${this.filestoreUrl}/project/${projectId}/file/${fileId}`
this.getProjectUrl = projectId =>
`${this.filestoreUrl}/project/${projectId}`
this.FileModel = class File {
constructor(options) {
;({ name: this.name, hash: this.hash } = options)
this._id = 'file_id_here'
this.rev = 0
if (options.linkedFileData != null) {
this.linkedFileData = options.linkedFileData
}
}
}
this.FileHashManager = {
computeHash: sinon.stub().callsArgWith(1, null, this.hashValue),
}
this.HistoryManager = {
uploadBlobFromDisk: sinon.stub().callsArg(4),
}
this.ProjectDetailsHandler = {
getDetails: sinon.stub().callsArgWith(1, null, {
overleaf: { history: { id: this.historyId } },
}),
}
this.Features = {
hasFeature: sinon.stub(),
}
this.handler = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.settings,
request: this.request,
'../History/HistoryManager': this.HistoryManager,
'../Project/ProjectDetailsHandler': this.ProjectDetailsHandler,
'./FileHashManager': this.FileHashManager,
'../../infrastructure/Features': this.Features,
// FIXME: need to stub File object here
'../../models/File': {
File: this.FileModel,
},
fs: this.fs,
},
})
})
describe('uploadFileFromDisk', function () {
beforeEach(function () {
this.request.returns(this.writeStream)
})
it('should get the project details', function (done) {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.ProjectDetailsHandler.getDetails
.calledWith(this.projectId)
.should.equal(true)
done()
}
)
})
it('should compute the file hash', function (done) {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.FileHashManager.computeHash
.calledWith(this.fsPath)
.should.equal(true)
done()
}
)
})
describe('when project-history-blobs feature is enabled', function () {
it('should upload the file to the history store as a blob', function (done) {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.Features.hasFeature.withArgs('project-history-blobs').returns(true)
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.HistoryManager.uploadBlobFromDisk
.calledWith(
this.historyId,
this.hashValue,
this.fileSize,
this.fsPath
)
.should.equal(true)
done()
}
)
})
})
describe('when project-history-blobs feature is disabled', function () {
it('should not upload the file to the history store as a blob', function (done) {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.HistoryManager.uploadBlobFromDisk.called.should.equal(false)
done()
}
)
})
})
describe('when filestore feature is enabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(true)
})
it('should create read stream', function (done) {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.fs.createReadStream.calledWith(this.fsPath).should.equal(true)
done()
}
)
})
it('should pipe the read stream to request', function (done) {
this.request.returns(this.writeStream)
this.fs.createReadStream.returns({
on(type, cb) {
if (type === 'open') {
cb()
}
},
pipe: o => {
this.writeStream.should.equal(o)
done()
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {}
)
})
it('should pass the correct options to request', function (done) {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.request.args[0][0].method.should.equal('post')
this.request.args[0][0].uri.should.equal(fileUrl)
done()
}
)
})
it('should callback with the url and fileRef', function (done) {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
(err, url, fileRef) => {
expect(err).to.not.exist
expect(url).to.equal(fileUrl)
expect(fileRef._id).to.equal(this.fileId)
expect(fileRef.hash).to.equal(this.hashValue)
done()
}
)
})
describe('when upload to filestore fails', function () {
beforeEach(function () {
this.writeStream.on = function (type, fn) {
if (type === 'response') {
fn({ statusCode: 500 })
}
}
})
it('should callback with an error', function (done) {
this.fs.createReadStream.callCount = 0
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
if (type === 'open') {
cb()
}
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
err => {
expect(err).to.exist
expect(err).to.be.instanceof(Error)
expect(this.fs.createReadStream.callCount).to.equal(
this.handler.RETRY_ATTEMPTS
)
done()
}
)
})
})
})
describe('when filestore feature is disabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
it('should not open file handle', function (done) {
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
expect(this.fs.createReadStream).to.not.have.been.called
done()
}
)
})
it('should not talk to filestore', function (done) {
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
expect(this.request).to.not.have.been.called
done()
}
)
})
it('should callback with the url and fileRef', function (done) {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
(err, url, fileRef) => {
expect(err).to.not.exist
expect(url).to.equal(fileUrl)
expect(fileRef._id).to.equal(this.fileId)
expect(fileRef.hash).to.equal(this.hashValue)
done()
}
)
})
})
describe('symlink', function () {
it('should not read file if it is symlink', function (done) {
this.fs.lstat = sinon.stub().callsArgWith(1, null, {
isFile() {
return false
},
isDirectory() {
return false
},
})
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.fs.createReadStream.called.should.equal(false)
done()
}
)
})
it('should not read file stat returns nothing', function (done) {
this.fs.lstat = sinon.stub().callsArgWith(1, null, null)
this.handler.uploadFileFromDisk(
this.projectId,
this.fileArgs,
this.fsPath,
() => {
this.fs.createReadStream.called.should.equal(false)
done()
}
)
})
})
})
describe('deleteFile', function () {
it('should send a delete request to filestore api', function (done) {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.request.callsArgWith(1, null)
this.handler.deleteFile(this.projectId, this.fileId, err => {
assert.equal(err, undefined)
this.request.args[0][0].method.should.equal('delete')
this.request.args[0][0].uri.should.equal(fileUrl)
done()
})
})
it('should return the error if there is one', function (done) {
const error = 'my error'
this.request.callsArgWith(1, error)
this.handler.deleteFile(this.projectId, this.fileId, err => {
assert.equal(err, error)
done()
})
})
})
describe('deleteProject', function () {
describe('when filestore is enabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(true)
})
it('should send a delete request to filestore api', function (done) {
const projectUrl = this.getProjectUrl(this.projectId)
this.request.callsArgWith(1, null)
this.handler.deleteProject(this.projectId, err => {
assert.equal(err, undefined)
this.request.args[0][0].method.should.equal('delete')
this.request.args[0][0].uri.should.equal(projectUrl)
done()
})
})
it('should wrap the error if there is one', function (done) {
const error = new Error('my error')
this.request.callsArgWith(1, error)
this.handler.deleteProject(this.projectId, err => {
expect(OError.getFullStack(err)).to.match(
/something went wrong deleting a project in filestore/
)
expect(OError.getFullStack(err)).to.match(/my error/)
done()
})
})
})
describe('when filestore is disabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
it('should not send a delete request to filestore api', function (done) {
this.handler.deleteProject(this.projectId, err => {
assert.equal(err, undefined)
this.request.called.should.equal(false)
done()
})
})
})
})
describe('getFileStream', function () {
describe('when filestore is disabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
it('should callback with a NotFoundError', function (done) {
this.handler.getFileStream(this.projectId, this.fileId, {}, err => {
expect(err).to.be.instanceof(Errors.NotFoundError)
done()
})
})
it('should not call request', function (done) {
this.handler.getFileStream(this.projectId, this.fileId, {}, () => {
this.request.called.should.equal(false)
done()
})
})
})
describe('when filestore is enabled', function () {
beforeEach(function () {
this.query = {}
this.request.returns(this.readStream)
this.Features.hasFeature.withArgs('filestore').returns(true)
})
it('should get the stream with the correct params', function (done) {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.handler.getFileStream(
this.projectId,
this.fileId,
this.query,
(err, stream) => {
if (err) {
return done(err)
}
this.request.args[0][0].method.should.equal('get')
this.request.args[0][0].uri.should.equal(
fileUrl + '?from=getFileStream'
)
done()
}
)
})
it('should get stream from request', function (done) {
this.handler.getFileStream(
this.projectId,
this.fileId,
this.query,
(err, stream) => {
if (err) {
return done(err)
}
stream.should.equal(this.readStream)
done()
}
)
})
it('should add an error handler', function (done) {
this.handler.getFileStream(
this.projectId,
this.fileId,
this.query,
(err, stream) => {
if (err) {
return done(err)
}
stream.on.calledWith('error').should.equal(true)
done()
}
)
})
describe('when range is specified in query', function () {
beforeEach(function () {
this.query = { range: '0-10' }
})
it('should add a range header', function (done) {
this.handler.getFileStream(
this.projectId,
this.fileId,
this.query,
(err, stream) => {
if (err) {
return done(err)
}
this.request.callCount.should.equal(1)
const { headers } = this.request.firstCall.args[0]
expect(headers).to.have.keys('range')
expect(headers.range).to.equal('bytes=0-10')
done()
}
)
})
describe('when range is invalid', function () {
;['0-', '-100', 'one-two', 'nonsense'].forEach(r => {
beforeEach(function () {
this.query = { range: `${r}` }
})
it(`should not add a range header for '${r}'`, function (done) {
this.handler.getFileStream(
this.projectId,
this.fileId,
this.query,
(err, stream) => {
if (err) {
return done(err)
}
this.request.callCount.should.equal(1)
const { headers } = this.request.firstCall.args[0]
expect(headers).to.not.have.keys('range')
done()
}
)
})
})
})
})
})
})
describe('getFileSize', function () {
it('returns the file size reported by filestore', function (done) {
const expectedFileSize = 32432
const fileUrl =
this.getFileUrl(this.projectId, this.fileId) + '?from=getFileSize'
this.request.head.yields(
new Error('request.head() received unexpected arguments')
)
this.request.head.withArgs(fileUrl).yields(null, {
statusCode: 200,
headers: {
'content-length': expectedFileSize,
},
})
this.handler.getFileSize(this.projectId, this.fileId, (err, fileSize) => {
if (err) {
return done(err)
}
expect(fileSize).to.equal(expectedFileSize)
done()
})
})
it('throws a NotFoundError on a 404 from filestore', function (done) {
this.request.head.yields(null, { statusCode: 404 })
this.handler.getFileSize(this.projectId, this.fileId, err => {
expect(err).to.be.instanceof(Errors.NotFoundError)
done()
})
})
it('throws an error on a non-200 from filestore', function (done) {
this.request.head.yields(null, { statusCode: 500 })
this.handler.getFileSize(this.projectId, this.fileId, err => {
expect(err).to.be.instanceof(Error)
done()
})
})
it('rethrows errors from filestore', function (done) {
this.request.head.yields(new Error())
this.handler.getFileSize(this.projectId, this.fileId, err => {
expect(err).to.be.instanceof(Error)
done()
})
})
})
describe('copyFile', function () {
beforeEach(function () {
this.newProjectId = 'new project'
this.newFileId = 'new file id'
})
it('should post json', function (done) {
const newFileUrl = this.getFileUrl(this.newProjectId, this.newFileId)
this.request.callsArgWith(1, null, { statusCode: 200 })
this.handler.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
this.newFileId,
() => {
this.request.args[0][0].method.should.equal('put')
this.request.args[0][0].uri.should.equal(newFileUrl)
this.request.args[0][0].json.source.project_id.should.equal(
this.projectId
)
this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
done()
}
)
})
it('returns the url', function (done) {
const expectedUrl = this.getFileUrl(this.newProjectId, this.newFileId)
this.request.callsArgWith(1, null, { statusCode: 200 })
this.handler.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
this.newFileId,
(err, url) => {
if (err) {
return done(err)
}
url.should.equal(expectedUrl)
done()
}
)
})
it('should return the err', function (done) {
const error = new Error('error')
this.request.callsArgWith(1, error)
this.handler.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
this.newFileId,
err => {
err.should.equal(error)
done()
}
)
})
it('should return an error for a non-success statusCode', function (done) {
this.request.callsArgWith(1, null, { statusCode: 500 })
this.handler.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
this.newFileId,
err => {
err.should.be.an('error')
err.message.should.equal(
'non-ok response from filestore for copyFile: 500'
)
done()
}
)
})
})
})

View File

@@ -0,0 +1,134 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
describe('AuthorizationHelper', function () {
beforeEach(function () {
this.AuthorizationHelper = SandboxedModule.require(modulePath, {
requires: {
'./AdminAuthorizationHelper': (this.AdminAuthorizationHelper = {
hasAdminAccess: sinon.stub().returns(false),
}),
'../../models/User': {
UserSchema: {
obj: {
staffAccess: {
publisherMetrics: {},
publisherManagement: {},
institutionMetrics: {},
institutionManagement: {},
groupMetrics: {},
groupManagement: {},
adminMetrics: {},
},
},
},
},
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
'../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
promises: {},
}),
},
})
})
describe('hasAnyStaffAccess', function () {
it('with empty user', function () {
const user = {}
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
})
it('with no access user', function () {
const user = { isAdmin: false, staffAccess: { adminMetrics: false } }
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
})
it('with admin user', function () {
const user = { isAdmin: true }
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
})
it('with staff user', function () {
const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
})
it('with non-staff user with extra attributes', function () {
// make sure that staffAccess attributes not declared on the model don't
// give user access
const user = { staffAccess: { adminMetrics: false, somethingElse: true } }
expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
})
})
describe('isReviewerRoleEnabled', function () {
it('with no reviewers and no split test', async function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
reviewer_refs: {},
owner_ref: 'ownerId',
})
this.SplitTestHandler.promises.getAssignmentForUser = sinon
.stub()
.resolves({
variant: 'disabled',
})
expect(
await this.AuthorizationHelper.promises.isReviewerRoleEnabled(
'projectId'
)
).to.be.false
})
it('with no reviewers and enabled split test', async function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
reviewer_refs: {},
owner_ref: 'userId',
})
this.SplitTestHandler.promises.getAssignmentForUser = sinon
.stub()
.resolves({
variant: 'enabled',
})
expect(
await this.AuthorizationHelper.promises.isReviewerRoleEnabled(
'projectId'
)
).to.be.true
})
it('with reviewers and disabled split test', async function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
reviewer_refs: [{ $oid: 'userId' }],
})
this.SplitTestHandler.promises.getAssignmentForUser = sinon
.stub()
.resolves({
variant: 'default',
})
expect(
await this.AuthorizationHelper.promises.isReviewerRoleEnabled(
'projectId'
)
).to.be.true
})
it('with reviewers and enabled split test', async function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
reviewer_refs: [{ $oid: 'userId' }],
})
this.SplitTestHandler.promises.getAssignmentForUser = sinon
.stub()
.resolves({
variant: 'enabled',
})
expect(
await this.AuthorizationHelper.promises.isReviewerRoleEnabled(
'projectId'
)
).to.be.true
})
})
})

View File

@@ -0,0 +1,28 @@
const { expect } = require('chai')
const {
stringSimilarity,
} = require('../../../../app/src/Features/Helpers/DiffHelper')
describe('DiffHelper', function () {
describe('stringSimilarity', function () {
it('should have a ratio of 1 for identical strings', function () {
expect(stringSimilarity('abcdef', 'abcdef')).to.equal(1.0)
})
it('should have a ratio of 0 for completely different strings', function () {
expect(stringSimilarity('abcdef', 'qmglzxv')).to.equal(0.0)
})
it('should have a ratio of between 0 and 1 for strings that are similar', function () {
const ratio = stringSimilarity('abcdef', 'abcdef@zxvkp')
expect(ratio).to.equal(0.66)
})
it('should reject non-string inputs', function () {
expect(() => stringSimilarity(1, 'abc')).to.throw
expect(() => stringSimilarity('abc', 2)).to.throw
expect(() => stringSimilarity('abc', new Array(1000).fill('a').join('')))
.to.throw
})
})
})

View File

@@ -0,0 +1,56 @@
const { expect } = require('chai')
const {
parseEmail,
} = require('../../../../app/src/Features/Helpers/EmailHelper')
describe('EmailHelper', function () {
it('should parse a single email', function () {
const address = 'test@example.com'
const expected = 'test@example.com'
expect(parseEmail(address)).to.equal(expected)
expect(parseEmail(address, true)).to.equal(expected)
})
it('should parse a valid email address', function () {
const address = '"Test Person" <test@example.com>'
const expected = 'test@example.com'
expect(parseEmail(address)).to.equal(null)
expect(parseEmail(address, true)).to.equal(expected)
})
it('should return null for garbage input', function () {
const cases = [
undefined,
null,
'',
42,
['test@example.com'],
{},
{ length: 42 },
{ trim: true, match: true },
{ toString: true },
]
for (const input of cases) {
expect(parseEmail(input)).to.equal(null, input)
expect(parseEmail(input, true)).to.equal(null, input)
}
})
it('should return null for an invalid single email', function () {
const address = 'testexample.com'
expect(parseEmail(address)).to.equal(null)
expect(parseEmail(address, true)).to.equal(null)
})
it('should return null for an invalid email address', function () {
const address = '"Test Person" test@example.com>'
expect(parseEmail(address)).to.equal(null)
expect(parseEmail(address, true)).to.equal(null)
})
it('should return null for a group of addresses', function () {
const address = 'Group name:test1@example.com,test2@example.com;'
expect(parseEmail(address)).to.equal(null)
expect(parseEmail(address, true)).to.equal(null)
})
})

View File

@@ -0,0 +1,156 @@
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const MODULE_PATH = require('path').join(
__dirname,
'../../../../app/src/Features/Helpers/SafeHTMLSubstitution.js'
)
describe('SafeHTMLSubstitution', function () {
let SafeHTMLSubstitution
before(function () {
SafeHTMLSubstitution = SandboxedModule.require(MODULE_PATH)
})
describe('SPLIT_REGEX', function () {
const CASES = {
'PRE<0>INNER</0>POST': ['PRE', '0', 'INNER', 'POST'],
'<0>INNER</0>': ['', '0', 'INNER', ''],
'<0></0>': ['', '0', '', ''],
'<0>INNER</0><0>INNER2</0>': ['', '0', 'INNER', '', '0', 'INNER2', ''],
'<0><1>INNER</1></0>': ['', '0', '<1>INNER</1>', ''],
'PLAIN TEXT': ['PLAIN TEXT'],
}
Object.entries(CASES).forEach(([input, output]) => {
it(`should parse "${input}" as expected`, function () {
expect(input.split(SafeHTMLSubstitution.SPLIT_REGEX)).to.deep.equal(
output
)
})
})
})
describe('render', function () {
describe('substitution', function () {
it('should substitute a single component', function () {
expect(
SafeHTMLSubstitution.render('<0>good</0>', [{ name: 'b' }])
).to.equal('<b>good</b>')
})
it('should substitute a single component as string', function () {
expect(SafeHTMLSubstitution.render('<0>good</0>', ['b'])).to.equal(
'<b>good</b>'
)
})
it('should substitute a single component twice', function () {
expect(
SafeHTMLSubstitution.render('<0>one</0><0>two</0>', [{ name: 'b' }])
).to.equal('<b>one</b><b>two</b>')
})
it('should substitute two components', function () {
expect(
SafeHTMLSubstitution.render('<0>one</0><1>two</1>', [
{ name: 'b' },
{ name: 'i' },
])
).to.equal('<b>one</b><i>two</i>')
})
it('should substitute a single component with a class', function () {
expect(
SafeHTMLSubstitution.render('<0>text</0>', [
{
name: 'b',
attrs: {
class: 'magic',
},
},
])
).to.equal('<b class="magic">text</b>')
})
it('should substitute two nested components', function () {
expect(
SafeHTMLSubstitution.render('<0><1>nested</1></0>', [
{ name: 'b' },
{ name: 'i' },
])
).to.equal('<b><i>nested</i></b>')
})
it('should handle links', function () {
expect(
SafeHTMLSubstitution.render('<0>Go to Login</0>', [
{ name: 'a', attrs: { href: 'https://www.overleaf.com/login' } },
])
).to.equal('<a href="https://www.overleaf.com/login">Go to Login</a>')
})
it('should not complain about too many components', function () {
expect(
SafeHTMLSubstitution.render('<0>good</0>', [
{ name: 'b' },
{ name: 'i' },
{ name: 'u' },
])
).to.equal('<b>good</b>')
})
})
describe('pug.escape', function () {
it('should handle plain text', function () {
expect(SafeHTMLSubstitution.render('plain text')).to.equal('plain text')
})
it('should keep a simple string delimiter', function () {
expect(SafeHTMLSubstitution.render("'")).to.equal(`'`)
})
it('should escape double quotes', function () {
expect(SafeHTMLSubstitution.render('"')).to.equal(`&quot;`)
})
it('should escape &', function () {
expect(SafeHTMLSubstitution.render('&')).to.equal(`&amp;`)
})
it('should escape <', function () {
expect(SafeHTMLSubstitution.render('<')).to.equal(`&lt;`)
})
it('should escape >', function () {
expect(SafeHTMLSubstitution.render('>')).to.equal(`&gt;`)
})
it('should escape html', function () {
expect(SafeHTMLSubstitution.render('<b>bad</b>')).to.equal(
'&lt;b&gt;bad&lt;/b&gt;'
)
})
})
describe('escape around substitutions', function () {
it('should escape text inside a component', function () {
expect(
SafeHTMLSubstitution.render('<0><i>inner</i></0>', [{ name: 'b' }])
).to.equal('<b>&lt;i&gt;inner&lt;/i&gt;</b>')
})
it('should escape text in front of a component', function () {
expect(
SafeHTMLSubstitution.render('<i>PRE</i><0>inner</0>', [{ name: 'b' }])
).to.equal('&lt;i&gt;PRE&lt;/i&gt;<b>inner</b>')
})
it('should escape text after of a component', function () {
expect(
SafeHTMLSubstitution.render('<0>inner</0><i>POST</i>', [
{ name: 'b' },
])
).to.equal('<b>inner</b>&lt;i&gt;POST&lt;/i&gt;')
})
})
})
})

View File

@@ -0,0 +1,44 @@
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Helpers/UrlHelper.js'
)
describe('UrlHelper', function () {
beforeEach(function () {
this.settings = {
apis: { linkedUrlProxy: { url: undefined } },
siteUrl: 'http://127.0.0.1:3000',
}
this.UrlHelper = SandboxedModule.require(modulePath, {
requires: { '@overleaf/settings': this.settings },
})
})
describe('getSafeRedirectPath', function () {
it('sanitize redirect path to prevent open redirects', function () {
expect(this.UrlHelper.getSafeRedirectPath('https://evil.com')).to.be
.undefined
expect(this.UrlHelper.getSafeRedirectPath('//evil.com')).to.be.undefined
expect(this.UrlHelper.getSafeRedirectPath('//ol.com/evil')).to.equal(
'/evil'
)
expect(this.UrlHelper.getSafeRedirectPath('////evil.com')).to.be.undefined
expect(this.UrlHelper.getSafeRedirectPath('%2F%2Fevil.com')).to.equal(
'/%2F%2Fevil.com'
)
expect(
this.UrlHelper.getSafeRedirectPath('http://foo.com//evil.com/bad')
).to.equal('/evil.com/bad')
return expect(this.UrlHelper.getSafeRedirectPath('.evil.com')).to.equal(
'/.evil.com'
)
})
})
})

View File

@@ -0,0 +1,265 @@
const sinon = require('sinon')
const { expect } = require('chai')
const { RequestFailedError } = require('@overleaf/fetch-utils')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const modulePath = '../../../../app/src/Features/History/HistoryController'
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
describe('HistoryController', function () {
beforeEach(function () {
this.callback = sinon.stub()
this.user_id = 'user-id-123'
this.project_id = 'mock-project-id'
this.stream = sinon.stub()
this.fetchResponse = {
headers: {
get: sinon.stub(),
},
}
this.next = sinon.stub()
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user_id),
}
this.Stream = {
pipeline: sinon.stub().resolves(),
}
this.HistoryManager = {
promises: {
injectUserDetails: sinon.stub(),
},
}
this.ProjectEntityUpdateHandler = {
promises: {
resyncProjectHistory: sinon.stub().resolves(),
},
}
this.fetchJson = sinon.stub()
this.fetchStream = sinon.stub().resolves(this.stream)
this.fetchStreamWithResponse = sinon
.stub()
.resolves({ stream: this.stream, response: this.fetchResponse })
this.fetchNothing = sinon.stub().resolves()
this.HistoryController = SandboxedModule.require(modulePath, {
requires: {
'stream/promises': this.Stream,
'@overleaf/settings': (this.settings = {}),
'@overleaf/fetch-utils': {
fetchJson: this.fetchJson,
fetchStream: this.fetchStream,
fetchStreamWithResponse: this.fetchStreamWithResponse,
fetchNothing: this.fetchNothing,
},
'@overleaf/Metrics': {},
'../../infrastructure/mongodb': { ObjectId },
'../Authentication/SessionManager': this.SessionManager,
'./HistoryManager': this.HistoryManager,
'../Project/ProjectDetailsHandler': (this.ProjectDetailsHandler = {}),
'../Project/ProjectEntityUpdateHandler':
this.ProjectEntityUpdateHandler,
'../User/UserGetter': (this.UserGetter = {}),
'../Project/ProjectGetter': (this.ProjectGetter = {}),
'./RestoreManager': (this.RestoreManager = {}),
'../../infrastructure/Features': (this.Features = sinon
.stub()
.withArgs('saas')
.returns(true)),
},
})
this.settings.apis = {
project_history: {
url: 'http://project_history.example.com',
},
}
})
describe('proxyToHistoryApi', function () {
beforeEach(async function () {
this.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
this.res = {
set: sinon.stub(),
}
this.contentType = 'application/json'
this.contentLength = 212
this.fetchResponse.headers.get
.withArgs('Content-Type')
.returns(this.contentType)
this.fetchResponse.headers.get
.withArgs('Content-Length')
.returns(this.contentLength)
await this.HistoryController.proxyToHistoryApi(
this.req,
this.res,
this.next
)
})
it('should get the user id', function () {
this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
this.req.session
)
})
it('should call the project history api', function () {
this.fetchStreamWithResponse.should.have.been.calledWith(
`${this.settings.apis.project_history.url}${this.req.url}`,
{
method: this.req.method,
headers: {
'X-User-Id': this.user_id,
},
}
)
})
it('should pipe the response to the client', function () {
expect(this.Stream.pipeline).to.have.been.calledWith(
this.stream,
this.res
)
})
it('should propagate the appropriate headers', function () {
expect(this.res.set).to.have.been.calledWith(
'Content-Type',
this.contentType
)
expect(this.res.set).to.have.been.calledWith(
'Content-Length',
this.contentLength
)
})
})
describe('proxyToHistoryApiAndInjectUserDetails', function () {
beforeEach(async function () {
this.req = { url: '/mock/url', method: 'POST' }
this.res = { json: sinon.stub() }
this.data = 'mock-data'
this.dataWithUsers = 'mock-injected-data'
this.fetchJson.resolves(this.data)
this.HistoryManager.promises.injectUserDetails.resolves(
this.dataWithUsers
)
await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
this.req,
this.res,
this.next
)
})
it('should get the user id', function () {
this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
this.req.session
)
})
it('should call the project history api', function () {
this.fetchJson.should.have.been.calledWith(
`${this.settings.apis.project_history.url}${this.req.url}`,
{
method: this.req.method,
headers: {
'X-User-Id': this.user_id,
},
}
)
})
it('should inject the user data', function () {
this.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
this.data
)
})
it('should return the data with users to the client', function () {
this.res.json.should.have.been.calledWith(this.dataWithUsers)
})
})
describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
beforeEach(async function () {
this.url = '/mock/url'
this.req = { url: this.url, method: 'POST' }
this.res = { json: sinon.stub() }
this.err = new RequestFailedError(this.url, {}, { status: 500 })
this.fetchJson.rejects(this.err)
await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
this.req,
this.res,
this.next
)
})
it('should not inject the user data', function () {
this.HistoryManager.promises.injectUserDetails.should.not.have.been.called
})
it('should not return the data with users to the client', function () {
this.res.json.should.not.have.been.called
})
it('should throw an error', function () {
this.next.should.have.been.calledWith(this.err)
})
})
describe('resyncProjectHistory', function () {
describe('for a project without project-history enabled', function () {
beforeEach(async function () {
this.req = { params: { Project_id: this.project_id }, body: {} }
this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
this.error = new Errors.ProjectHistoryDisabledError()
this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
this.error
)
await this.HistoryController.resyncProjectHistory(
this.req,
this.res,
this.next
)
})
it('response with a 404', function () {
this.res.sendStatus.should.have.been.calledWith(404)
})
})
describe('for a project with project-history enabled', function () {
beforeEach(async function () {
this.req = { params: { Project_id: this.project_id }, body: {} }
this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
await this.HistoryController.resyncProjectHistory(
this.req,
this.res,
this.next
)
})
it('sets an extended response timeout', function () {
this.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
})
it('resyncs the project', function () {
this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
this.project_id
)
})
it('responds with a 204', function () {
this.res.sendStatus.should.have.been.calledWith(204)
})
})
})
})

View File

@@ -0,0 +1,303 @@
const { expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH = '../../../../app/src/Features/History/HistoryManager'
describe('HistoryManager', function () {
beforeEach(function () {
this.user_id = 'user-id-123'
this.historyId = new ObjectId().toString()
this.AuthenticationController = {
getLoggedInUserId: sinon.stub().returns(this.user_id),
}
this.FetchUtils = {
fetchJson: sinon.stub(),
fetchNothing: sinon.stub().resolves(),
}
this.projectHistoryUrl = 'http://project_history.example.com'
this.v1HistoryUrl = 'http://v1_history.example.com'
this.v1HistoryUser = 'system'
this.v1HistoryPassword = 'verysecret'
this.settings = {
apis: {
project_history: {
url: this.projectHistoryUrl,
},
v1_history: {
url: this.v1HistoryUrl,
user: this.v1HistoryUser,
pass: this.v1HistoryPassword,
},
},
}
this.UserGetter = {
promises: {
getUsersByV1Ids: sinon.stub(),
getUsers: sinon.stub(),
},
}
this.project = {
overleaf: {
history: {
id: this.historyId,
},
},
}
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.HistoryBackupDeletionHandler = {
deleteProject: sinon.stub().resolves(),
}
this.HistoryManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'../../infrastructure/mongodb': { ObjectId },
'@overleaf/fetch-utils': this.FetchUtils,
'@overleaf/settings': this.settings,
'../User/UserGetter': this.UserGetter,
'../Project/ProjectGetter': this.ProjectGetter,
'./HistoryBackupDeletionHandler': this.HistoryBackupDeletionHandler,
},
})
})
describe('initializeProject', function () {
beforeEach(function () {
this.settings.apis.project_history.initializeHistoryForNewProjects = true
})
describe('project history returns a successful response', function () {
beforeEach(async function () {
this.FetchUtils.fetchJson.resolves({ project: { id: this.historyId } })
this.result = await this.HistoryManager.promises.initializeProject(
this.historyId
)
})
it('should call the project history api', function () {
this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
`${this.settings.apis.project_history.url}/project`,
{ method: 'POST' }
)
})
it('should return the overleaf id', function () {
expect(this.result).to.equal(this.historyId)
})
})
describe('project history returns a response without the project id', function () {
it('should throw an error', async function () {
this.FetchUtils.fetchJson.resolves({ project: {} })
await expect(
this.HistoryManager.promises.initializeProject(this.historyId)
).to.be.rejected
})
})
describe('project history errors', function () {
it('should propagate the error', async function () {
this.FetchUtils.fetchJson.rejects(new Error('problem connecting'))
await expect(
this.HistoryManager.promises.initializeProject(this.historyId)
).to.be.rejected
})
})
})
describe('injectUserDetails', function () {
beforeEach(function () {
this.user1 = {
_id: (this.user_id1 = '123456'),
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@example.com',
overleaf: { id: 5011 },
}
this.user1_view = {
id: this.user_id1,
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@example.com',
}
this.user2 = {
_id: (this.user_id2 = 'abcdef'),
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
}
this.user2_view = {
id: this.user_id2,
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
}
this.UserGetter.promises.getUsersByV1Ids.resolves([this.user1])
this.UserGetter.promises.getUsers.resolves([this.user1, this.user2])
})
describe('with a diff', function () {
it('should turn user_ids into user objects', async function () {
const diff = await this.HistoryManager.promises.injectUserDetails({
diff: [
{
i: 'foo',
meta: {
users: [this.user_id1],
},
},
{
i: 'bar',
meta: {
users: [this.user_id2],
},
},
],
})
expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
})
it('should handle v1 user ids', async function () {
const diff = await this.HistoryManager.promises.injectUserDetails({
diff: [
{
i: 'foo',
meta: {
users: [5011],
},
},
{
i: 'bar',
meta: {
users: [this.user_id2],
},
},
],
})
expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
})
it('should leave user objects', async function () {
const diff = await this.HistoryManager.promises.injectUserDetails({
diff: [
{
i: 'foo',
meta: {
users: [this.user1_view],
},
},
{
i: 'bar',
meta: {
users: [this.user_id2],
},
},
],
})
expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
})
it('should handle a binary diff marker', async function () {
const diff = await this.HistoryManager.promises.injectUserDetails({
diff: { binary: true },
})
expect(diff.diff.binary).to.be.true
})
})
describe('with a list of updates', function () {
it('should turn user_ids into user objects', async function () {
const updates = await this.HistoryManager.promises.injectUserDetails({
updates: [
{
fromV: 5,
toV: 8,
meta: {
users: [this.user_id1],
},
},
{
fromV: 4,
toV: 5,
meta: {
users: [this.user_id2],
},
},
],
})
expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view])
expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view])
})
it('should leave user objects', async function () {
const updates = await this.HistoryManager.promises.injectUserDetails({
updates: [
{
fromV: 5,
toV: 8,
meta: {
users: [this.user1_view],
},
},
{
fromV: 4,
toV: 5,
meta: {
users: [this.user_id2],
},
},
],
})
expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view])
expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view])
})
})
})
describe('deleteProject', function () {
const projectId = new ObjectId()
const historyId = new ObjectId()
beforeEach(async function () {
await this.HistoryManager.promises.deleteProject(projectId, historyId)
})
it('should call the project-history service', async function () {
expect(this.FetchUtils.fetchNothing).to.have.been.calledWith(
`${this.projectHistoryUrl}/project/${projectId}`,
{ method: 'DELETE' }
)
})
it('should call the v1-history service', async function () {
expect(this.FetchUtils.fetchNothing).to.have.been.calledWith(
`${this.v1HistoryUrl}/projects/${historyId}`,
{
method: 'DELETE',
basicAuth: {
user: this.v1HistoryUser,
password: this.v1HistoryPassword,
},
}
)
})
it('should call the history-backup-deletion service', async function () {
expect(
this.HistoryBackupDeletionHandler.deleteProject
).to.have.been.calledWith(projectId)
})
})
})

View File

@@ -0,0 +1,869 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/History/RestoreManager'
)
const Errors = require('../../../../app/src/Features/Errors/Errors')
const tk = require('timekeeper')
const moment = require('moment')
const { expect } = require('chai')
describe('RestoreManager', function () {
beforeEach(function () {
tk.freeze(Date.now()) // freeze the time for these tests
this.RestoreManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': {},
'../../infrastructure/FileWriter': (this.FileWriter = { promises: {} }),
'../Uploads/FileSystemImportManager': (this.FileSystemImportManager = {
promises: {},
}),
'../Editor/EditorController': (this.EditorController = {
promises: {},
}),
'../Project/ProjectLocator': (this.ProjectLocator = { promises: {} }),
'../DocumentUpdater/DocumentUpdaterHandler':
(this.DocumentUpdaterHandler = {
promises: { flushProjectToMongo: sinon.stub().resolves() },
}),
'../Docstore/DocstoreManager': (this.DocstoreManager = {
promises: {},
}),
'../Chat/ChatApiHandler': (this.ChatApiHandler = { promises: {} }),
'../Chat/ChatManager': (this.ChatManager = { promises: {} }),
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
{}),
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
promises: {},
}),
},
})
this.user_id = 'mock-user-id'
this.project_id = 'mock-project-id'
this.version = 42
})
afterEach(function () {
tk.reset()
})
describe('restoreFileFromV2', function () {
beforeEach(function () {
this.RestoreManager.promises._writeFileVersionToDisk = sinon
.stub()
.resolves((this.fsPath = '/tmp/path/on/disk'))
this.RestoreManager.promises._findOrCreateFolder = sinon
.stub()
.resolves((this.folder_id = 'mock-folder-id'))
this.FileSystemImportManager.promises.addEntity = sinon
.stub()
.resolves((this.entity = 'mock-entity'))
})
describe('with a file not in a folder', function () {
beforeEach(async function () {
this.pathname = 'foo.tex'
this.result = await this.RestoreManager.promises.restoreFileFromV2(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should write the file version to disk', function () {
this.RestoreManager.promises._writeFileVersionToDisk
.calledWith(this.project_id, this.version, this.pathname)
.should.equal(true)
})
it('should find the root folder', function () {
this.RestoreManager.promises._findOrCreateFolder
.calledWith(this.project_id, '', this.user_id)
.should.equal(true)
})
it('should add the entity', function () {
this.FileSystemImportManager.promises.addEntity
.calledWith(
this.user_id,
this.project_id,
this.folder_id,
'foo.tex',
this.fsPath,
false
)
.should.equal(true)
})
it('should return the entity', function () {
expect(this.result).to.equal(this.entity)
})
})
describe('with a file in a folder', function () {
beforeEach(async function () {
this.pathname = 'foo/bar.tex'
await this.RestoreManager.promises.restoreFileFromV2(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should find the folder', function () {
this.RestoreManager.promises._findOrCreateFolder
.calledWith(this.project_id, 'foo', this.user_id)
.should.equal(true)
})
it('should add the entity by its basename', function () {
this.FileSystemImportManager.promises.addEntity
.calledWith(
this.user_id,
this.project_id,
this.folder_id,
'bar.tex',
this.fsPath,
false
)
.should.equal(true)
})
})
})
describe('_findOrCreateFolder', function () {
beforeEach(async function () {
this.EditorController.promises.mkdirp = sinon.stub().resolves({
newFolders: [],
lastFolder: { _id: (this.folder_id = 'mock-folder-id') },
})
this.result = await this.RestoreManager.promises._findOrCreateFolder(
this.project_id,
'folder/name',
this.user_id
)
})
it('should look up or create the folder', function () {
this.EditorController.promises.mkdirp
.calledWith(this.project_id, 'folder/name', this.user_id)
.should.equal(true)
})
it('should return the folder_id', function () {
expect(this.result).to.equal(this.folder_id)
})
})
describe('_addEntityWithUniqueName', function () {
beforeEach(function () {
this.addEntityWithName = sinon.stub()
this.name = 'foo.tex'
})
describe('with a valid name', function () {
beforeEach(async function () {
this.addEntityWithName.resolves((this.entity = 'mock-entity'))
this.result =
await this.RestoreManager.promises._addEntityWithUniqueName(
this.addEntityWithName,
this.name
)
})
it('should add the entity', function () {
this.addEntityWithName.calledWith(this.name).should.equal(true)
})
it('should return the entity', function () {
expect(this.result).to.equal(this.entity)
})
})
describe('with a duplicate name', function () {
beforeEach(async function () {
this.addEntityWithName.rejects(new Errors.DuplicateNameError())
this.addEntityWithName
.onSecondCall()
.resolves((this.entity = 'mock-entity'))
this.result =
await this.RestoreManager.promises._addEntityWithUniqueName(
this.addEntityWithName,
this.name
)
})
it('should try to add the entity with its original name', function () {
this.addEntityWithName.calledWith('foo.tex').should.equal(true)
})
it('should try to add the entity with a unique name', function () {
const date = moment(new Date()).format('Do MMM YY H:mm:ss')
this.addEntityWithName
.calledWith(`foo (Restored on ${date}).tex`)
.should.equal(true)
})
it('should return the entity', function () {
expect(this.result).to.equal(this.entity)
})
})
})
describe('revertFile', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub()
this.ProjectGetter.promises.getProject
.withArgs(this.project_id)
.resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
this.RestoreManager.promises._writeFileVersionToDisk = sinon
.stub()
.resolves((this.fsPath = '/tmp/path/on/disk'))
this.RestoreManager.promises._findOrCreateFolder = sinon
.stub()
.resolves((this.folder_id = 'mock-folder-id'))
this.FileSystemImportManager.promises.addEntity = sinon
.stub()
.resolves((this.entity = 'mock-entity'))
this.RestoreManager.promises._getRangesFromHistory = sinon
.stub()
.rejects()
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: undefined })
})
describe('reverting a project without ranges support', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
overleaf: { history: { rangesSupportEnabled: false } },
})
})
it('should throw an error', async function () {
await expect(
this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
).to.eventually.be.rejectedWith('project does not have ranges support')
})
})
describe('reverting a document with ranges', function () {
beforeEach(function () {
this.pathname = 'foo.tex'
this.comments = [
{ op: { t: 'comment-in-other-doc', p: 0, c: 'foo' } },
{ op: { t: 'single-comment', p: 10, c: 'bar' } },
{ op: { t: 'deleted-comment', p: 20, c: 'baz' } },
]
this.remappedComments = [
{ op: { t: 'duplicate-comment', p: 0, c: 'foo' } },
{ op: { t: 'single-comment', p: 10, c: 'bar' } },
]
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
this.DocstoreManager.promises.getAllRanges = sinon.stub().resolves([
{
ranges: {
comments: this.comments.slice(0, 1),
},
},
])
this.ChatApiHandler.promises.duplicateCommentThreads = sinon
.stub()
.resolves({
newThreads: {
'comment-in-other-doc': {
duplicateId: 'duplicate-comment',
},
},
})
this.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
(this.threadData = {
'single-comment': {
messages: [
{
content: 'message-content',
timestamp: '2024-01-01T00:00:00.000Z',
user_id: 'user-1',
},
],
},
'duplicate-comment': {
messages: [
{
content: 'another message',
timestamp: '2024-01-01T00:00:00.000Z',
user_id: 'user-1',
},
],
},
})
)
this.ChatManager.promises.injectUserInfoIntoThreads = sinon
.stub()
.resolves(this.threadData)
this.EditorRealTimeController.emitToRoom = sinon.stub()
this.tracked_changes = [
{
op: { pos: 4, i: 'bar' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { pos: 8, d: 'qux' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
},
]
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
this.RestoreManager.promises._getRangesFromHistory = sinon
.stub()
.resolves({
changes: this.tracked_changes,
comments: this.comments,
})
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([
{ toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
])
this.EditorController.promises.addDocWithRanges = sinon
.stub()
.resolves((this.addedFile = { _id: 'mock-doc', type: 'doc' }))
})
describe("when reverting a file that doesn't current exist", function () {
beforeEach(async function () {
this.data = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should flush the document before fetching ranges', function () {
expect(
this.DocumentUpdaterHandler.promises.flushProjectToMongo
).to.have.been.calledBefore(
this.DocstoreManager.promises.getAllRanges
)
})
it('should import the file', function () {
expect(
this.EditorController.promises.addDocWithRanges
).to.have.been.calledWith(
this.project_id,
this.folder_id,
'foo.tex',
['foo', 'bar', 'baz'],
{ changes: this.tracked_changes, comments: this.remappedComments }
)
})
it('should return the created entity', function () {
expect(this.data).to.deep.equal(this.addedFile)
})
it('should look up ranges', function () {
expect(
this.RestoreManager.promises._getRangesFromHistory
).to.have.been.calledWith(
this.project_id,
this.version,
this.pathname
)
})
})
describe('with an existing file in the current project', function () {
beforeEach(async function () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
.resolves({ type: 'file', element: { _id: 'mock-file-id' } })
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.data = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should delete the existing file', async function () {
expect(
this.EditorController.promises.deleteEntity
).to.have.been.calledWith(
this.project_id,
'mock-file-id',
'file',
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
})
describe('with an existing document in the current project', function () {
beforeEach(async function () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
.resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.data = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should delete the existing document', async function () {
expect(
this.EditorController.promises.deleteEntity
).to.have.been.calledWith(
this.project_id,
'mock-file-id',
'doc',
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
it('should delete the document before flushing', function () {
expect(
this.EditorController.promises.deleteEntity
).to.have.been.calledBefore(
this.DocumentUpdaterHandler.promises.flushProjectToMongo
)
})
it('should flush the document before fetching ranges', function () {
expect(
this.DocumentUpdaterHandler.promises.flushProjectToMongo
).to.have.been.calledBefore(
this.DocstoreManager.promises.getAllRanges
)
})
it('should import the file', function () {
expect(
this.EditorController.promises.addDocWithRanges
).to.have.been.calledWith(
this.project_id,
this.folder_id,
'foo.tex',
['foo', 'bar', 'baz'],
{ changes: this.tracked_changes, comments: this.remappedComments },
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
}
)
})
it('should return the created entity', function () {
expect(this.data).to.deep.equal(this.addedFile)
})
it('should look up ranges', function () {
expect(
this.RestoreManager.promises._getRangesFromHistory
).to.have.been.calledWith(
this.project_id,
this.version,
this.pathname
)
})
})
})
describe('reverting a file or document with metadata', function () {
beforeEach(function () {
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
this.EditorController.promises.addDocWithRanges = sinon.stub()
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([
{ toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
])
this.EditorController.promises.upsertFile = sinon
.stub()
.resolves({ _id: 'mock-file-id', type: 'file' })
this.RestoreManager.promises._getRangesFromHistory = sinon
.stub()
.resolves({
changes: [],
comments: [],
})
this.EditorController.promises.addDocWithRanges = sinon
.stub()
.resolves((this.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
this.DocstoreManager.promises.getAllRanges = sinon.stub().resolves([])
this.ChatApiHandler.promises.generateThreadData = sinon
.stub()
.resolves({})
this.ChatManager.promises.injectUserInfoIntoThreads = sinon
.stub()
.resolves({})
this.EditorRealTimeController.emitToRoom = sinon.stub()
})
describe('when reverting a linked file', function () {
beforeEach(async function () {
this.pathname = 'foo.png'
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'file' })
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: { provider: 'bar' } })
this.result = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should revert it as a file', function () {
expect(this.result).to.deep.equal({
_id: 'mock-file-id',
type: 'file',
})
})
it('should upload to the project as a file', function () {
expect(
this.EditorController.promises.upsertFile
).to.have.been.calledWith(
this.project_id,
'mock-folder-id',
'foo.png',
this.fsPath,
{ provider: 'bar' },
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
it('should not look up ranges', function () {
expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
.been.called
})
it('should not try to add a document', function () {
expect(this.EditorController.promises.addDocWithRanges).to.not.have
.been.called
})
})
describe('when reverting a linked document with provider', function () {
beforeEach(async function () {
this.pathname = 'foo.tex'
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: { provider: 'bar' } })
this.result = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should revert it as a file', function () {
expect(this.result).to.deep.equal({
_id: 'mock-file-id',
type: 'file',
})
})
it('should upload to the project as a file', function () {
expect(
this.EditorController.promises.upsertFile
).to.have.been.calledWith(
this.project_id,
'mock-folder-id',
'foo.tex',
this.fsPath,
{ provider: 'bar' },
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
it('should not look up ranges', function () {
expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
.been.called
})
it('should not try to add a document', function () {
expect(this.EditorController.promises.addDocWithRanges).to.not.have
.been.called
})
})
describe('when reverting a linked document with { main: true }', function () {
beforeEach(async function () {
this.pathname = 'foo.tex'
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: { main: true } })
this.result = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should revert it as a document', function () {
expect(this.result).to.deep.equal({
_id: 'mock-doc-id',
type: 'doc',
})
})
it('should not upload to the project as a file', function () {
expect(this.EditorController.promises.upsertFile).to.not.have.been
.called
})
it('should look up ranges', function () {
expect(this.RestoreManager.promises._getRangesFromHistory).to.have
.been.called
})
it('should add the document', function () {
expect(
this.EditorController.promises.addDocWithRanges
).to.have.been.calledWith(
this.project_id,
this.folder_id,
'foo.tex',
['foo', 'bar', 'baz'],
{ changes: [], comments: [] }
)
})
})
})
describe('when reverting a binary file', function () {
beforeEach(async function () {
this.pathname = 'foo.png'
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'file' })
this.EditorController.promises.upsertFile = sinon
.stub()
.resolves({ _id: 'mock-file-id', type: 'file' })
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
})
it('should return the created entity if file exists', async function () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
.resolves({ type: 'file', element: { _id: 'existing-file-id' } })
const revertRes = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
})
it('should return the created entity if file does not exists', async function () {
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
const revertRes = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
})
})
})
describe('revertProject', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub()
this.ProjectGetter.promises.getProject
.withArgs(this.project_id)
.resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
this.RestoreManager.promises.revertFile = sinon.stub().resolves()
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
.stub()
.resolves([])
this.ProjectEntityHandler.promises.getAllEntities = sinon
.stub()
.resolves({ docs: [], files: [] })
this.EditorController.promises.deleteEntityWithPath = sinon
.stub()
.resolves()
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([
{ toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
])
})
describe('reverting a project without ranges support', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
overleaf: { history: { rangesSupportEnabled: false } },
})
})
it('should throw an error', async function () {
await expect(
this.RestoreManager.promises.revertProject(
this.user_id,
this.project_id,
this.version
)
).to.eventually.be.rejectedWith('project does not have ranges support')
})
})
describe('for a project with overlap in current files and old files', function () {
beforeEach(async function () {
this.ProjectEntityHandler.promises.getAllEntities = sinon
.stub()
.resolves({
docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
files: [{ path: '/figures/image.png' }],
})
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
.stub()
.resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
await this.RestoreManager.promises.revertProject(
this.user_id,
this.project_id,
this.version
)
this.origin = {
kind: 'project-restore',
version: this.version,
timestamp: new Date(this.end_ts).toISOString(),
}
})
it('should delete the old files', function () {
expect(
this.EditorController.promises.deleteEntityWithPath
).to.have.been.calledWith(
this.project_id,
'new-file.tex',
this.origin,
this.user_id
)
})
it('should not delete the current files', function () {
expect(
this.EditorController.promises.deleteEntityWithPath
).to.not.have.been.calledWith(
this.project_id,
'main.tex',
this.origin,
this.user_id
)
expect(
this.EditorController.promises.deleteEntityWithPath
).to.not.have.been.calledWith(
this.project_id,
'figures/image.png',
this.origin,
this.user_id
)
})
it('should revert the old files', function () {
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
this.user_id,
this.project_id,
this.version,
'main.tex'
)
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
this.user_id,
this.project_id,
this.version,
'figures/image.png'
)
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
this.user_id,
this.project_id,
this.version,
'since-deleted.tex'
)
})
it('should not revert the current files', function () {
expect(
this.RestoreManager.promises.revertFile
).to.not.have.been.calledWith(
this.user_id,
this.project_id,
this.version,
'new-file.tex'
)
})
})
})
})

View File

@@ -0,0 +1,151 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/InactiveData/InactiveProjectManager'
)
const { ObjectId, ReadPreference } = require('mongodb-legacy')
const { expect } = require('chai')
describe('InactiveProjectManager', function () {
beforeEach(function () {
this.settings = {}
this.metrics = { inc: sinon.stub() }
this.DocstoreManager = {
promises: {
unarchiveProject: sinon.stub(),
archiveProject: sinon.stub(),
},
}
this.DocumentUpdaterHandler = {
promises: {
flushProjectToMongoAndDelete: sinon.stub(),
},
}
this.ProjectUpdateHandler = {
promises: {
markAsActive: sinon.stub(),
markAsInactive: sinon.stub(),
},
}
this.ProjectGetter = { promises: { getProject: sinon.stub() } }
this.Modules = { promises: { hooks: { fire: sinon.stub() } } }
this.InactiveProjectManager = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'@overleaf/settings': this.settings,
'@overleaf/metrics': this.metrics,
'../Docstore/DocstoreManager': this.DocstoreManager,
'../DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../Project/ProjectUpdateHandler': this.ProjectUpdateHandler,
'../Project/ProjectGetter': this.ProjectGetter,
'../../models/Project': {},
'../../infrastructure/Modules': this.Modules,
'../../infrastructure/mongodb': {
ObjectId,
READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
},
},
})
this.project_id = '1234'
})
describe('reactivateProjectIfRequired', function () {
beforeEach(function () {
this.project = { active: false }
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectUpdateHandler.promises.markAsActive.resolves()
})
it('should call unarchiveProject', async function () {
this.DocstoreManager.promises.unarchiveProject.resolves()
await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
this.project_id
)
this.DocstoreManager.promises.unarchiveProject
.calledWith(this.project_id)
.should.equal(true)
this.ProjectUpdateHandler.promises.markAsActive
.calledWith(this.project_id)
.should.equal(true)
})
it('should not mark project as active if error with unarchiving', async function () {
this.DocstoreManager.promises.unarchiveProject.rejects()
await expect(
this.InactiveProjectManager.promises.reactivateProjectIfRequired(
this.project_id
)
).to.be.rejected
this.DocstoreManager.promises.unarchiveProject
.calledWith(this.project_id)
.should.equal(true)
this.ProjectUpdateHandler.promises.markAsActive
.calledWith(this.project_id)
.should.equal(false)
})
it('should not call unarchiveProject if it is active', async function () {
this.project.active = true
this.DocstoreManager.promises.unarchiveProject.resolves()
await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
this.project_id
)
this.DocstoreManager.promises.unarchiveProject
.calledWith(this.project_id)
.should.equal(false)
this.ProjectUpdateHandler.promises.markAsActive
.calledWith(this.project_id)
.should.equal(false)
})
})
describe('deactivateProject', function () {
it('should call archiveProject and markAsInactive after flushing', async function () {
this.DocstoreManager.promises.archiveProject.resolves()
this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
this.ProjectUpdateHandler.promises.markAsInactive.resolves()
this.Modules.promises.hooks.fire.resolves()
await this.InactiveProjectManager.promises.deactivateProject(
this.project_id
)
this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
.calledWith(this.project_id)
.should.equal(true)
this.Modules.promises.hooks.fire
.calledWith('deactivateProject', this.project_id)
.should.equal(true)
this.DocstoreManager.promises.archiveProject
.calledWith(this.project_id)
.should.equal(true)
this.ProjectUpdateHandler.promises.markAsInactive
.calledWith(this.project_id)
.should.equal(true)
})
it('should not call markAsInactive if there was a problem archiving in docstore', async function () {
this.DocstoreManager.promises.archiveProject.rejects()
this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
this.ProjectUpdateHandler.promises.markAsInactive.resolves()
this.Modules.promises.hooks.fire.resolves()
await expect(
this.InactiveProjectManager.promises.deactivateProject(this.project_id)
).to.be.rejected
this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
.calledWith(this.project_id)
.should.equal(true)
this.DocstoreManager.promises.archiveProject
.calledWith(this.project_id)
.should.equal(true)
this.ProjectUpdateHandler.promises.markAsInactive
.calledWith(this.project_id)
.should.equal(false)
})
})
})

View File

@@ -0,0 +1,67 @@
const { expect } = require('chai')
const path = require('path')
const InstitutionsHelper = require(
path.join(
__dirname,
'/../../../../app/src/Features/Institutions/InstitutionsHelper'
)
)
describe('InstitutionsHelper', function () {
describe('emailHasLicence', function () {
it('returns licence', function () {
const emailHasLicence = InstitutionsHelper.emailHasLicence({
confirmedAt: new Date(),
affiliation: {
institution: { confirmed: true },
licence: 'pro_plus',
},
})
expect(emailHasLicence).to.be.true
})
it('returns false if licence is free', function () {
const emailHasLicence = InstitutionsHelper.emailHasLicence({
confirmedAt: new Date(),
affiliation: {
institution: { confirmed: true },
licence: 'free',
},
})
expect(emailHasLicence).to.be.false
})
it('returns false if licence is null', function () {
const emailHasLicence = InstitutionsHelper.emailHasLicence({
confirmedAt: new Date(),
affiliation: {
institution: { confirmed: true },
licence: null,
},
})
expect(emailHasLicence).to.be.false
})
it('returns false if institution is not confirmed', function () {
const emailHasLicence = InstitutionsHelper.emailHasLicence({
confirmedAt: new Date(),
affiliation: {
institution: { confirmed: false },
licence: 'pro_plus',
},
})
expect(emailHasLicence).to.be.false
})
it('returns false if email is not confirmed', function () {
const emailHasLicence = InstitutionsHelper.emailHasLicence({
confirmedAt: null,
affiliation: {
institution: { confirmed: true },
licence: 'pro_plus',
},
})
expect(emailHasLicence).to.be.false
})
})
})

View File

@@ -0,0 +1,423 @@
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsAPI'
)
const Errors = require('../../../../app/src/Features/Errors/Errors')
describe('InstitutionsAPI', function () {
beforeEach(function () {
this.settings = {
apis: { v1: { url: 'v1.url', user: '', pass: '', timeout: 5000 } },
}
this.request = sinon.stub()
this.fetchNothing = sinon.stub()
this.ipMatcherNotification = {
read: (this.markAsReadIpMatcher = sinon.stub().resolves()),
}
this.InstitutionsAPI = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
requestretry: this.request,
'@overleaf/fetch-utils': {
fetchNothing: this.fetchNothing,
fetchJson: (this.fetchJson = sinon.stub()),
},
'../Notifications/NotificationsBuilder': {
promises: {
ipMatcherAffiliation: sinon
.stub()
.returns(this.ipMatcherNotification),
},
},
},
})
this.stubbedUser = {
_id: '3131231',
name: 'bob',
email: 'hello@world.com',
}
this.newEmail = 'bob@bob.com'
})
describe('getInstitutionAffiliations', function () {
it('get affiliations', function (done) {
this.institutionId = 123
const responseBody = ['123abc', '456def']
this.request.yields(null, { statusCode: 200 }, responseBody)
this.InstitutionsAPI.getInstitutionAffiliations(
this.institutionId,
(err, body) => {
expect(err).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl = `v1.url/api/v2/institutions/${this.institutionId}/affiliations`
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('GET')
requestOptions.maxAttempts.should.exist
requestOptions.maxAttempts.should.not.equal(0)
requestOptions.retryDelay.should.exist
expect(requestOptions.body).not.to.exist
body.should.equal(responseBody)
done()
}
)
})
it('handle empty response', function (done) {
this.settings.apis.v1.url = ''
this.InstitutionsAPI.getInstitutionAffiliations(
this.institutionId,
(err, body) => {
expect(err).not.to.exist
expect(body).to.be.a('Array')
body.length.should.equal(0)
done()
}
)
})
})
describe('getLicencesForAnalytics', function () {
const lag = 'daily'
const queryDate = '2017-01-07:00:00.000Z'
it('should send the request to v1', function (done) {
const v1Result = {
lag: 'daily',
date: queryDate,
data: {
user_counts: { total: [], new: [] },
max_confirmation_months: [],
},
}
this.request.callsArgWith(1, null, { statusCode: 201 }, v1Result)
this.InstitutionsAPI.getLicencesForAnalytics(
lag,
queryDate,
(error, result) => {
expect(error).not.to.exist
const requestOptions = this.request.lastCall.args[0]
expect(requestOptions.body.query_date).to.equal(queryDate)
expect(requestOptions.body.lag).to.equal(lag)
requestOptions.method.should.equal('GET')
done()
}
)
})
it('should handle errors', function (done) {
this.request.callsArgWith(1, null, { statusCode: 500 })
this.InstitutionsAPI.getLicencesForAnalytics(
lag,
queryDate,
(error, result) => {
expect(error).to.be.instanceof(Errors.V1ConnectionError)
done()
}
)
})
})
describe('getUserAffiliations', function () {
it('get affiliations', function (done) {
const responseBody = [{ foo: 'bar' }]
this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
this.InstitutionsAPI.getUserAffiliations(
this.stubbedUser._id,
(err, body) => {
expect(err).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('GET')
requestOptions.maxAttempts.should.equal(3)
expect(requestOptions.body).not.to.exist
body.should.equal(responseBody)
done()
}
)
})
it('handle error', function (done) {
const body = { errors: 'affiliation error message' }
this.request.callsArgWith(1, null, { statusCode: 503 }, body)
this.InstitutionsAPI.getUserAffiliations(this.stubbedUser._id, err => {
expect(err).to.be.instanceof(Errors.V1ConnectionError)
done()
})
})
it('handle empty response', function (done) {
this.settings.apis.v1.url = ''
this.InstitutionsAPI.getUserAffiliations(
this.stubbedUser._id,
(err, body) => {
expect(err).not.to.exist
expect(body).to.be.a('Array')
body.length.should.equal(0)
done()
}
)
})
})
describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
it('get the list of users', function (done) {
this.fetchJson.resolves({ statusCode: 200 })
this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
error => {
expect(error).not.to.exist
this.fetchJson.calledOnce.should.equal(true)
const requestOptions = this.fetchJson.lastCall.args[1]
const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
this.fetchJson.lastCall.args[0].should.equal(expectedUrl)
requestOptions.method.should.equal('GET')
done()
}
)
})
it('handle error', function (done) {
this.fetchJson.throws({ info: { statusCode: 500 } })
this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
error => {
expect(error).to.exist
done()
}
)
})
})
describe('addAffiliation', function () {
beforeEach(function () {
this.fetchNothing.resolves({ status: 201 })
})
it('add affiliation', function (done) {
const affiliationOptions = {
university: { id: 1 },
department: 'Math',
role: 'Prof',
confirmedAt: new Date(),
entitlement: true,
}
this.InstitutionsAPI.addAffiliation(
this.stubbedUser._id,
this.newEmail,
affiliationOptions,
err => {
expect(err).not.to.exist
this.fetchNothing.calledOnce.should.equal(true)
const requestOptions = this.fetchNothing.lastCall.args[1]
const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
expect(this.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
requestOptions.method.should.equal('POST')
const { json } = requestOptions
Object.keys(json).length.should.equal(7)
expect(json).to.deep.equal(
Object.assign(
{ email: this.newEmail, rejectIfBlocklisted: undefined },
affiliationOptions
)
)
this.markAsReadIpMatcher.calledOnce.should.equal(true)
done()
}
)
})
it('handles 422 error', function (done) {
const messageFromApi = 'affiliation error message'
const body = JSON.stringify({ errors: messageFromApi })
this.fetchNothing.throws({ response: { status: 422 }, body })
this.InstitutionsAPI.addAffiliation(
this.stubbedUser._id,
this.newEmail,
{},
err => {
expect(err).to.exist
expect(err).to.be.instanceOf(Errors.InvalidInstitutionalEmailError)
err.message.should.have.string(422)
err.message.should.have.string(messageFromApi)
done()
}
)
})
it('handles 500 error', function (done) {
const body = { errors: 'affiliation error message' }
this.fetchNothing.throws({ response: { status: 500 }, body })
this.InstitutionsAPI.addAffiliation(
this.stubbedUser._id,
this.newEmail,
{},
err => {
expect(err).to.be.instanceOf(Errors.V1ConnectionError)
expect(err.message).to.equal('error getting affiliations from v1')
expect(err.info).to.deep.equal({ status: 500, body })
done()
}
)
})
it('uses default error message when no error body in response', function (done) {
this.fetchNothing.throws({ response: { status: 429 } })
this.InstitutionsAPI.addAffiliation(
this.stubbedUser._id,
this.newEmail,
{},
err => {
expect(err).to.exist
expect(err.message).to.equal("Couldn't create affiliation: 429")
done()
}
)
})
it('does not try to mark IP matcher notifications as read if no university passed', function (done) {
const affiliationOptions = {
confirmedAt: new Date(),
}
this.InstitutionsAPI.addAffiliation(
this.stubbedUser._id,
this.newEmail,
affiliationOptions,
err => {
expect(err).not.to.exist
expect(this.markAsReadIpMatcher.callCount).to.equal(0)
done()
}
)
})
})
describe('removeAffiliation', function () {
beforeEach(function () {
this.fetchNothing.throws({ response: { status: 404 } })
})
it('remove affiliation', function (done) {
this.InstitutionsAPI.removeAffiliation(
this.stubbedUser._id,
this.newEmail,
err => {
expect(err).not.to.exist
this.fetchNothing.calledOnce.should.equal(true)
const requestOptions = this.fetchNothing.lastCall.args[1]
const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/remove`
this.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
requestOptions.method.should.equal('POST')
expect(requestOptions.json).to.deep.equal({ email: this.newEmail })
done()
}
)
})
it('handle error', function (done) {
this.fetchNothing.throws({ response: { status: 500 } })
this.InstitutionsAPI.removeAffiliation(
this.stubbedUser._id,
this.newEmail,
err => {
expect(err).to.exist
err.message.should.exist
done()
}
)
})
})
describe('deleteAffiliations', function () {
it('delete affiliations', function (done) {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.InstitutionsAPI.deleteAffiliations(this.stubbedUser._id, err => {
expect(err).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('DELETE')
done()
})
})
it('handle error', function (done) {
const body = { errors: 'affiliation error message' }
this.request.callsArgWith(1, null, { statusCode: 518 }, body)
this.InstitutionsAPI.deleteAffiliations(this.stubbedUser._id, err => {
expect(err).to.be.instanceof(Errors.V1ConnectionError)
done()
})
})
})
describe('endorseAffiliation', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 })
})
it('endorse affiliation', function (done) {
this.InstitutionsAPI.endorseAffiliation(
this.stubbedUser._id,
this.newEmail,
'Student',
'Physics',
err => {
expect(err).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/endorse`
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('POST')
const { body } = requestOptions
Object.keys(body).length.should.equal(3)
body.email.should.equal(this.newEmail)
body.role.should.equal('Student')
body.department.should.equal('Physics')
done()
}
)
})
})
describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
const users = ['abc123', 'def456']
it('sends the list of users', function (done) {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
users,
error => {
expect(error).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl =
'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('POST')
expect(requestOptions.body).to.deep.equal({ users })
done()
}
)
})
it('handle error', function (done) {
this.request.callsArgWith(1, null, { statusCode: 500 })
this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
users,
error => {
expect(error).to.exist
done()
}
)
})
})
})

View File

@@ -0,0 +1,174 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsFeatures.js'
)
describe('InstitutionsFeatures', function () {
beforeEach(function () {
this.UserGetter = {
promises: { getUserFullEmails: sinon.stub().resolves([]) },
}
this.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
this.institutionPlanCode = 'institution_plan_code'
this.InstitutionsFeatures = SandboxedModule.require(modulePath, {
requires: {
'../User/UserGetter': this.UserGetter,
'../Subscription/PlansLocator': this.PlansLocator,
'@overleaf/settings': {
institutionPlanCode: this.institutionPlanCode,
},
},
})
this.emailDataWithLicense = [{ emailHasInstitutionLicence: true }]
this.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }]
return (this.userId = '12345abcde')
})
describe('hasLicence', function () {
it('should handle error', function (done) {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
return this.InstitutionsFeatures.hasLicence(
this.userId,
(error, hasLicence) => {
expect(error).to.exist
return done()
}
)
})
it('should return false if user has no paid affiliations', function (done) {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
return this.InstitutionsFeatures.hasLicence(
this.userId,
(error, hasLicence) => {
expect(error).to.not.exist
expect(hasLicence).to.be.false
return done()
}
)
})
it('should return true if user has confirmed paid affiliation', function (done) {
const emailData = [
{ emailHasInstitutionLicence: true },
{ emailHasInstitutionLicence: false },
]
this.UserGetter.promises.getUserFullEmails.resolves(emailData)
return this.InstitutionsFeatures.hasLicence(
this.userId,
(error, hasLicence) => {
expect(error).to.not.exist
expect(hasLicence).to.be.true
return done()
}
)
})
})
describe('getInstitutionsFeatures', function () {
beforeEach(function () {
this.testFeatures = { features: { institution: 'all' } }
return this.PlansLocator.findLocalPlanInSettings
.withArgs(this.institutionPlanCode)
.returns(this.testFeatures)
})
it('should handle error', function (done) {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
return this.InstitutionsFeatures.getInstitutionsFeatures(
this.userId,
(error, features) => {
expect(error).to.exist
return done()
}
)
})
it('should return no feaures if user has no plan code', function (done) {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
return this.InstitutionsFeatures.getInstitutionsFeatures(
this.userId,
(error, features) => {
expect(error).to.not.exist
expect(features).to.deep.equal({})
return done()
}
)
})
it('should return feaures if user has affiliations plan code', function (done) {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithLicense
)
return this.InstitutionsFeatures.getInstitutionsFeatures(
this.userId,
(error, features) => {
expect(error).to.not.exist
expect(features).to.deep.equal(this.testFeatures.features)
return done()
}
)
})
})
describe('getInstitutionsPlan', function () {
it('should handle error', function (done) {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
return this.InstitutionsFeatures.getInstitutionsPlan(
this.userId,
error => {
expect(error).to.exist
return done()
}
)
})
it('should return no plan if user has no licence', function (done) {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
return this.InstitutionsFeatures.getInstitutionsPlan(
this.userId,
(error, plan) => {
expect(error).to.not.exist
expect(plan).to.equal(null)
return done()
}
)
})
it('should return plan if user has licence', function (done) {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithLicense
)
return this.InstitutionsFeatures.getInstitutionsPlan(
this.userId,
(error, plan) => {
expect(error).to.not.exist
expect(plan).to.equal(this.institutionPlanCode)
return done()
}
)
})
})
})

View File

@@ -0,0 +1,205 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsGetter.js'
)
describe('InstitutionsGetter', function () {
beforeEach(function () {
this.UserGetter = {
getUserFullEmails: sinon.stub(),
promises: {
getUserFullEmails: sinon.stub(),
},
}
this.InstitutionsGetter = SandboxedModule.require(modulePath, {
requires: {
'../User/UserGetter': this.UserGetter,
'../UserMembership/UserMembershipsHandler':
(this.UserMembershipsHandler = {}),
'../UserMembership/UserMembershipEntityConfigs':
(this.UserMembershipEntityConfigs = {}),
},
})
this.userId = '12345abcde'
this.confirmedAffiliation = {
confirmedAt: new Date(),
affiliation: {
institution: { id: 456, confirmed: true },
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
}
this.confirmedAffiliationPastReconfirmation = {
confirmedAt: new Date('2000-01-01'),
affiliation: {
institution: { id: 135, confirmed: true },
cachedPastReconfirmDate: true,
pastReconfirmDate: true,
},
}
this.licencedAffiliation = {
confirmedAt: new Date(),
affiliation: {
licence: 'pro_plus',
institution: { id: 777, confirmed: true },
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
}
this.licencedAffiliationPastReconfirmation = {
confirmedAt: new Date('2000-01-01'),
affiliation: {
licence: 'pro_plus',
institution: { id: 888, confirmed: true },
cachedPastReconfirmDate: true,
pastReconfirmDate: true,
},
}
this.unconfirmedEmailLicensedAffiliation = {
confirmedAt: null,
affiliation: {
licence: 'pro_plus',
institution: {
id: 123,
confirmed: true,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
},
}
this.unconfirmedDomainLicensedAffiliation = {
confirmedAt: new Date(),
affiliation: {
licence: 'pro_plus',
institution: {
id: 789,
confirmed: false,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
},
}
this.userEmails = [
{
confirmedAt: null,
affiliation: {
institution: {
id: 123,
confirmed: true,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
},
},
this.confirmedAffiliation,
this.confirmedAffiliation,
this.confirmedAffiliationPastReconfirmation,
{
confirmedAt: new Date(),
affiliation: null,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
{
confirmedAt: new Date(),
affiliation: {
institution: null,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
},
{
confirmedAt: new Date(),
affiliation: {
institution: {
id: 789,
confirmed: false,
cachedPastReconfirmDate: false,
pastReconfirmDate: false,
},
},
},
]
this.fullEmailCollection = [
this.licencedAffiliation,
this.licencedAffiliation,
this.licencedAffiliationPastReconfirmation,
this.confirmedAffiliation,
this.confirmedAffiliationPastReconfirmation,
this.unconfirmedDomainLicensedAffiliation,
this.unconfirmedEmailLicensedAffiliation,
]
})
describe('getCurrentInstitutionIds', function () {
it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(this.userEmails)
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
)
expect(institutions.length).to.equal(1)
expect(institutions[0]).to.equal(456)
})
it('handles empty response', async function () {
this.UserGetter.promises.getUserFullEmails.resolves([])
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
)
expect(institutions).to.deep.equal([])
})
it('handles errors', async function () {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
let e
try {
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
)
} catch (error) {
e = error
}
expect(e.message).to.equal('oops')
})
})
describe('getCurrentAndPastAffiliationIds', function () {
it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.fullEmailCollection
)
const institutions =
await this.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
this.userId
)
expect(institutions).to.deep.equal([777, 888, 456, 135])
})
it('handles empty response', async function () {
this.UserGetter.promises.getUserFullEmails.resolves([])
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
)
expect(institutions).to.deep.equal([])
})
})
describe('getCurrentInstitutionsWithLicence', function () {
it('returns one result per institution and filters out affiliations without license', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.fullEmailCollection
)
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
this.userId
)
expect(institutions.map(institution => institution.id)).to.deep.equal([
this.licencedAffiliation.affiliation.institution.id,
])
})
})
})

View File

@@ -0,0 +1,466 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsManager'
)
const Features = require('../../../../app/src/infrastructure/Features')
describe('InstitutionsManager', function () {
beforeEach(function () {
this.institutionId = 123
this.user = {}
const lapsedUser = {
_id: '657300a08a14461b3d1aac3e',
features: {},
}
this.users = [
lapsedUser,
{ _id: '657300a08a14461b3d1aac3f', features: {} },
{ _id: '657300a08a14461b3d1aac40', features: {} },
{ _id: '657300a08a14461b3d1aac41', features: {} },
]
this.ssoUsers = [
{
_id: '657300a08a14461b3d1aac3f',
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
},
{
_id: '657300a08a14461b3d1aac40',
samlIdentifiers: [
{
providerId: this.institutionId.toString(),
hasEntitlement: true,
},
],
},
{
_id: '657300a08a14461b3d1aac3e',
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
hasEntitlement: true,
},
]
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
getUsers: sinon.stub().resolves(this.users),
getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
getSsoUsersAtInstitution: (this.getSsoUsersAtInstitution = sinon
.stub()
.resolves(this.ssoUsers)),
},
}
this.creator = { create: sinon.stub().resolves() }
this.NotificationsBuilder = {
promises: {
featuresUpgradedByAffiliation: sinon.stub().returns(this.creator),
redundantPersonalSubscription: sinon.stub().returns(this.creator),
},
}
this.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
},
}
this.institutionWithV1Data = { name: 'Wombat University' }
this.institution = {
fetchV1DataPromise: sinon.stub().resolves(this.institutionWithV1Data),
}
this.InstitutionModel = {
Institution: {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.institution),
}),
},
}
this.subscriptionExec = sinon.stub().resolves()
const SubscriptionModel = {
Subscription: {
find: () => ({
populate: () => ({
exec: this.subscriptionExec,
}),
}),
},
}
this.Mongo = {
ObjectId,
}
this.v1Counts = {
user_ids: this.users.map(user => user._id),
current_users_count: 3,
lapsed_user_ids: [lapsedUser._id],
entitled_via_sso: 1, // 2 entitled, but 1 lapsed
with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
}
this.InstitutionsManager = SandboxedModule.require(modulePath, {
requires: {
'./InstitutionsAPI': {
promises: {
addAffiliation: (this.addAffiliationPromise = sinon
.stub()
.resolves()),
getInstitutionAffiliations:
(this.getInstitutionAffiliationsPromise = sinon
.stub()
.resolves(this.affiliations)),
getConfirmedInstitutionAffiliations:
(this.getConfirmedInstitutionAffiliationsPromise = sinon
.stub()
.resolves(this.affiliations)),
getInstitutionAffiliationsCounts:
(this.getInstitutionAffiliationsCounts = sinon
.stub()
.resolves(this.v1Counts)),
},
},
'../Subscription/FeaturesUpdater': {
promises: {
refreshFeatures: (this.refreshFeaturesPromise = sinon
.stub()
.resolves()),
},
},
'../Subscription/FeaturesHelper': {
isFeatureSetBetter: (this.isFeatureSetBetter = sinon.stub()),
},
'../User/UserGetter': this.UserGetter,
'../Notifications/NotificationsBuilder': this.NotificationsBuilder,
'../Subscription/SubscriptionLocator': this.SubscriptionLocator,
'../../models/Institution': this.InstitutionModel,
'../../models/Subscription': SubscriptionModel,
'mongodb-legacy': this.Mongo,
'@overleaf/settings': {
features: { professional: { 'test-feature': true } },
},
},
})
})
describe('refreshInstitutionUsers', function () {
beforeEach(function () {
this.user1Id = '123abc123abc123abc123abc'
this.user2Id = '456def456def456def456def'
this.user3Id = '789abd789abd789abd789abd'
this.user4Id = '321cba321cba321cba321cba'
this.affiliations = [
{ user_id: this.user1Id },
{ user_id: this.user2Id },
{ user_id: this.user3Id },
{ user_id: this.user4Id },
]
this.user1 = { _id: this.user1Id }
this.user2 = { _id: this.user2Id }
this.user3 = { _id: this.user3Id }
this.user4 = { _id: this.user4Id }
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user1Id))
.resolves(this.user1)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user2Id))
.resolves(this.user2)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user3Id))
.resolves(this.user3)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user4Id))
.resolves(this.user4)
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user2)
.resolves({
planCode: 'pro',
groupPlan: false,
})
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user3)
.resolves({
planCode: 'collaborator_free_trial_7_days',
groupPlan: false,
})
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user4)
.resolves({
planCode: 'collaborator-annual',
groupPlan: true,
})
this.refreshFeaturesPromise.resolves({
newFeatures: {},
featuresChanged: false,
})
this.refreshFeaturesPromise
.withArgs(new ObjectId(this.user1Id))
.resolves({ newFeatures: {}, featuresChanged: true })
this.getInstitutionAffiliationsPromise.resolves(this.affiliations)
this.getConfirmedInstitutionAffiliationsPromise.resolves(
this.affiliations
)
})
it('refresh all users Features', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
false
)
sinon.assert.callCount(this.refreshFeaturesPromise, 4)
// expect no notifications
sinon.assert.notCalled(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.notCalled(
this.NotificationsBuilder.promises.redundantPersonalSubscription
)
})
it('notifies users if their features have been upgraded', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
true
)
sinon.assert.calledOnce(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
this.affiliations[0],
this.user1
)
})
it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
true
)
sinon.assert.calledTwice(
this.NotificationsBuilder.promises.redundantPersonalSubscription
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.redundantPersonalSubscription,
this.affiliations[1],
this.user2
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.redundantPersonalSubscription,
this.affiliations[2],
this.user3
)
})
})
describe('checkInstitutionUsers', function () {
it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function () {
if (Features.hasFeature('saas')) {
this.isFeatureSetBetter.returns(true)
const usersSummary =
await this.InstitutionsManager.promises.checkInstitutionUsers(
this.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
})
}
})
it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function () {
if (Features.hasFeature('saas')) {
this.isFeatureSetBetter.returns(true)
this.v1Counts.with_confirmed_email = 100
const usersSummary =
await this.InstitutionsManager.promises.checkInstitutionUsers(
this.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
databaseMismatch: {
withConfirmedEmail: {
v1: 100,
v2: 2,
},
},
})
}
})
})
describe('getInstitutionUsersSubscriptions', function () {
it('returns all institution users subscriptions', async function () {
const stubbedUsers = [
{ user_id: '123abc123abc123abc123abc' },
{ user_id: '456def456def456def456def' },
{ user_id: '789def789def789def789def' },
]
this.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
await this.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
this.institutionId
)
sinon.assert.calledOnce(this.subscriptionExec)
})
})
describe('addAffiliations', function () {
beforeEach(function () {
this.host = 'mit.edu'.split('').reverse().join('')
this.stubbedUser1 = {
_id: '6573014d8a14461b3d1aac3f',
name: 'bob',
email: 'hello@world.com',
emails: [
{ email: 'stubb1@mit.edu', reversedHostname: this.host },
{ email: 'test@test.com', reversedHostname: 'test.com' },
{ email: 'another@mit.edu', reversedHostname: this.host },
],
}
this.stubbedUser1DecoratedEmails = [
{
email: 'stubb1@mit.edu',
reversedHostname: this.host,
samlIdentifier: { hasEntitlement: false },
},
{ email: 'test@test.com', reversedHostname: 'test.com' },
{
email: 'another@mit.edu',
reversedHostname: this.host,
samlIdentifier: { hasEntitlement: true },
},
]
this.stubbedUser2 = {
_id: '6573014d8a14461b3d1aac40',
name: 'test',
email: 'hello2@world.com',
emails: [{ email: 'subb2@mit.edu', reversedHostname: this.host }],
}
this.stubbedUser2DecoratedEmails = [
{
email: 'subb2@mit.edu',
reversedHostname: this.host,
},
]
this.getInstitutionUsersByHostname = sinon.stub().resolves([
{
_id: this.stubbedUser1._id,
emails: this.stubbedUser1DecoratedEmails,
},
{
_id: this.stubbedUser2._id,
emails: this.stubbedUser2DecoratedEmails,
},
])
this.UserGetter.promises.getInstitutionUsersByHostname =
this.getInstitutionUsersByHostname
})
describe('affiliateUsers', function () {
it('should add affiliations for matching users', async function () {
await this.InstitutionsManager.promises.affiliateUsers('mit.edu')
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
this.addAffiliationPromise.calledThrice.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser1._id,
this.stubbedUser1.emails[0].email,
{ entitlement: false }
)
.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser1._id,
this.stubbedUser1.emails[2].email,
{ entitlement: true }
)
.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser2._id,
this.stubbedUser2.emails[0].email,
{ entitlement: undefined }
)
.should.equal(true)
this.refreshFeaturesPromise
.calledWith(this.stubbedUser1._id)
.should.equal(true)
this.refreshFeaturesPromise
.calledWith(this.stubbedUser2._id)
.should.equal(true)
this.refreshFeaturesPromise.should.have.been.calledTwice
})
it('should return errors if last affiliation cannot be added', async function () {
this.addAffiliationPromise.onCall(2).rejects()
await expect(
this.InstitutionsManager.promises.affiliateUsers('mit.edu')
).to.be.rejected
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,155 @@
import { expect } from 'chai'
import esmock from 'esmock'
import sinon from 'sinon'
const modulePath =
'../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs'
describe('LinkedFilesController', function () {
beforeEach(function () {
this.fakeTime = new Date()
this.clock = sinon.useFakeTimers(this.fakeTime.getTime())
})
afterEach(function () {
this.clock.restore()
})
beforeEach(async function () {
this.userId = 'user-id'
this.Agent = {
promises: {
createLinkedFile: sinon.stub().resolves(),
refreshLinkedFile: sinon.stub().resolves(),
},
}
this.projectId = 'projectId'
this.provider = 'provider'
this.name = 'linked-file-name'
this.data = { customAgentData: 'foo' }
this.LinkedFilesHandler = {
promises: {
getFileById: sinon.stub(),
},
}
this.AnalyticsManager = {}
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.userId),
}
this.EditorRealTimeController = {}
this.ReferencesHandler = {}
this.UrlAgent = {}
this.ProjectFileAgent = {}
this.ProjectOutputFileAgent = {}
this.EditorController = {}
this.ProjectLocator = {}
this.logger = {
error: sinon.stub(),
}
this.settings = { enabledLinkedFileTypes: [] }
this.LinkedFilesController = await esmock.strict(modulePath, {
'.../../../../app/src/Features/Authentication/SessionManager':
this.SessionManager,
'../../../../app/src/Features/Analytics/AnalyticsManager':
this.AnalyticsManager,
'../../../../app/src/Features/LinkedFiles/LinkedFilesHandler':
this.LinkedFilesHandler,
'../../../../app/src/Features/Editor/EditorRealTimeController':
this.EditorRealTimeController,
'../../../../app/src/Features/References/ReferencesHandler':
this.ReferencesHandler,
'../../../../app/src/Features/LinkedFiles/UrlAgent': this.UrlAgent,
'../../../../app/src/Features/LinkedFiles/ProjectFileAgent':
this.ProjectFileAgent,
'../../../../app/src/Features/LinkedFiles/ProjectOutputFileAgent':
this.ProjectOutputFileAgent,
'../../../../app/src/Features/Editor/EditorController':
this.EditorController,
'../../../../app/src/Features/Project/ProjectLocator':
this.ProjectLocator,
'@overleaf/logger': this.logger,
'@overleaf/settings': this.settings,
})
this.LinkedFilesController._getAgent = sinon.stub().resolves(this.Agent)
})
describe('createLinkedFile', function () {
beforeEach(function () {
this.req = {
params: { project_id: this.projectId },
body: {
name: this.name,
provider: this.provider,
data: this.data,
},
}
this.next = sinon.stub()
})
it('sets importedAt timestamp on linkedFileData', function (done) {
this.next = sinon.stub().callsFake(() => done('unexpected error'))
this.res = {
json: () => {
expect(this.Agent.promises.createLinkedFile).to.have.been.calledWith(
this.projectId,
{ ...this.data, importedAt: this.fakeTime.toISOString() },
this.name,
undefined,
this.userId
)
done()
},
}
this.LinkedFilesController.createLinkedFile(this.req, this.res, this.next)
})
})
describe('refreshLinkedFiles', function () {
beforeEach(function () {
this.data.provider = this.provider
this.file = {
name: this.name,
linkedFileData: {
...this.data,
importedAt: new Date(2020, 1, 1).toISOString(),
},
}
this.LinkedFilesHandler.promises.getFileById
.withArgs(this.projectId, 'file-id')
.resolves({
file: this.file,
path: 'fake-path',
parentFolder: {
_id: 'parent-folder-id',
},
})
this.req = {
params: { project_id: this.projectId, file_id: 'file-id' },
body: {},
}
this.next = sinon.stub()
})
it('resets importedAt timestamp on linkedFileData', function (done) {
this.next = sinon.stub().callsFake(() => done('unexpected error'))
this.res = {
json: () => {
expect(this.Agent.promises.refreshLinkedFile).to.have.been.calledWith(
this.projectId,
{
...this.data,
importedAt: this.fakeTime.toISOString(),
},
this.name,
'parent-folder-id',
this.userId
)
done()
},
}
this.LinkedFilesController.refreshLinkedFile(
this.req,
this.res,
this.next
)
})
})
})

View File

@@ -0,0 +1,163 @@
import { expect } from 'chai'
import sinon from 'sinon'
import esmock from 'esmock'
import MockResponse from '../helpers/MockResponse.js'
const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs'
describe('MetaController', function () {
beforeEach(async function () {
this.EditorRealTimeController = {
emitToRoom: sinon.stub(),
}
this.MetaHandler = {
promises: {
getAllMetaForProject: sinon.stub(),
getMetaForDoc: sinon.stub(),
},
}
this.MetadataController = await esmock.strict(modulePath, {
'../../../../app/src/Features/Editor/EditorRealTimeController':
this.EditorRealTimeController,
'../../../../app/src/Features/Metadata/MetaHandler': this.MetaHandler,
})
})
describe('getMetadata', function () {
it('should respond with json', async function () {
const projectMeta = {
'doc-id': {
labels: ['foo'],
packages: { a: { commands: [] } },
packageNames: ['a'],
},
}
this.MetaHandler.promises.getAllMetaForProject = sinon
.stub()
.resolves(projectMeta)
const req = { params: { project_id: 'project-id' } }
const res = new MockResponse()
const next = sinon.stub()
await this.MetadataController.getMetadata(req, res, next)
this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
'project-id'
)
res.json.should.have.been.calledOnceWith({
projectId: 'project-id',
projectMeta,
})
next.should.not.have.been.called
})
it('should handle an error', async function () {
this.MetaHandler.promises.getAllMetaForProject = sinon
.stub()
.throws(new Error('woops'))
const req = { params: { project_id: 'project-id' } }
const res = new MockResponse()
const next = sinon.stub()
await this.MetadataController.getMetadata(req, res, next)
this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
'project-id'
)
res.json.should.not.have.been.called
next.should.have.been.calledWithMatch(error => error instanceof Error)
})
})
describe('broadcastMetadataForDoc', function () {
it('should broadcast on broadcast:true ', async function () {
this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves({
labels: ['foo'],
packages: { a: { commands: [] } },
packageNames: ['a'],
})
this.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
body: { broadcast: true },
}
const res = new MockResponse()
const next = sinon.stub()
await this.MetadataController.broadcastMetadataForDoc(req, res, next)
this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
)
res.json.should.not.have.been.called
res.sendStatus.should.have.been.calledOnceWith(200)
next.should.not.have.been.called
this.EditorRealTimeController.emitToRoom.should.have.been.calledOnce
const { lastCall } = this.EditorRealTimeController.emitToRoom
expect(lastCall.args[0]).to.equal('project-id')
expect(lastCall.args[1]).to.equal('broadcastDocMeta')
expect(lastCall.args[2]).to.have.all.keys(['docId', 'meta'])
})
it('should return json on broadcast:false ', async function () {
const docMeta = {
labels: ['foo'],
packages: { a: [] },
packageNames: ['a'],
}
this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves(docMeta)
this.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
body: { broadcast: false },
}
const res = new MockResponse()
const next = sinon.stub()
await this.MetadataController.broadcastMetadataForDoc(req, res, next)
this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
)
this.EditorRealTimeController.emitToRoom.should.not.have.been.called
res.json.should.have.been.calledOnceWith({
docId: 'doc-id',
meta: docMeta,
})
next.should.not.have.been.called
})
it('should handle an error', async function () {
this.MetaHandler.promises.getMetaForDoc = sinon
.stub()
.throws(new Error('woops'))
this.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
body: { broadcast: true },
}
const res = new MockResponse()
const next = sinon.stub()
await this.MetadataController.broadcastMetadataForDoc(req, res, next)
this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
)
res.json.should.not.have.been.called
next.should.have.been.calledWithMatch(error => error instanceof Error)
})
})
})

View File

@@ -0,0 +1,218 @@
import { expect } from 'chai'
import sinon from 'sinon'
import esmock from 'esmock'
const modulePath = '../../../../app/src/Features/Metadata/MetaHandler.mjs'
describe('MetaHandler', function () {
beforeEach(async function () {
this.projectId = 'someprojectid'
this.docId = 'somedocid'
this.lines = [
'\\usepackage{ foo, bar }',
'\\usepackage{baz}',
'one',
'\\label{aaa}',
'two',
'commented label % \\label{bbb}', // bbb should not be in the returned labels
'\\label{ccc}%bar', // ccc should be in the returned labels
'\\label{ddd} % bar', // ddd should be in the returned labels
'\\label{ e,f,g }', // e,f,g should be in the returned labels
'\\begin{lstlisting}[label=foo, caption={Test}]', // foo should be in the returned labels
'\\begin{lstlisting}[label={lst:foo},caption={Test}]', // lst:foo should be in the returned labels
]
this.docs = {
[this.docId]: {
_id: this.docId,
lines: this.lines,
},
}
this.ProjectEntityHandler = {
promises: {
getAllDocs: sinon.stub().resolves(this.docs),
getDoc: sinon.stub().resolves(this.docs[this.docId]),
},
}
this.DocumentUpdaterHandler = {
promises: {
flushDocToMongo: sinon.stub().resolves(),
flushProjectToMongo: sinon.stub().resolves(),
},
}
this.packageMapping = {
foo: [
{
caption: '\\bar',
snippet: '\\bar',
meta: 'foo-cmd',
score: 12,
},
{
caption: '\\bat[]{}',
snippet: '\\bar[$1]{$2}',
meta: 'foo-cmd',
score: 10,
},
],
baz: [
{
caption: '\\longercommandtest{}',
snippet: '\\longercommandtest{$1}',
meta: 'baz-cmd',
score: 50,
},
],
}
this.MetaHandler = await esmock.strict(modulePath, {
'../../../../app/src/Features/Project/ProjectEntityHandler':
this.ProjectEntityHandler,
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../../../../app/src/Features/Metadata/packageMapping':
this.packageMapping,
})
})
describe('getMetaForDoc', function () {
it('should extract all the labels and packages', async function () {
const result = await this.MetaHandler.promises.getMetaForDoc(
this.projectId,
this.docId
)
expect(result).to.deep.equal({
labels: ['aaa', 'ccc', 'ddd', 'e,f,g', 'foo', 'lst:foo'],
packages: {
foo: this.packageMapping.foo,
baz: this.packageMapping.baz,
},
packageNames: ['foo', 'bar', 'baz'],
})
this.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith(
this.projectId,
this.docId
)
this.ProjectEntityHandler.promises.getDoc.should.be.calledWith(
this.projectId,
this.docId
)
})
})
describe('getAllMetaForProject', function () {
it('should extract all metadata', async function () {
this.ProjectEntityHandler.promises.getAllDocs = sinon.stub().resolves({
doc_one: {
_id: 'id_one',
lines: ['one', '\\label{aaa} two', 'three'],
},
doc_two: {
_id: 'id_two',
lines: ['four'],
},
doc_three: {
_id: 'id_three',
lines: ['\\label{bbb}', 'five six', 'seven eight \\label{ccc} nine'],
},
doc_four: {
_id: 'id_four',
lines: [
'\\usepackage[width=\\textwidth]{baz}',
'\\usepackage{amsmath}',
],
},
doc_five: {
_id: 'id_five',
lines: [
'\\usepackage{foo,baz}',
'\\usepackage[options=foo]{hello}',
'some text',
'\\section{this}\\label{sec:intro}',
'In Section \\ref{sec:intro} we saw',
'nothing',
],
},
})
const result = await this.MetaHandler.promises.getAllMetaForProject(
this.projectId
)
expect(result).to.deep.equal({
id_one: {
labels: ['aaa'],
packages: {},
packageNames: [],
},
id_two: {
labels: [],
packages: {},
packageNames: [],
},
id_three: {
labels: ['bbb', 'ccc'],
packages: {},
packageNames: [],
},
id_four: {
labels: [],
packages: {
baz: [
{
caption: '\\longercommandtest{}',
snippet: '\\longercommandtest{$1}',
meta: 'baz-cmd',
score: 50,
},
],
},
packageNames: ['baz', 'amsmath'],
},
id_five: {
labels: ['sec:intro'],
packages: {
foo: [
{
caption: '\\bar',
snippet: '\\bar',
meta: 'foo-cmd',
score: 12,
},
{
caption: '\\bat[]{}',
snippet: '\\bar[$1]{$2}',
meta: 'foo-cmd',
score: 10,
},
],
baz: [
{
caption: '\\longercommandtest{}',
snippet: '\\longercommandtest{$1}',
meta: 'baz-cmd',
score: 50,
},
],
},
packageNames: ['foo', 'baz', 'hello'],
},
})
this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.be.calledWith(
this.projectId
)
this.ProjectEntityHandler.promises.getAllDocs.should.be.calledWith(
this.projectId
)
})
})
})

View File

@@ -0,0 +1,214 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { RequestFailedError } = require('@overleaf/fetch-utils')
const SandboxedModule = require('sandboxed-module')
const MODULE_PATH = '../../../../app/src/Features/Newsletter/NewsletterManager'
describe('NewsletterManager', function () {
beforeEach('setup mocks', function () {
this.Settings = {
mailchimp: {
api_key: 'api_key',
list_id: 'list_id',
},
}
this.mailchimp = {
get: sinon.stub(),
put: sinon.stub(),
patch: sinon.stub(),
delete: sinon.stub(),
}
this.Mailchimp = sinon.stub().returns(this.mailchimp)
this.mergeFields = {
FNAME: 'Overleaf',
LNAME: 'Duck',
MONGO_ID: 'user_id',
}
this.NewsletterManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./MailChimpClient': this.Mailchimp,
'@overleaf/settings': this.Settings,
},
globals: { AbortController },
}).promises
this.NewsletterManager.get = sinon.stub()
this.NewsletterManager.delete = sinon.stub()
this.user = {
_id: 'user_id',
email: 'overleaf.duck@example.com',
first_name: 'Overleaf',
last_name: 'Duck',
}
// MD5 sum of the user email
this.emailHash = 'c02f60ed0ef51818186274e406c9a48f'
})
describe('subscribed', function () {
it('calls Mailchimp to get the user status', async function () {
await this.NewsletterManager.subscribed(this.user)
expect(this.mailchimp.get).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`
)
})
it('returns true when subscribed', async function () {
this.mailchimp.get.resolves({ status: 'subscribed' })
const subscribed = await this.NewsletterManager.subscribed(this.user)
expect(subscribed).to.be.true
})
it('returns false on 404', async function () {
this.mailchimp.get.rejects(
new RequestFailedError(
'http://some-url',
{},
{ status: 404 },
'Not found'
)
)
const subscribed = await this.NewsletterManager.subscribed(this.user)
expect(subscribed).to.be.false
})
})
describe('subscribe', function () {
it('calls Mailchimp to subscribe the user', async function () {
await this.NewsletterManager.subscribe(this.user)
expect(this.mailchimp.put).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`,
{
email_address: this.user.email,
status: 'subscribed',
status_if_new: 'subscribed',
merge_fields: this.mergeFields,
}
)
})
})
describe('unsubscribe', function () {
describe('when unsubscribing normally', function () {
it('calls Mailchimp to unsubscribe the user', async function () {
await this.NewsletterManager.unsubscribe(this.user)
expect(this.mailchimp.patch).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`,
{
status: 'unsubscribed',
merge_fields: this.mergeFields,
}
)
})
it('ignores a Mailchimp error about fake emails', async function () {
this.mailchimp.patch.rejects(
new Error(
'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
)
)
await expect(this.NewsletterManager.unsubscribe(this.user)).to.be
.fulfilled
})
it('rejects on other errors', async function () {
this.mailchimp.patch.rejects(
new Error('something really wrong is happening')
)
await expect(this.NewsletterManager.unsubscribe(this.user)).to.be
.rejected
})
})
describe('when deleting', function () {
it('calls Mailchimp to delete the user', async function () {
await this.NewsletterManager.unsubscribe(this.user, { delete: true })
expect(this.mailchimp.delete).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`
)
})
it('ignores a Mailchimp error about fake emails', async function () {
this.mailchimp.delete.rejects(
new Error(
'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
)
)
await expect(
this.NewsletterManager.unsubscribe(this.user, { delete: true })
).to.be.fulfilled
})
it('rejects on other errors', async function () {
this.mailchimp.delete.rejects(
new Error('something really wrong is happening')
)
await expect(
this.NewsletterManager.unsubscribe(this.user, { delete: true })
).to.be.rejected
})
})
})
describe('changeEmail', function () {
it('calls Mailchimp to change the subscriber email', async function () {
await this.NewsletterManager.changeEmail(
this.user,
'overleaf.squirrel@example.com'
)
expect(this.mailchimp.patch).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`,
{
email_address: 'overleaf.squirrel@example.com',
merge_fields: this.mergeFields,
}
)
})
it('deletes the old email if changing the address fails', async function () {
this.mailchimp.patch
.withArgs(`/lists/list_id/members/${this.emailHash}`, {
email_address: 'overleaf.squirrel@example.com',
merge_fields: this.mergeFields,
})
.rejects(new Error('that did not work'))
await expect(
this.NewsletterManager.changeEmail(
this.user,
'overleaf.squirrel@example.com'
)
).to.be.rejected
expect(this.mailchimp.delete).to.have.been.calledWith(
`/lists/list_id/members/${this.emailHash}`
)
})
it('does not reject on non-fatal error ', async function () {
const nonFatalError = new Error('merge fields were invalid')
this.mailchimp.patch.rejects(nonFatalError)
await expect(
this.NewsletterManager.changeEmail(
this.user,
'overleaf.squirrel@example.com'
)
).to.be.fulfilled
})
it('rejects on any other error', async function () {
const fatalError = new Error('fatal error')
this.mailchimp.patch.rejects(fatalError)
await expect(
this.NewsletterManager.changeEmail(
this.user,
'overleaf.squirrel@example.com'
)
).to.be.rejected
})
})
})

View File

@@ -0,0 +1,170 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Notifications/NotificationsBuilder.js'
)
describe('NotificationsBuilder', function () {
const userId = '123nd3ijdks'
beforeEach(function () {
this.handler = { createNotification: sinon.stub().callsArgWith(6) }
this.settings = { apis: { v1: { url: 'v1.url', user: '', pass: '' } } }
this.request = sinon.stub()
this.controller = SandboxedModule.require(modulePath, {
requires: {
'./NotificationsHandler': this.handler,
'@overleaf/settings': this.settings,
request: this.request,
},
})
})
describe('dropboxUnlinkedDueToLapsedReconfirmation', function (done) {
it('should create the notification', function (done) {
this.controller
.dropboxUnlinkedDueToLapsedReconfirmation(userId)
.create(error => {
expect(error).to.not.exist
expect(this.handler.createNotification).to.have.been.calledWith(
userId,
'drobox-unlinked-due-to-lapsed-reconfirmation',
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
{},
null,
true
)
done()
})
})
describe('NotificationsHandler error', function () {
let anError
beforeEach(function () {
anError = new Error('oops')
this.handler.createNotification.yields(anError)
})
it('should return errors from NotificationsHandler', function (done) {
this.controller
.dropboxUnlinkedDueToLapsedReconfirmation(userId)
.create(error => {
expect(error).to.exist
expect(error).to.deep.equal(anError)
done()
})
})
})
})
describe('groupInvitation', function (done) {
const subscriptionId = '123123bcabca'
beforeEach(function () {
this.invite = {
token: '123123abcabc',
inviterName: 'Mr Overleaf',
managedUsersEnabled: false,
}
})
it('should create the notification', function (done) {
this.controller
.groupInvitation(
userId,
subscriptionId,
this.invite.managedUsersEnabled
)
.create(this.invite, error => {
expect(error).to.not.exist
expect(this.handler.createNotification).to.have.been.calledWith(
userId,
`groupInvitation-${subscriptionId}-${userId}`,
'notification_group_invitation',
{
token: this.invite.token,
inviterName: this.invite.inviterName,
managedUsersEnabled: this.invite.managedUsersEnabled,
},
null,
true
)
done()
})
})
})
describe('ipMatcherAffiliation', function () {
describe('with portal and with SSO', function () {
beforeEach(function () {
this.body = {
id: 1,
name: 'stanford',
enrolment_ad_html: 'v1 ad content',
is_university: true,
portal_slug: null,
sso_enabled: false,
}
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
})
it('should call v1 and create affiliation notifications', function (done) {
const ip = '192.168.0.1'
this.controller.ipMatcherAffiliation(userId).create(ip, callback => {
this.request.calledOnce.should.equal(true)
const expectedOpts = {
institutionId: this.body.id,
university_name: this.body.name,
content: this.body.enrolment_ad_html,
ssoEnabled: false,
portalPath: undefined,
}
this.handler.createNotification
.calledWith(
userId,
`ip-matched-affiliation-${this.body.id}`,
'notification_ip_matched_affiliation',
expectedOpts
)
.should.equal(true)
done()
})
})
})
describe('without portal and without SSO', function () {
beforeEach(function () {
this.body = {
id: 1,
name: 'stanford',
enrolment_ad_html: 'v1 ad content',
is_university: true,
portal_slug: 'stanford',
sso_enabled: true,
}
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
})
it('should call v1 and create affiliation notifications', function (done) {
const ip = '192.168.0.1'
this.controller.ipMatcherAffiliation(userId).create(ip, callback => {
this.request.calledOnce.should.equal(true)
const expectedOpts = {
institutionId: this.body.id,
university_name: this.body.name,
content: this.body.enrolment_ad_html,
ssoEnabled: true,
portalPath: '/edu/stanford',
}
this.handler.createNotification
.calledWith(
userId,
`ip-matched-affiliation-${this.body.id}`,
'notification_ip_matched_affiliation',
expectedOpts
)
.should.equal(true)
done()
})
})
})
})
})

View File

@@ -0,0 +1,66 @@
import esmock from 'esmock'
import sinon from 'sinon'
const modulePath = new URL(
'../../../../app/src/Features/Notifications/NotificationsController.mjs',
import.meta.url
).pathname
describe('NotificationsController', function () {
const userId = '123nd3ijdks'
const notificationId = '123njdskj9jlk'
beforeEach(async function () {
this.handler = {
getUserNotifications: sinon.stub().callsArgWith(1),
markAsRead: sinon.stub().callsArgWith(2),
}
this.req = {
params: {
notificationId,
},
session: {
user: {
_id: userId,
},
},
i18n: {
translate() {},
},
}
this.AuthenticationController = {
getLoggedInUserId: sinon.stub().returns(this.req.session.user._id),
}
this.controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Notifications/NotificationsHandler':
this.handler,
'../../../../app/src/Features/Authentication/AuthenticationController':
this.AuthenticationController,
})
})
it('should ask the handler for all unread notifications', function (done) {
const allNotifications = [{ _id: notificationId, user_id: userId }]
this.handler.getUserNotifications = sinon
.stub()
.callsArgWith(1, null, allNotifications)
this.controller.getAllUnreadNotifications(this.req, {
json: body => {
body.should.deep.equal(allNotifications)
this.handler.getUserNotifications.calledWith(userId).should.equal(true)
done()
},
})
})
it('should send a delete request when a delete has been received to mark a notification', function (done) {
this.controller.markNotificationAsRead(this.req, {
sendStatus: () => {
this.handler.markAsRead
.calledWith(userId, notificationId)
.should.equal(true)
done()
},
})
})
})

View File

@@ -0,0 +1,179 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const { assert } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Notifications/NotificationsHandler.js'
)
const _ = require('lodash')
describe('NotificationsHandler', function () {
const userId = '123nd3ijdks'
const notificationId = '123njdskj9jlk'
const notificationUrl = 'notification.overleaf.testing'
beforeEach(function () {
this.request = sinon.stub().callsArgWith(1)
return (this.handler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': {
apis: { notifications: { url: notificationUrl } },
},
request: this.request,
},
}))
})
describe('getUserNotifications', function () {
it('should get unread notifications', function (done) {
const stubbedNotifications = [{ _id: notificationId, user_id: userId }]
this.request.callsArgWith(
1,
null,
{ statusCode: 200 },
stubbedNotifications
)
return this.handler.getUserNotifications(
userId,
(err, unreadNotifications) => {
stubbedNotifications.should.deep.equal(unreadNotifications)
const getOpts = {
uri: `${notificationUrl}/user/${userId}`,
json: true,
timeout: 1000,
method: 'GET',
}
this.request.calledWith(getOpts).should.equal(true)
return done()
}
)
})
it('should return empty arrays if there are no notifications', function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, null)
return this.handler.getUserNotifications(
userId,
(err, unreadNotifications) => {
return unreadNotifications.length.should.equal(0)
}
)
})
})
describe('markAsRead', function () {
beforeEach(function () {
return (this.key = 'some key here')
})
it('should send a delete request when a delete has been received to mark a notification', function (done) {
return this.handler.markAsReadWithKey(userId, this.key, () => {
const opts = {
uri: `${notificationUrl}/user/${userId}`,
json: {
key: this.key,
},
timeout: 1000,
method: 'DELETE',
}
this.request.calledWith(opts).should.equal(true)
return done()
})
})
})
describe('createNotification', function () {
beforeEach(function () {
this.key = 'some key here'
this.messageOpts = { value: 12344 }
this.templateKey = 'renderThisHtml'
return (this.expiry = null)
})
it('should post the message over', function (done) {
return this.handler.createNotification(
userId,
this.key,
this.templateKey,
this.messageOpts,
this.expiry,
() => {
const args = this.request.args[0][0]
args.uri.should.equal(`${notificationUrl}/user/${userId}`)
args.timeout.should.equal(1000)
const expectedJson = {
key: this.key,
templateKey: this.templateKey,
messageOpts: this.messageOpts,
forceCreate: true,
}
assert.deepEqual(args.json, expectedJson)
return done()
}
)
})
describe('when expiry date is supplied', function () {
beforeEach(function () {
this.key = 'some key here'
this.messageOpts = { value: 12344 }
this.templateKey = 'renderThisHtml'
return (this.expiry = new Date())
})
it('should post the message over with expiry field', function (done) {
return this.handler.createNotification(
userId,
this.key,
this.templateKey,
this.messageOpts,
this.expiry,
() => {
const args = this.request.args[0][0]
args.uri.should.equal(`${notificationUrl}/user/${userId}`)
args.timeout.should.equal(1000)
const expectedJson = {
key: this.key,
templateKey: this.templateKey,
messageOpts: this.messageOpts,
expires: this.expiry,
forceCreate: true,
}
assert.deepEqual(args.json, expectedJson)
return done()
}
)
})
})
})
describe('markAsReadByKeyOnly', function () {
beforeEach(function () {
return (this.key = 'some key here')
})
it('should send a delete request when a delete has been received to mark a notification', function (done) {
return this.handler.markAsReadByKeyOnly(this.key, () => {
const opts = {
uri: `${notificationUrl}/key/${this.key}`,
timeout: 1000,
method: 'DELETE',
}
this.request.calledWith(opts).should.equal(true)
return done()
})
})
})
})

View File

@@ -0,0 +1,485 @@
import esmock from 'esmock'
import sinon from 'sinon'
import { expect } from 'chai'
import MockResponse from '../helpers/MockResponse.js'
const MODULE_PATH = new URL(
'../../../../app/src/Features/PasswordReset/PasswordResetController.mjs',
import.meta.url
).pathname
describe('PasswordResetController', function () {
beforeEach(async function () {
this.email = 'bob@bob.com'
this.user_id = 'mock-user-id'
this.token = 'my security token that was emailed to me'
this.password = 'my new password'
this.req = {
body: {
email: this.email,
passwordResetToken: this.token,
password: this.password,
},
i18n: {
translate() {
return '.'
},
},
session: {},
query: {},
}
this.res = new MockResponse()
this.settings = {}
this.PasswordResetHandler = {
generateAndEmailResetToken: sinon.stub(),
promises: {
generateAndEmailResetToken: sinon.stub(),
setNewUserPassword: sinon.stub().resolves({
found: true,
reset: true,
userID: this.user_id,
mustReconfirm: true,
}),
getUserForPasswordResetToken: sinon
.stub()
.withArgs(this.token)
.resolves({
user: { _id: this.user_id },
remainingPeeks: 1,
}),
},
}
this.UserSessionsManager = {
promises: {
removeSessionsFromRedis: sinon.stub().resolves(),
},
}
this.UserUpdater = {
promises: {
removeReconfirmFlag: sinon.stub().resolves(),
},
}
this.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves('default'),
},
}
this.PasswordResetController = await esmock.strict(MODULE_PATH, {
'@overleaf/settings': this.settings,
'../../../../app/src/Features/PasswordReset/PasswordResetHandler':
this.PasswordResetHandler,
'../../../../app/src/Features/Authentication/AuthenticationManager': {
validatePassword: sinon.stub().returns(null),
},
'../../../../app/src/Features/Authentication/AuthenticationController':
(this.AuthenticationController = {
getLoggedInUserId: sinon.stub(),
finishLogin: sinon.stub(),
setAuditInfo: sinon.stub(),
}),
'../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
promises: {
getUser: sinon.stub(),
},
}),
'../../../../app/src/Features/User/UserSessionsManager':
this.UserSessionsManager,
'../../../../app/src/Features/User/UserUpdater': this.UserUpdater,
'../../../../app/src/Features/SplitTests/SplitTestHandler':
this.SplitTestHandler,
})
})
describe('requestReset', function () {
it('should tell the handler to process that email', function (done) {
this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
'primary'
)
this.res.callback = () => {
this.res.statusCode.should.equal(200)
this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
expect(
this.PasswordResetHandler.promises.generateAndEmailResetToken.lastCall
.args[0]
).equal(this.email)
done()
}
this.PasswordResetController.requestReset(this.req, this.res)
})
it('should send a 500 if there is an error', function (done) {
this.PasswordResetHandler.promises.generateAndEmailResetToken.rejects(
new Error('error')
)
this.PasswordResetController.requestReset(this.req, this.res, error => {
expect(error).to.exist
done()
})
})
it("should send a 404 if the email doesn't exist", function (done) {
this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
null
)
this.res.callback = () => {
this.res.statusCode.should.equal(404)
this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
done()
}
this.PasswordResetController.requestReset(this.req, this.res)
})
it('should send a 404 if the email is registered as a secondard email', function (done) {
this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
'secondary'
)
this.res.callback = () => {
this.res.statusCode.should.equal(404)
this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
done()
}
this.PasswordResetController.requestReset(this.req, this.res)
})
it('should normalize the email address', function (done) {
this.email = ' UPperCaseEMAILWithSpacesAround@example.Com '
this.req.body.email = this.email
this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
'primary'
)
this.res.callback = () => {
this.res.statusCode.should.equal(200)
this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
done()
}
this.PasswordResetController.requestReset(this.req, this.res)
})
})
describe('setNewUserPassword', function () {
beforeEach(function () {
this.req.session.resetToken = this.token
})
it('should tell the user handler to reset the password', function (done) {
this.res.sendStatus = code => {
code.should.equal(200)
this.PasswordResetHandler.promises.setNewUserPassword
.calledWith(this.token, this.password)
.should.equal(true)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should preserve spaces in the password', function (done) {
this.password = this.req.body.password = ' oh! clever! spaces around! '
this.res.sendStatus = code => {
code.should.equal(200)
this.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith(
this.token,
this.password
)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should send 404 if the token was not found', function (done) {
this.PasswordResetHandler.promises.setNewUserPassword.resolves({
found: false,
reset: false,
userId: this.user_id,
})
this.res.status = code => {
code.should.equal(404)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('token-expired')
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 500 if not reset', function (done) {
this.PasswordResetHandler.promises.setNewUserPassword.resolves({
found: true,
reset: false,
userId: this.user_id,
})
this.res.status = code => {
code.should.equal(500)
return this.res
}
this.res.json = data => {
expect(data.message).to.exist
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 400 (Bad Request) if there is no password', function (done) {
this.req.body.password = ''
this.res.status = code => {
code.should.equal(400)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('invalid-password')
this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
false
)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 400 (Bad Request) if there is no passwordResetToken', function (done) {
this.req.body.passwordResetToken = ''
this.res.status = code => {
code.should.equal(400)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('invalid-password')
this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
false
)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 400 (Bad Request) if the password is invalid', function (done) {
this.req.body.password = 'correct horse battery staple'
const err = new Error('bad')
err.name = 'InvalidPasswordError'
this.PasswordResetHandler.promises.setNewUserPassword.rejects(err)
this.res.status = code => {
code.should.equal(400)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('invalid-password')
this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
true
)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should clear sessions', function (done) {
this.res.sendStatus = code => {
this.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal(
1
)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should call removeReconfirmFlag if user.must_reconfirm', function (done) {
this.res.sendStatus = code => {
this.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
describe('catch errors', function () {
it('should return 404 for NotFoundError', function (done) {
const anError = new Error('oops')
anError.name = 'NotFoundError'
this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
this.res.status = code => {
code.should.equal(404)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('token-expired')
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 400 for InvalidPasswordError', function (done) {
const anError = new Error('oops')
anError.name = 'InvalidPasswordError'
this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
this.res.status = code => {
code.should.equal(400)
return this.res
}
this.res.json = data => {
data.message.key.should.equal('invalid-password')
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
it('should return 500 for other errors', function (done) {
const anError = new Error('oops')
this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
this.res.status = code => {
code.should.equal(500)
return this.res
}
this.res.json = data => {
expect(data.message).to.exist
done()
}
this.res.sendStatus = code => {
code.should.equal(500)
done()
}
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
})
describe('when doLoginAfterPasswordReset is set', function () {
beforeEach(function () {
this.user = {
_id: this.userId,
email: 'joe@example.com',
}
this.UserGetter.promises.getUser.resolves(this.user)
this.req.session.doLoginAfterPasswordReset = 'true'
})
it('should login user', function (done) {
this.AuthenticationController.finishLogin.callsFake((...args) => {
expect(args[0]).to.equal(this.user)
done()
})
this.PasswordResetController.setNewUserPassword(this.req, this.res)
})
})
})
describe('renderSetPasswordForm', function () {
describe('with token in query-string', function () {
beforeEach(function () {
this.req.query.passwordResetToken = this.token
})
it('should set session.resetToken and redirect', function (done) {
this.req.session.should.not.have.property('resetToken')
this.res.redirect = path => {
path.should.equal('/user/password/set')
this.req.session.resetToken.should.equal(this.token)
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('with expired token in query', function () {
beforeEach(function () {
this.req.query.passwordResetToken = this.token
this.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon
.stub()
.withArgs(this.token)
.resolves({ user: { _id: this.user_id }, remainingPeeks: 0 })
})
it('should redirect to the reset request page with an error message', function (done) {
this.res.redirect = path => {
path.should.equal('/user/password/reset?error=token_expired')
this.req.session.should.not.have.property('resetToken')
done()
}
this.res.render = (templatePath, options) => {
done('should not render')
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('with token and email in query-string', function () {
beforeEach(function () {
this.req.query.passwordResetToken = this.token
this.req.query.email = 'foo@bar.com'
})
it('should set session.resetToken and redirect with email', function (done) {
this.req.session.should.not.have.property('resetToken')
this.res.redirect = path => {
path.should.equal('/user/password/set?email=foo%40bar.com')
this.req.session.resetToken.should.equal(this.token)
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('with token and invalid email in query-string', function () {
beforeEach(function () {
this.req.query.passwordResetToken = this.token
this.req.query.email = 'not-an-email'
})
it('should set session.resetToken and redirect without email', function (done) {
this.req.session.should.not.have.property('resetToken')
this.res.redirect = path => {
path.should.equal('/user/password/set')
this.req.session.resetToken.should.equal(this.token)
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('with token and non-string email in query-string', function () {
beforeEach(function () {
this.req.query.passwordResetToken = this.token
this.req.query.email = { foo: 'bar' }
})
it('should set session.resetToken and redirect without email', function (done) {
this.req.session.should.not.have.property('resetToken')
this.res.redirect = path => {
path.should.equal('/user/password/set')
this.req.session.resetToken.should.equal(this.token)
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('without a token in query-string', function () {
describe('with token in session', function () {
beforeEach(function () {
this.req.session.resetToken = this.token
})
it('should render the page, passing the reset token', function (done) {
this.res.render = (templatePath, options) => {
options.passwordResetToken.should.equal(this.token)
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
it('should clear the req.session.resetToken', function (done) {
this.res.render = (templatePath, options) => {
this.req.session.should.not.have.property('resetToken')
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
describe('without a token in session', function () {
it('should redirect to the reset request page', function (done) {
this.res.redirect = path => {
path.should.equal('/user/password/reset')
this.req.session.should.not.have.property('resetToken')
done()
}
this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
})
})
})
})
})

View File

@@ -0,0 +1,563 @@
import esmock from 'esmock'
import sinon from 'sinon'
import { expect } from 'chai'
const modulePath = new URL(
'../../../../app/src/Features/PasswordReset/PasswordResetHandler',
import.meta.url
).pathname
describe('PasswordResetHandler', function () {
beforeEach(async function () {
this.settings = { siteUrl: 'https://www.overleaf.com' }
this.OneTimeTokenHandler = {
promises: {
getNewToken: sinon.stub(),
peekValueFromToken: sinon.stub(),
},
peekValueFromToken: sinon.stub(),
expireToken: sinon.stub(),
}
this.UserGetter = {
getUserByMainEmail: sinon.stub(),
getUser: sinon.stub(),
promises: {
getUserByAnyEmail: sinon.stub(),
getUserByMainEmail: sinon.stub(),
},
}
this.EmailHandler = { promises: { sendEmail: sinon.stub() } }
this.AuthenticationManager = {
setUserPasswordInV2: sinon.stub(),
promises: {
setUserPassword: sinon.stub().resolves(),
},
}
this.PasswordResetHandler = await esmock.strict(modulePath, {
'../../../../app/src/Features/User/UserAuditLogHandler':
(this.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}),
'../../../../app/src/Features/User/UserGetter': this.UserGetter,
'../../../../app/src/Features/Security/OneTimeTokenHandler':
this.OneTimeTokenHandler,
'../../../../app/src/Features/Email/EmailHandler': this.EmailHandler,
'../../../../app/src/Features/Authentication/AuthenticationManager':
this.AuthenticationManager,
'@overleaf/settings': this.settings,
'../../../../app/src/Features/Authorization/PermissionsManager':
(this.PermissionsManager = {
promises: {
assertUserPermissions: sinon.stub(),
},
}),
})
this.token = '12312321i'
this.user_id = 'user_id_here'
this.user = { email: (this.email = 'bob@bob.com'), _id: this.user_id }
this.password = 'my great secret password'
this.callback = sinon.stub()
// this should not have any effect now
this.settings.overleaf = true
})
afterEach(function () {
this.settings.overleaf = false
})
describe('generateAndEmailResetToken', function () {
it('should check the user exists', function () {
this.UserGetter.promises.getUserByAnyEmail.resolves()
this.PasswordResetHandler.generateAndEmailResetToken(
this.user.email,
this.callback
)
this.UserGetter.promises.getUserByAnyEmail.should.have.been.calledWith(
this.user.email
)
})
it('should send the email with the token', function (done) {
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token)
this.EmailHandler.promises.sendEmail.resolves()
this.PasswordResetHandler.generateAndEmailResetToken(
this.user.email,
(err, status) => {
expect(err).to.not.exist
this.EmailHandler.promises.sendEmail.called.should.equal(true)
status.should.equal('primary')
const args = this.EmailHandler.promises.sendEmail.args[0]
args[0].should.equal('passwordResetRequested')
args[1].setNewPasswordUrl.should.equal(
`${this.settings.siteUrl}/user/password/set?passwordResetToken=${
this.token
}&email=${encodeURIComponent(this.user.email)}`
)
done()
}
)
})
it('should return errors from getUserByAnyEmail', function (done) {
const err = new Error('oops')
this.UserGetter.promises.getUserByAnyEmail.rejects(err)
this.PasswordResetHandler.generateAndEmailResetToken(
this.user.email,
err => {
expect(err).to.equal(err)
done()
}
)
})
describe('when the email exists', function () {
let result
beforeEach(async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token)
this.EmailHandler.promises.sendEmail.resolves()
result =
await this.PasswordResetHandler.promises.generateAndEmailResetToken(
this.email
)
})
it('should set the password token data to the user id and email', function () {
this.OneTimeTokenHandler.promises.getNewToken.should.have.been.calledWith(
'password',
{
email: this.email,
user_id: this.user._id,
}
)
})
it('should send an email with the token', function () {
this.EmailHandler.promises.sendEmail.called.should.equal(true)
const args = this.EmailHandler.promises.sendEmail.args[0]
args[0].should.equal('passwordResetRequested')
args[1].setNewPasswordUrl.should.equal(
`${this.settings.siteUrl}/user/password/set?passwordResetToken=${
this.token
}&email=${encodeURIComponent(this.user.email)}`
)
})
it('should return status == true', async function () {
expect(result).to.equal('primary')
})
})
describe("when the email doesn't exist", function () {
let result
beforeEach(async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(null)
result =
await this.PasswordResetHandler.promises.generateAndEmailResetToken(
this.email
)
})
it('should not set the password token data', function () {
this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
})
it('should send an email with the token', function () {
this.EmailHandler.promises.sendEmail.called.should.equal(false)
})
it('should return status == null', function () {
expect(result).to.equal(null)
})
})
describe('when the email is a secondary email', function () {
let result
beforeEach(async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
result =
await this.PasswordResetHandler.promises.generateAndEmailResetToken(
'secondary@email.com'
)
})
it('should not set the password token data', function () {
this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
})
it('should not send an email with the token', function () {
this.EmailHandler.promises.sendEmail.called.should.equal(false)
})
it('should return status == secondary', function () {
expect(result).to.equal('secondary')
})
})
})
describe('setNewUserPassword', function () {
beforeEach(function () {
this.auditLog = { ip: '0:0:0:0' }
})
describe('when no data is found', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken.resolves(null)
})
it('should return found == false and reset == false', function () {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, result) => {
expect(error).to.not.exist
expect(result).to.deep.equal({
found: false,
reset: false,
userId: null,
})
}
)
})
})
describe('when the token has a user_id and email', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
data: {
user_id: this.user._id,
email: this.email,
},
})
this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user, this.password)
.resolves(true)
this.OneTimeTokenHandler.expireToken = sinon
.stub()
.callsArgWith(2, null)
})
describe('when no user is found with this email', function () {
beforeEach(function () {
this.UserGetter.getUserByMainEmail
.withArgs(this.email)
.yields(null, null)
})
it('should return found == false and reset == false', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { found, reset } = result
expect(err).to.not.exist
expect(found).to.be.false
expect(reset).to.be.false
expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
done()
}
)
})
})
describe("when the email and user don't match", function () {
beforeEach(function () {
this.UserGetter.getUserByMainEmail
.withArgs(this.email)
.yields(null, { _id: 'not-the-same', email: this.email })
this.OneTimeTokenHandler.expireToken.callsArgWith(2, null)
})
it('should return found == false and reset == false', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { found, reset } = result
expect(err).to.not.exist
expect(found).to.be.false
expect(reset).to.be.false
expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
done()
}
)
})
})
describe('when the email and user match', function () {
describe('success', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByMainEmail.resolves(this.user)
this.OneTimeTokenHandler.expireToken = sinon
.stub()
.callsArgWith(2, null)
})
it('should update the user audit log', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, result) => {
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.user_id,
'reset-password',
undefined,
this.auditLog.ip,
{ token: this.token.substring(0, 10) }
)
expect(error).to.not.exist
done()
}
)
})
it('should return reset == true and the user id', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { reset, userId } = result
expect(err).to.not.exist
expect(reset).to.be.true
expect(userId).to.equal(this.user._id)
done()
}
)
})
it('should expire the token', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(_err, _result) => {
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
true
)
done()
}
)
})
describe('when logged in', function () {
beforeEach(function () {
this.auditLog.initiatorId = this.user_id
})
it('should update the user audit log with initiatorId', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, result) => {
expect(error).to.not.exist
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.user_id,
'reset-password',
this.user_id,
this.auditLog.ip,
{ token: this.token.substring(0, 10) }
)
done()
}
)
})
})
})
describe('errors', function () {
describe('via setUserPassword', function () {
beforeEach(function () {
this.PasswordResetHandler.promises.getUserForPasswordResetToken =
sinon.stub().withArgs(this.token).resolves({ user: this.user })
this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user, this.password)
.rejects()
})
it('should return the error', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, _result) => {
expect(error).to.exist
expect(
this.UserAuditLogHandler.promises.addEntry.callCount
).to.equal(1)
done()
}
)
})
})
describe('via UserAuditLogHandler', function () {
beforeEach(function () {
this.PasswordResetHandler.promises.getUserForPasswordResetToken =
sinon.stub().withArgs(this.token).resolves({ user: this.user })
this.UserAuditLogHandler.promises.addEntry.rejects(
new Error('oops')
)
})
it('should return the error', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(error, _result) => {
expect(error).to.exist
expect(
this.UserAuditLogHandler.promises.addEntry.callCount
).to.equal(1)
expect(this.AuthenticationManager.promises.setUserPassword).to
.not.have.been.called
done()
}
)
})
})
})
})
})
describe('when the token has a v1_user_id and email', function () {
beforeEach(function () {
this.user.overleaf = { id: 184 }
this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
data: {
v1_user_id: this.user.overleaf.id,
email: this.email,
},
})
this.AuthenticationManager.promises.setUserPassword
.withArgs(this.user, this.password)
.resolves(true)
this.OneTimeTokenHandler.expireToken = sinon
.stub()
.callsArgWith(2, null)
})
describe('when no user is reset with this email', function () {
beforeEach(function () {
this.UserGetter.getUserByMainEmail
.withArgs(this.email)
.yields(null, null)
})
it('should return reset == false', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { reset } = result
expect(err).to.not.exist
expect(reset).to.be.false
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
false
)
done()
}
)
})
})
describe("when the email and user don't match", function () {
beforeEach(function () {
this.UserGetter.getUserByMainEmail.withArgs(this.email).yields(null, {
_id: this.user._id,
email: this.email,
overleaf: { id: 'not-the-same' },
})
})
it('should return reset == false', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { reset } = result
expect(err).to.not.exist
expect(reset).to.be.false
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
false
)
done()
}
)
})
})
describe('when the email and user match', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByMainEmail.resolves(this.user)
})
it('should return reset == true and the user id', function (done) {
this.PasswordResetHandler.setNewUserPassword(
this.token,
this.password,
this.auditLog,
(err, result) => {
const { reset, userId } = result
expect(err).to.not.exist
expect(reset).to.be.true
expect(userId).to.equal(this.user._id)
expect(this.OneTimeTokenHandler.expireToken.called).to.equal(true)
done()
}
)
})
})
})
})
describe('getUserForPasswordResetToken', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
data: {
user_id: this.user._id,
email: this.email,
},
remainingPeeks: 1,
})
this.UserGetter.promises.getUserByMainEmail.resolves({
_id: this.user._id,
email: this.email,
})
})
it('should returns errors from user permissions', async function () {
let error
const err = new Error('nope')
this.PermissionsManager.promises.assertUserPermissions.rejects(err)
try {
await this.PasswordResetHandler.promises.getUserForPasswordResetToken(
'abc123'
)
} catch (e) {
error = e
}
expect(error).to.deep.equal(error)
})
it('returns user when user has permissions and remaining peaks', async function () {
const result =
await this.PasswordResetHandler.promises.getUserForPasswordResetToken(
'abc123'
)
expect(result).to.deep.equal({
user: { _id: this.user._id, email: this.email },
remainingPeeks: 1,
})
})
})
})

View File

@@ -0,0 +1,80 @@
import esmock from 'esmock'
const modulePath = '../../../../app/src/Features/Project/DocLinesComparitor.mjs'
describe('doc lines comparitor', function () {
beforeEach(async function () {
this.comparitor = await esmock.strict(modulePath, {})
})
it('should return true when the lines are the same', function () {
const lines1 = ['hello', 'world']
const lines2 = ['hello', 'world']
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(true)
})
;[
{
lines1: ['hello', 'world'],
lines2: ['diff', 'world'],
},
{
lines1: ['hello', 'world'],
lines2: ['hello', 'wrld'],
},
].forEach(({ lines1, lines2 }) => {
it('should return false when the lines are different', function () {
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
})
it('should return true when the lines are same', function () {
const lines1 = ['hello', 'world']
const lines2 = ['hello', 'world']
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(true)
})
it('should return false if the doc lines are different in length', function () {
const lines1 = ['hello', 'world']
const lines2 = ['hello', 'world', 'please']
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
it('should return false if the first array is undefined', function () {
const lines1 = undefined
const lines2 = ['hello', 'world']
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
it('should return false if the second array is undefined', function () {
const lines1 = ['hello']
const lines2 = undefined
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
it('should return false if the second array is not an array', function () {
const lines1 = ['hello']
const lines2 = ''
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
it('should return true when comparing equal orchard docs', function () {
const lines1 = [{ text: 'hello world' }]
const lines2 = [{ text: 'hello world' }]
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(true)
})
it('should return false when comparing different orchard docs', function () {
const lines1 = [{ text: 'goodbye world' }]
const lines2 = [{ text: 'hello world' }]
const result = this.comparitor.areSame(lines1, lines2)
result.should.equal(false)
})
})

View File

@@ -0,0 +1,112 @@
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const sinon = require('sinon')
const MODULE_PATH =
'../../../../app/src/Features/Project/FolderStructureBuilder'
describe('FolderStructureBuilder', function () {
beforeEach(function () {
this.FolderStructureBuilder = SandboxedModule.require(MODULE_PATH, {
requires: { 'mongodb-legacy': { ObjectId } },
})
})
describe('buildFolderStructure', function () {
describe('when given no documents at all', function () {
beforeEach(function () {
this.result = this.FolderStructureBuilder.buildFolderStructure([], [])
})
it('returns an empty root folder', function () {
sinon.assert.match(this.result, {
_id: sinon.match.instanceOf(ObjectId),
name: 'rootFolder',
folders: [],
docs: [],
fileRefs: [],
})
})
})
describe('when given documents and files', function () {
beforeEach(function () {
const docUploads = [
{ path: '/main.tex', doc: { _id: 'doc-1', name: 'main.tex' } },
{ path: '/foo/other.tex', doc: { _id: 'doc-2', name: 'other.tex' } },
{ path: '/foo/other.bib', doc: { _id: 'doc-3', name: 'other.bib' } },
{
path: '/foo/foo1/foo2/another.tex',
doc: { _id: 'doc-4', name: 'another.tex' },
},
]
const fileUploads = [
{ path: '/aaa.jpg', file: { _id: 'file-1', name: 'aaa.jpg' } },
{ path: '/foo/bbb.jpg', file: { _id: 'file-2', name: 'bbb.jpg' } },
{ path: '/bar/ccc.jpg', file: { _id: 'file-3', name: 'ccc.jpg' } },
]
this.result = this.FolderStructureBuilder.buildFolderStructure(
docUploads,
fileUploads
)
})
it('returns a full folder structure', function () {
sinon.assert.match(this.result, {
_id: sinon.match.instanceOf(ObjectId),
name: 'rootFolder',
docs: [{ _id: 'doc-1', name: 'main.tex' }],
fileRefs: [{ _id: 'file-1', name: 'aaa.jpg' }],
folders: [
{
_id: sinon.match.instanceOf(ObjectId),
name: 'foo',
docs: [
{ _id: 'doc-2', name: 'other.tex' },
{ _id: 'doc-3', name: 'other.bib' },
],
fileRefs: [{ _id: 'file-2', name: 'bbb.jpg' }],
folders: [
{
_id: sinon.match.instanceOf(ObjectId),
name: 'foo1',
docs: [],
fileRefs: [],
folders: [
{
_id: sinon.match.instanceOf(ObjectId),
name: 'foo2',
docs: [{ _id: 'doc-4', name: 'another.tex' }],
fileRefs: [],
folders: [],
},
],
},
],
},
{
_id: sinon.match.instanceOf(ObjectId),
name: 'bar',
docs: [],
fileRefs: [{ _id: 'file-3', name: 'ccc.jpg' }],
folders: [],
},
],
})
})
})
describe('when given duplicate files', function () {
it('throws an error', function () {
const docUploads = [
{ path: '/foo/doc.tex', doc: { _id: 'doc-1', name: 'doc.tex' } },
{ path: '/foo/doc.tex', doc: { _id: 'doc-2', name: 'doc.tex' } },
]
expect(() =>
this.FolderStructureBuilder.buildFolderStructure(docUploads, [])
).to.throw()
})
})
})
})

View File

@@ -0,0 +1,18 @@
const { expect } = require('chai')
const {
iterablePaths,
} = require('../../../../app/src/Features/Project/IterablePath')
describe('iterablePaths', function () {
it('returns an empty array for empty folders', function () {
expect(iterablePaths(null, 'docs')).to.deep.equal([])
expect(iterablePaths({}, 'docs')).to.deep.equal([])
})
it('returns the `docs` object when it is iterable', function () {
const folder = {
docs: [{ _id: 1 }, { _id: 2 }],
}
expect(iterablePaths(folder, 'docs')).to.equal(folder.docs)
})
})

View File

@@ -0,0 +1,57 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import esmock from 'esmock'
import sinon from 'sinon'
const modulePath = '../../../../app/src/Features/Project/ProjectApiController'
describe('Project api controller', function () {
beforeEach(async function () {
this.ProjectDetailsHandler = { getDetails: sinon.stub() }
this.controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Project/ProjectDetailsHandler':
this.ProjectDetailsHandler,
})
this.project_id = '321l3j1kjkjl'
this.req = {
params: {
project_id: this.project_id,
},
session: {
destroy: sinon.stub(),
},
}
this.res = {}
this.next = sinon.stub()
return (this.projDetails = { name: 'something' })
})
describe('getProjectDetails', function () {
it('should ask the project details handler for proj details', function (done) {
this.ProjectDetailsHandler.getDetails.callsArgWith(
1,
null,
this.projDetails
)
this.res.json = data => {
this.ProjectDetailsHandler.getDetails
.calledWith(this.project_id)
.should.equal(true)
data.should.deep.equal(this.projDetails)
return done()
}
return this.controller.getProjectDetails(this.req, this.res)
})
it('should send a 500 if there is an error', function () {
this.ProjectDetailsHandler.getDetails.callsArgWith(1, 'error')
this.controller.getProjectDetails(this.req, this.res, this.next)
return this.next.calledWith('error').should.equal(true)
})
})
})

View File

@@ -0,0 +1,426 @@
const { ObjectId } = require('mongodb-legacy')
const Path = require('path')
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = Path.join(
__dirname,
'../../../../app/src/Features/Project/ProjectCollabratecDetailsHandler'
)
describe('ProjectCollabratecDetailsHandler', function () {
beforeEach(function () {
this.projectId = new ObjectId('5bea8747c7bba6012fcaceb3')
this.userId = new ObjectId('5be316a9c7f6aa03802ea8fb')
this.userId2 = new ObjectId('5c1794b3f0e89b1d1c577eca')
this.ProjectModel = {}
this.ProjectCollabratecDetailsHandler = SandboxedModule.require(
modulePath,
{
requires: {
'mongodb-legacy': { ObjectId },
'../../models/Project': { Project: this.ProjectModel },
},
}
)
})
describe('initializeCollabratecProject', function () {
describe('when update succeeds', function () {
beforeEach(async function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
await this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
this.projectId,
this.userId,
'collabratec-document-id',
'collabratec-private-group-id'
)
})
it('should update project model', function () {
const update = {
$set: {
collabratecUsers: [
{
user_id: this.userId,
collabratec_document_id: 'collabratec-document-id',
collabratec_privategroup_id: 'collabratec-private-group-id',
},
],
},
}
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.projectId },
update
)
})
})
describe('when update has error', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', async function () {
await expect(
this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
this.projectId,
this.userId,
'collabratec-document-id',
'collabratec-private-group-id'
)
).to.be.rejected
})
})
describe('with invalid args', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
'bad-project-id',
'bad-user-id',
'collabratec-document-id',
'collabratec-private-group-id'
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
})
describe('isLinkedCollabratecUserProject', function () {
beforeEach(function () {
this.ProjectModel.findOne = sinon.stub().resolves()
})
describe('when find succeeds', function () {
describe('when user project found', function () {
beforeEach(async function () {
this.ProjectModel.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves('project') })
this.result =
await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
this.projectId,
this.userId
)
})
it('should call find with project and user id', function () {
expect(this.ProjectModel.findOne).to.have.been.calledWithMatch({
_id: new ObjectId(this.projectId),
collabratecUsers: {
$elemMatch: {
user_id: new ObjectId(this.userId),
},
},
})
})
it('should return true', function () {
expect(this.result).to.equal(true)
})
})
describe('when user project is not found', function () {
beforeEach(async function () {
this.ProjectModel.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves(null) })
this.result =
await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
this.projectId,
this.userId
)
})
it('should return false', function () {
expect(this.result).to.equal(false)
})
})
})
describe('when find has error', function () {
beforeEach(function () {
this.ProjectModel.findOne = sinon
.stub()
.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', async function () {
await expect(
this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
this.projectId,
this.userId
)
).to.be.rejected
})
})
describe('with invalid args', function () {
beforeEach(function () {
this.ProjectModel.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
'bad-project-id',
'bad-user-id'
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.findOne).not.to.have.been.called
})
})
})
describe('linkCollabratecUserProject', function () {
describe('when update succeeds', function () {
beforeEach(async function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
await this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
this.projectId,
this.userId,
'collabratec-document-id'
)
})
it('should update project model', function () {
const query = {
_id: this.projectId,
collabratecUsers: {
$not: {
$elemMatch: {
collabratec_document_id: 'collabratec-document-id',
user_id: this.userId,
},
},
},
}
const update = {
$push: {
collabratecUsers: {
collabratec_document_id: 'collabratec-document-id',
user_id: this.userId,
},
},
}
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
query,
update
)
})
})
describe('when update has error', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', async function () {
await expect(
this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
this.projectId,
this.userId,
'collabratec-document-id'
)
).to.be.rejected
})
})
describe('with invalid args', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
'bad-project-id',
'bad-user-id',
'collabratec-document-id'
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
})
describe('setCollabratecUsers', function () {
beforeEach(function () {
this.collabratecUsers = [
{
user_id: this.userId,
collabratec_document_id: 'collabratec-document-id-1',
collabratec_privategroup_id: 'collabratec-private-group-id-1',
},
{
user_id: this.userId2,
collabratec_document_id: 'collabratec-document-id-2',
collabratec_privategroup_id: 'collabratec-private-group-id-2',
},
]
})
describe('when update succeeds', function () {
beforeEach(async function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
await this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
this.projectId,
this.collabratecUsers
)
})
it('should update project model', function () {
const update = {
$set: {
collabratecUsers: this.collabratecUsers,
},
}
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.projectId },
update
)
})
})
describe('when update has error', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', async function () {
await expect(
this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
this.projectId,
this.collabratecUsers
)
).to.be.rejected
})
})
describe('with invalid project_id', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
'bad-project-id',
this.collabratecUsers
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
describe('with invalid user_id', function () {
beforeEach(function () {
this.collabratecUsers[1].user_id = 'bad-user-id'
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
this.projectId,
this.collabratecUsers
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
})
describe('unlinkCollabratecUserProject', function () {
describe('when update succeeds', function () {
beforeEach(async function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
await this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
this.projectId,
this.userId
)
})
it('should update project model', function () {
const query = { _id: this.projectId }
const update = {
$pull: {
collabratecUsers: {
user_id: this.userId,
},
},
}
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
query,
update
)
})
})
describe('when update has error', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', async function () {
await expect(
this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
this.projectId,
this.userId
)
).to.be.rejected
})
})
describe('with invalid args', function () {
beforeEach(function () {
this.ProjectModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.resultPromise =
this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
'bad-project-id',
'bad-user-id'
)
})
it('should be rejected without updating', async function () {
await expect(this.resultPromise).to.be.rejected
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,848 @@
const modulePath = '../../../../app/src/Features/Project/ProjectDeleter'
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const tk = require('timekeeper')
const moment = require('moment')
const { Project } = require('../helpers/models/Project')
const { DeletedProject } = require('../helpers/models/DeletedProject')
const { ObjectId, ReadPreference } = require('mongodb-legacy')
const Errors = require('../../../../app/src/Features/Errors/Errors')
describe('ProjectDeleter', function () {
beforeEach(function () {
tk.freeze(Date.now())
this.ip = '192.170.18.1'
this.project = dummyProject()
this.user = {
_id: '588f3ddae8ebc1bac07c9fa4',
first_name: 'bjkdsjfk',
features: {},
}
this.doc = {
_id: '5bd975f54f62e803cb8a8fec',
lines: ['a bunch of lines', 'for a sunny day', 'in London town'],
ranges: {},
project_id: '5cf9270b4eff6e186cf8b05e',
}
this.deletedProjects = [
{
_id: '5cf7f145c1401f0ca0eb1aaa',
deleterData: {
_id: '5cf7f145c1401f0ca0eb1aac',
deletedAt: moment().subtract(95, 'days').toDate(),
deleterId: '588f3ddae8ebc1bac07c9fa4',
deleterIpAddress: '172.19.0.1',
deletedProjectId: '5cf9270b4eff6e186cf8b05e',
},
project: {
_id: '5cf9270b4eff6e186cf8b05e',
overleaf: {
history: {
id: new ObjectId(),
},
},
},
},
{
_id: '5cf8eb11c1401f0ca0eb1ad7',
deleterData: {
_id: '5b74360c0fbe57011ae9938f',
deletedAt: moment().subtract(95, 'days').toDate(),
deleterId: '588f3ddae8ebc1bac07c9fa4',
deleterIpAddress: '172.20.0.1',
deletedProjectId: '5cf8f95a0c87371362c23919',
},
project: {
_id: '5cf8f95a0c87371362c23919',
},
},
]
this.DocumentUpdaterHandler = {
promises: {
flushProjectToMongoAndDelete: sinon.stub().resolves(),
},
}
this.EditorRealTimeController = {
emitToRoom: sinon.stub(),
}
this.TagsHandler = {
promises: {
removeProjectFromAllTags: sinon.stub().resolves(),
},
}
this.CollaboratorsHandler = {
promises: {
removeUserFromAllProjects: sinon.stub().resolves(),
},
}
this.CollaboratorsGetter = {
promises: {
getMemberIds: sinon
.stub()
.withArgs(this.project._id)
.resolves(['member-id-1', 'member-id-2']),
},
}
this.ProjectDetailsHandler = {
promises: {
generateUniqueName: sinon.stub().resolves(this.project.name),
},
}
this.ProjectHelper = {
calculateArchivedArray: sinon.stub(),
}
this.db = {
deletedFiles: {
indexExists: sinon.stub().resolves(false),
deleteMany: sinon.stub(),
},
projects: {
insertOne: sinon.stub().resolves(),
},
}
this.DocstoreManager = {
promises: {
archiveProject: sinon.stub().resolves(),
destroyProject: sinon.stub().resolves(),
},
}
this.HistoryManager = {
promises: {
deleteProject: sinon.stub().resolves(),
},
}
this.ProjectMock = sinon.mock(Project)
this.DeletedProjectMock = sinon.mock(DeletedProject)
this.FileStoreHandler = {
promises: {
deleteProject: sinon.stub().resolves(),
},
}
this.Features = {
hasFeature: sinon.stub().returns(true),
}
this.ChatApiHandler = {
promises: {
destroyProject: sinon.stub().resolves(),
},
}
this.ProjectAuditLogEntry = {
deleteMany: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
this.ProjectDeleter = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/Modules': {
promises: { hooks: { fire: sinon.stub().resolves() } },
},
'../../infrastructure/Features': this.Features,
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
'../../models/Project': { Project },
'./ProjectHelper': this.ProjectHelper,
'../../models/DeletedProject': { DeletedProject },
'../DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../Tags/TagsHandler': this.TagsHandler,
'../FileStore/FileStoreHandler': this.FileStoreHandler,
'../Chat/ChatApiHandler': this.ChatApiHandler,
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
'../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
'../Docstore/DocstoreManager': this.DocstoreManager,
'./ProjectDetailsHandler': this.ProjectDetailsHandler,
'../../infrastructure/mongodb': {
db: this.db,
ObjectId,
READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
},
'../History/HistoryManager': this.HistoryManager,
'../../models/ProjectAuditLogEntry': {
ProjectAuditLogEntry: this.ProjectAuditLogEntry,
},
},
})
})
afterEach(function () {
tk.reset()
this.DeletedProjectMock.restore()
this.ProjectMock.restore()
})
describe('mark as deleted by external source', function () {
beforeEach(function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{ _id: this.project._id },
{ deletedByExternalDataSource: true }
)
.chain('exec')
.resolves()
})
it('should update the project with the flag set to true', async function () {
await this.ProjectDeleter.promises.markAsDeletedByExternalSource(
this.project._id
)
this.ProjectMock.verify()
})
it('should tell the editor controler so users are notified', async function () {
await this.ProjectDeleter.promises.markAsDeletedByExternalSource(
this.project._id
)
expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
this.project._id,
'projectRenamedOrDeletedByExternalSource'
)
})
})
describe('unmarkAsDeletedByExternalSource', function () {
beforeEach(async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{ _id: this.project._id },
{ deletedByExternalDataSource: false }
)
.chain('exec')
.resolves()
await this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource(
this.project._id
)
})
it('should remove the flag from the project', function () {
this.ProjectMock.verify()
})
})
describe('deleteUsersProjects', function () {
beforeEach(function () {
this.projects = [dummyProject(), dummyProject()]
this.ProjectMock.expects('find')
.withArgs({ owner_ref: this.user._id })
.chain('exec')
.resolves(this.projects)
for (const project of this.projects) {
this.ProjectMock.expects('findOne')
.withArgs({ _id: project._id })
.chain('exec')
.resolves(project)
this.ProjectMock.expects('deleteOne')
.withArgs({ _id: project._id })
.chain('exec')
.resolves()
this.DeletedProjectMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedProjectId': project._id },
{
project,
deleterData: sinon.match.object,
},
{ upsert: true }
)
.resolves()
}
})
it('should delete all projects owned by the user', async function () {
await this.ProjectDeleter.promises.deleteUsersProjects(this.user._id)
this.ProjectMock.verify()
this.DeletedProjectMock.verify()
})
it('should remove any collaboration from this user', async function () {
await this.ProjectDeleter.promises.deleteUsersProjects(this.user._id)
sinon.assert.calledWith(
this.CollaboratorsHandler.promises.removeUserFromAllProjects,
this.user._id
)
sinon.assert.calledOnce(
this.CollaboratorsHandler.promises.removeUserFromAllProjects
)
})
})
describe('deleteProject', function () {
beforeEach(function () {
this.deleterData = {
deletedAt: new Date(),
deletedProjectId: this.project._id,
deletedProjectOwnerId: this.project.owner_ref,
deletedProjectCollaboratorIds: this.project.collaberator_refs,
deletedProjectReadOnlyIds: this.project.readOnly_refs,
deletedProjectReviewerIds: this.project.reviewer_refs,
deletedProjectReadWriteTokenAccessIds:
this.project.tokenAccessReadAndWrite_refs,
deletedProjectReadOnlyTokenAccessIds:
this.project.tokenAccessReadOnly_refs,
deletedProjectReadWriteToken: this.project.tokens.readAndWrite,
deletedProjectReadOnlyToken: this.project.tokens.readOnly,
deletedProjectOverleafId: this.project.overleaf.id,
deletedProjectOverleafHistoryId: this.project.overleaf.history.id,
deletedProjectLastUpdatedAt: this.project.lastUpdated,
}
this.ProjectMock.expects('findOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves(this.project)
})
it('should save a DeletedProject with additional deleterData', async function () {
this.deleterData.deleterIpAddress = this.ip
this.deleterData.deleterId = this.user._id
this.ProjectMock.expects('deleteOne').chain('exec').resolves()
this.DeletedProjectMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedProjectId': this.project._id },
{
project: this.project,
deleterData: this.deleterData,
},
{ upsert: true }
)
.resolves()
await this.ProjectDeleter.promises.deleteProject(this.project._id, {
deleterUser: this.user,
ipAddress: this.ip,
})
this.DeletedProjectMock.verify()
})
it('should flushProjectToMongoAndDelete in doc updater', async function () {
this.ProjectMock.expects('deleteOne').chain('exec').resolves()
this.DeletedProjectMock.expects('updateOne').resolves()
await this.ProjectDeleter.promises.deleteProject(this.project._id, {
deleterUser: this.user,
ipAddress: this.ip,
})
this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
.calledWith(this.project._id)
.should.equal(true)
})
it('should flush docs out of mongo', async function () {
this.ProjectMock.expects('deleteOne').chain('exec').resolves()
this.DeletedProjectMock.expects('updateOne').resolves()
await this.ProjectDeleter.promises.deleteProject(this.project._id, {
deleterUser: this.user,
ipAddress: this.ip,
})
expect(
this.DocstoreManager.promises.archiveProject
).to.have.been.calledWith(this.project._id)
})
it('should flush docs out of mongo and ignore errors', async function () {
this.ProjectMock.expects('deleteOne').chain('exec').resolves()
this.DeletedProjectMock.expects('updateOne').resolves()
this.DocstoreManager.promises.archiveProject.rejects(new Error('foo'))
await this.ProjectDeleter.promises.deleteProject(this.project._id, {
deleterUser: this.user,
ipAddress: this.ip,
})
})
it('should removeProjectFromAllTags', async function () {
this.ProjectMock.expects('deleteOne').chain('exec').resolves()
this.DeletedProjectMock.expects('updateOne').resolves()
await this.ProjectDeleter.promises.deleteProject(this.project._id)
sinon.assert.calledWith(
this.TagsHandler.promises.removeProjectFromAllTags,
'member-id-1',
this.project._id
)
sinon.assert.calledWith(
this.TagsHandler.promises.removeProjectFromAllTags,
'member-id-2',
this.project._id
)
})
it('should remove the project from Mongo', async function () {
this.ProjectMock.expects('deleteOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves()
this.DeletedProjectMock.expects('updateOne').resolves()
await this.ProjectDeleter.promises.deleteProject(this.project._id)
this.ProjectMock.verify()
})
})
describe('expireDeletedProjectsAfterDuration', function () {
beforeEach(async function () {
for (const deletedProject of this.deletedProjects) {
this.ProjectMock.expects('findById')
.withArgs(deletedProject.deleterData.deletedProjectId)
.chain('exec')
.resolves(null)
}
this.DeletedProjectMock.expects('find')
.withArgs({
'deleterData.deletedAt': {
$lt: new Date(moment().subtract(90, 'days')),
},
project: {
$type: 'object',
},
})
.chain('exec')
.resolves(this.deletedProjects)
for (const deletedProject of this.deletedProjects) {
this.DeletedProjectMock.expects('findOne')
.withArgs({
'deleterData.deletedProjectId': deletedProject.project._id,
})
.chain('exec')
.resolves(deletedProject)
this.DeletedProjectMock.expects('updateOne')
.withArgs(
{
_id: deletedProject._id,
},
{
$set: {
'deleterData.deleterIpAddress': null,
project: null,
},
}
)
.chain('exec')
.resolves()
}
await this.ProjectDeleter.promises.expireDeletedProjectsAfterDuration()
})
it('should expire projects older than 90 days', function () {
this.DeletedProjectMock.verify()
})
})
describe('expireDeletedProject', function () {
describe('on an inactive project', function () {
beforeEach(async function () {
this.ProjectMock.expects('findById')
.withArgs(this.deletedProjects[0].deleterData.deletedProjectId)
.chain('exec')
.resolves(null)
this.DeletedProjectMock.expects('updateOne')
.withArgs(
{
_id: this.deletedProjects[0]._id,
},
{
$set: {
'deleterData.deleterIpAddress': null,
project: null,
},
}
)
.chain('exec')
.resolves()
this.DeletedProjectMock.expects('findOne')
.withArgs({
'deleterData.deletedProjectId': this.deletedProjects[0].project._id,
})
.chain('exec')
.resolves(this.deletedProjects[0])
await this.ProjectDeleter.promises.expireDeletedProject(
this.deletedProjects[0].project._id
)
})
it('should find the specified deletedProject and remove its project and ip address', function () {
this.DeletedProjectMock.verify()
})
it('should destroy the docs in docstore', function () {
expect(
this.DocstoreManager.promises.destroyProject
).to.have.been.calledWith(this.deletedProjects[0].project._id)
})
it('should delete the project in history', function () {
expect(
this.HistoryManager.promises.deleteProject
).to.have.been.calledWith(
this.deletedProjects[0].project._id,
this.deletedProjects[0].project.overleaf.history.id
)
})
it('should destroy the files in filestore', function () {
expect(
this.FileStoreHandler.promises.deleteProject
).to.have.been.calledWith(this.deletedProjects[0].project._id)
})
it('should destroy the chat threads and messages', function () {
expect(
this.ChatApiHandler.promises.destroyProject
).to.have.been.calledWith(this.deletedProjects[0].project._id)
})
it('should delete audit logs', async function () {
expect(this.ProjectAuditLogEntry.deleteMany).to.have.been.calledWith({
projectId: this.deletedProjects[0].project._id,
})
})
})
describe('on an active project (from an incomplete delete)', function () {
beforeEach(async function () {
this.ProjectMock.expects('findById')
.withArgs(this.deletedProjects[0].deleterData.deletedProjectId)
.chain('exec')
.resolves(this.deletedProjects[0].project)
this.DeletedProjectMock.expects('deleteOne')
.withArgs({
'deleterData.deletedProjectId': this.deletedProjects[0].project._id,
})
.chain('exec')
.resolves()
await this.ProjectDeleter.promises.expireDeletedProject(
this.deletedProjects[0].project._id
)
})
it('should delete the spurious deleted project record', function () {
this.DeletedProjectMock.verify()
})
it('should not destroy the docs in docstore', function () {
expect(this.DocstoreManager.promises.destroyProject).to.not.have.been
.called
})
it('should not delete the project in history', function () {
expect(this.HistoryManager.promises.deleteProject).to.not.have.been
.called
})
it('should not destroy the files in filestore', function () {
expect(this.FileStoreHandler.promises.deleteProject).to.not.have.been
.called
})
it('should not destroy the chat threads and messages', function () {
expect(this.ChatApiHandler.promises.destroyProject).to.not.have.been
.called
})
})
})
describe('archiveProject', function () {
beforeEach(function () {
const archived = [new ObjectId(this.user._id)]
this.ProjectHelper.calculateArchivedArray.returns(archived)
this.ProjectMock.expects('findOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves(this.project)
this.ProjectMock.expects('updateOne')
.withArgs(
{ _id: this.project._id },
{
$set: { archived },
$pull: { trashed: new ObjectId(this.user._id) },
}
)
.resolves()
})
it('should update the project', async function () {
await this.ProjectDeleter.promises.archiveProject(
this.project._id,
this.user._id
)
this.ProjectMock.verify()
})
it('calculates the archived array', async function () {
await this.ProjectDeleter.promises.archiveProject(
this.project._id,
this.user._id
)
expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith(
this.project,
this.user._id,
'ARCHIVE'
)
})
})
describe('unarchiveProject', function () {
beforeEach(function () {
const archived = [new ObjectId(this.user._id)]
this.ProjectHelper.calculateArchivedArray.returns(archived)
this.ProjectMock.expects('findOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves(this.project)
this.ProjectMock.expects('updateOne')
.withArgs({ _id: this.project._id }, { $set: { archived } })
.resolves()
})
it('should update the project', async function () {
await this.ProjectDeleter.promises.unarchiveProject(
this.project._id,
this.user._id
)
this.ProjectMock.verify()
})
it('calculates the archived array', async function () {
await this.ProjectDeleter.promises.unarchiveProject(
this.project._id,
this.user._id
)
expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith(
this.project,
this.user._id,
'UNARCHIVE'
)
})
})
describe('trashProject', function () {
beforeEach(function () {
const archived = [new ObjectId(this.user._id)]
this.ProjectHelper.calculateArchivedArray.returns(archived)
this.ProjectMock.expects('findOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves(this.project)
this.ProjectMock.expects('updateOne')
.withArgs(
{ _id: this.project._id },
{
$addToSet: { trashed: new ObjectId(this.user._id) },
$set: { archived },
}
)
.resolves()
})
it('should update the project', async function () {
await this.ProjectDeleter.promises.trashProject(
this.project._id,
this.user._id
)
this.ProjectMock.verify()
})
it('unarchives the project', async function () {
await this.ProjectDeleter.promises.trashProject(
this.project._id,
this.user._id
)
expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith(
this.project,
this.user._id,
'UNARCHIVE'
)
})
})
describe('untrashProject', function () {
beforeEach(function () {
this.ProjectMock.expects('findOne')
.withArgs({ _id: this.project._id })
.chain('exec')
.resolves(this.project)
this.ProjectMock.expects('updateOne')
.withArgs(
{ _id: this.project._id },
{ $pull: { trashed: new ObjectId(this.user._id) } }
)
.resolves()
})
it('should update the project', async function () {
await this.ProjectDeleter.promises.untrashProject(
this.project._id,
this.user._id
)
this.ProjectMock.verify()
})
})
describe('restoreProject', function () {
beforeEach(function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
$unset: { archived: true },
}
)
.chain('exec')
.resolves()
})
it('should unset the archive attribute', async function () {
await this.ProjectDeleter.promises.restoreProject(this.project._id)
})
})
describe('undeleteProject', function () {
beforeEach(function () {
this.unknownProjectId = new ObjectId()
this.purgedProjectId = new ObjectId()
this.deletedProject = {
_id: 'deleted',
project: this.project,
deleterData: {
deletedProjectId: this.project._id,
deletedProjectOwnerId: this.project.owner_ref,
},
}
this.purgedProject = {
_id: 'purged',
deleterData: {
deletedProjectId: this.purgedProjectId,
deletedProjectOwnerId: 'potato',
},
}
this.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': this.project._id })
.chain('exec')
.resolves(this.deletedProject)
this.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': this.purgedProjectId })
.chain('exec')
.resolves(this.purgedProject)
this.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': this.unknownProjectId })
.chain('exec')
.resolves(null)
this.DeletedProjectMock.expects('deleteOne').chain('exec').resolves()
})
it('should return not found if the project does not exist', async function () {
await expect(
this.ProjectDeleter.promises.undeleteProject(
this.unknownProjectId.toString()
)
).to.be.rejectedWith(Errors.NotFoundError, 'project_not_found')
})
it('should return not found if the project has been expired', async function () {
await expect(
this.ProjectDeleter.promises.undeleteProject(
this.purgedProjectId.toString()
)
).to.be.rejectedWith(Errors.NotFoundError, 'project_too_old_to_restore')
})
it('should insert the project into the collection', async function () {
await this.ProjectDeleter.promises.undeleteProject(this.project._id)
sinon.assert.calledWith(
this.db.projects.insertOne,
sinon.match({
_id: this.project._id,
name: this.project.name,
})
)
})
it('should clear the archive bit', async function () {
this.project.archived = true
await this.ProjectDeleter.promises.undeleteProject(this.project._id)
sinon.assert.calledWith(
this.db.projects.insertOne,
sinon.match({ archived: undefined })
)
})
it('should generate a unique name for the project', async function () {
await this.ProjectDeleter.promises.undeleteProject(this.project._id)
sinon.assert.calledWith(
this.ProjectDetailsHandler.promises.generateUniqueName,
this.project.owner_ref
)
})
it('should add a suffix to the project name', async function () {
await this.ProjectDeleter.promises.undeleteProject(this.project._id)
sinon.assert.calledWith(
this.ProjectDetailsHandler.promises.generateUniqueName,
this.project.owner_ref,
this.project.name + ' (Restored)'
)
})
it('should remove the DeletedProject', async function () {
// need to change the mock just to include the methods we want
this.DeletedProjectMock.restore()
this.DeletedProjectMock = sinon.mock(DeletedProject)
this.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': this.project._id })
.chain('exec')
.resolves(this.deletedProject)
this.DeletedProjectMock.expects('deleteOne')
.withArgs({ _id: 'deleted' })
.chain('exec')
.resolves()
await this.ProjectDeleter.promises.undeleteProject(this.project._id)
this.DeletedProjectMock.verify()
})
})
})
function dummyProject() {
return {
_id: new ObjectId(),
lastUpdated: new Date(),
rootFolder: [],
collaberator_refs: [new ObjectId(), new ObjectId()],
readOnly_refs: [new ObjectId(), new ObjectId()],
reviewer_refs: [new ObjectId()],
tokenAccessReadAndWrite_refs: [new ObjectId(), new ObjectId()],
tokenAccessReadOnly_refs: [new ObjectId(), new ObjectId()],
owner_ref: new ObjectId(),
tokens: {
readOnly: 'wombat',
readAndWrite: 'potato',
},
overleaf: {
id: 1234,
history: {
id: 5678,
},
},
name: 'a very scientific analysis of spooky ghosts',
}
}

View File

@@ -0,0 +1,648 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const ProjectHelper = require('../../../../app/src/Features/Project/ProjectHelper')
const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDetailsHandler'
describe('ProjectDetailsHandler', function () {
beforeEach(function () {
this.user = {
_id: new ObjectId(),
email: 'user@example.com',
features: 'mock-features',
}
this.collaborator = {
_id: new ObjectId(),
email: 'collaborator@example.com',
}
this.project = {
_id: new ObjectId(),
name: 'project',
description: 'this is a great project',
something: 'should not exist',
compiler: 'latexxxxxx',
owner_ref: this.user._id,
collaberator_refs: [this.collaborator._id],
}
this.ProjectGetter = {
promises: {
getProjectWithoutDocLines: sinon.stub().resolves(this.project),
getProject: sinon.stub().resolves(this.project),
findAllUsersProjects: sinon.stub().resolves({
owned: [],
readAndWrite: [],
readOnly: [],
tokenReadAndWrite: [],
tokenReadOnly: [],
}),
},
}
this.ProjectModelUpdateQuery = {
exec: sinon.stub().resolves(),
}
this.ProjectModel = {
updateOne: sinon.stub().returns(this.ProjectModelUpdateQuery),
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
},
}
this.TpdsUpdateSender = {
promises: {
moveEntity: sinon.stub().resolves(),
},
}
this.TokenGenerator = {
readAndWriteToken: sinon.stub(),
promises: {
generateUniqueReadOnlyToken: sinon.stub(),
},
}
this.settings = {
defaultFeatures: 'default-features',
}
this.handler = SandboxedModule.require(MODULE_PATH, {
requires: {
'./ProjectHelper': ProjectHelper,
'./ProjectGetter': this.ProjectGetter,
'../../models/Project': {
Project: this.ProjectModel,
},
'../User/UserGetter': this.UserGetter,
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
'../TokenGenerator/TokenGenerator': this.TokenGenerator,
'@overleaf/settings': this.settings,
},
})
})
describe('getDetails', function () {
it('should find the project and owner', async function () {
const details = await this.handler.promises.getDetails(this.project._id)
details.name.should.equal(this.project.name)
details.description.should.equal(this.project.description)
details.compiler.should.equal(this.project.compiler)
details.features.should.equal(this.user.features)
expect(details.something).to.be.undefined
})
it('should find overleaf metadata if it exists', async function () {
this.project.overleaf = { id: 'id' }
const details = await this.handler.promises.getDetails(this.project._id)
details.overleaf.should.equal(this.project.overleaf)
expect(details.something).to.be.undefined
})
it('should return an error for a non-existent project', async function () {
this.ProjectGetter.promises.getProject.resolves(null)
await expect(
this.handler.promises.getDetails('0123456789012345678901234')
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should return the default features if no owner found', async function () {
this.UserGetter.promises.getUser.resolves(null)
const details = await this.handler.promises.getDetails(this.project._id)
details.features.should.equal(this.settings.defaultFeatures)
})
it('should rethrow any error', async function () {
this.ProjectGetter.promises.getProject.rejects(new Error('boom'))
await expect(this.handler.promises.getDetails(this.project._id)).to.be
.rejected
})
})
describe('getProjectDescription', function () {
it('should make a call to mongo just for the description', async function () {
this.ProjectGetter.promises.getProject.resolves()
await this.handler.promises.getProjectDescription(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{ description: true }
)
})
it('should return what the mongo call returns', async function () {
const expectedDescription = 'cool project'
this.ProjectGetter.promises.getProject.resolves({
description: expectedDescription,
})
const description = await this.handler.promises.getProjectDescription(
this.project._id
)
expect(description).to.equal(expectedDescription)
})
})
describe('setProjectDescription', function () {
beforeEach(function () {
this.description = 'updated teh description'
})
it('should update the project detials', async function () {
await this.handler.promises.setProjectDescription(
this.project._id,
this.description
)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{ description: this.description }
)
})
})
describe('renameProject', function () {
beforeEach(function () {
this.newName = 'new name here'
})
it('should update the project with the new name', async function () {
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{ name: this.newName }
)
})
it('should tell the TpdsUpdateSender', async function () {
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.TpdsUpdateSender.promises.moveEntity).to.have.been.calledWith(
{
projectId: this.project._id,
projectName: this.project.name,
newProjectName: this.newName,
}
)
})
it('should not do anything with an invalid name', async function () {
await expect(this.handler.promises.renameProject(this.project._id)).to.be
.rejected
expect(this.TpdsUpdateSender.promises.moveEntity).not.to.have.been.called
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
it('should trim whitespace around name', async function () {
await this.handler.promises.renameProject(
this.project._id,
` ${this.newName} `
)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{ name: this.newName }
)
})
})
describe('validateProjectName', function () {
it('should reject undefined names', async function () {
await expect(this.handler.promises.validateProjectName(undefined)).to.be
.rejected
})
it('should reject empty names', async function () {
await expect(this.handler.promises.validateProjectName('')).to.be.rejected
})
it('should reject names with /s', async function () {
await expect(this.handler.promises.validateProjectName('foo/bar')).to.be
.rejected
})
it('should reject names with \\s', async function () {
await expect(this.handler.promises.validateProjectName('foo\\bar')).to.be
.rejected
})
it('should reject long names', async function () {
await expect(this.handler.promises.validateProjectName('a'.repeat(1000)))
.to.be.rejected
})
it('should accept normal names', async function () {
await expect(this.handler.promises.validateProjectName('foobar')).to.be
.fulfilled
})
})
describe('generateUniqueName', function () {
// actually testing `ProjectHelper.promises.ensureNameIsUnique()`
beforeEach(function () {
this.longName = 'x'.repeat(this.handler.MAX_PROJECT_NAME_LENGTH - 5)
const usersProjects = {
owned: [
{ _id: 1, name: 'name' },
{ _id: 2, name: 'name1' },
{ _id: 3, name: 'name11' },
{ _id: 100, name: 'numeric' },
{ _id: 101, name: 'numeric (1)' },
{ _id: 102, name: 'numeric (2)' },
{ _id: 103, name: 'numeric (3)' },
{ _id: 104, name: 'numeric (4)' },
{ _id: 105, name: 'numeric (5)' },
{ _id: 106, name: 'numeric (6)' },
{ _id: 107, name: 'numeric (7)' },
{ _id: 108, name: 'numeric (8)' },
{ _id: 109, name: 'numeric (9)' },
{ _id: 110, name: 'numeric (10)' },
{ _id: 111, name: 'numeric (11)' },
{ _id: 112, name: 'numeric (12)' },
{ _id: 113, name: 'numeric (13)' },
{ _id: 114, name: 'numeric (14)' },
{ _id: 115, name: 'numeric (15)' },
{ _id: 116, name: 'numeric (16)' },
{ _id: 117, name: 'numeric (17)' },
{ _id: 118, name: 'numeric (18)' },
{ _id: 119, name: 'numeric (19)' },
{ _id: 120, name: 'numeric (20)' },
{ _id: 130, name: 'numeric (30)' },
{ _id: 131, name: 'numeric (31)' },
{ _id: 132, name: 'numeric (32)' },
{ _id: 133, name: 'numeric (33)' },
{ _id: 134, name: 'numeric (34)' },
{ _id: 135, name: 'numeric (35)' },
{ _id: 136, name: 'numeric (36)' },
{ _id: 137, name: 'numeric (37)' },
{ _id: 138, name: 'numeric (38)' },
{ _id: 139, name: 'numeric (39)' },
{ _id: 140, name: 'numeric (40)' },
{ _id: 141, name: 'Yearbook (2021)' },
{ _id: 142, name: 'Yearbook (2021) (1)' },
{ _id: 143, name: 'Resume (2020' },
],
readAndWrite: [
{ _id: 4, name: 'name2' },
{ _id: 5, name: 'name22' },
],
readOnly: [
{ _id: 6, name: 'name3' },
{ _id: 7, name: 'name33' },
],
tokenReadAndWrite: [
{ _id: 8, name: 'name4' },
{ _id: 9, name: 'name44' },
],
tokenReadOnly: [
{ _id: 10, name: 'name5' },
{ _id: 11, name: 'name55' },
{ _id: 12, name: this.longName },
],
}
this.ProjectGetter.promises.findAllUsersProjects.resolves(usersProjects)
})
it('should leave a unique name unchanged', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'unique-name',
['-test-suffix']
)
expect(name).to.equal('unique-name')
})
it('should append a suffix to an existing name', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
['-test-suffix']
)
expect(name).to.equal('name1-test-suffix')
})
it('should fallback to a second suffix when needed', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
['1', '-test-suffix']
)
expect(name).to.equal('name1-test-suffix')
})
it('should truncate the name when append a suffix if the result is too long', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
this.longName,
['-test-suffix']
)
expect(name).to.equal(
this.longName.substr(0, this.handler.MAX_PROJECT_NAME_LENGTH - 12) +
'-test-suffix'
)
})
it('should use a numeric index if no suffix is supplied', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
[]
)
expect(name).to.equal('name1 (1)')
})
it('should use a numeric index if all suffixes are exhausted', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name',
['1', '11']
)
expect(name).to.equal('name (1)')
})
it('should find the next lowest available numeric index for the base name', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'numeric',
[]
)
expect(name).to.equal('numeric (21)')
})
it('should not find a numeric index lower than the one already present', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'numeric (31)',
[]
)
expect(name).to.equal('numeric (41)')
})
it('should handle years in name', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'unique-name (2021)',
[]
)
expect(name).to.equal('unique-name (2021)')
})
it('should handle duplicating with year in name', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'Yearbook (2021)',
[]
)
expect(name).to.equal('Yearbook (2021) (2)')
})
describe('title with that causes invalid regex', function () {
it('should create the project with a suffix when project name exists', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'Resume (2020',
[]
)
expect(name).to.equal('Resume (2020 (1)')
})
it('should create the project with the provided name', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'Yearbook (2021',
[]
)
expect(name).to.equal('Yearbook (2021')
})
})
describe('numeric index is already present', function () {
describe('when there is 1 project "x (2)"', function () {
beforeEach(function () {
const usersProjects = {
owned: [{ _id: 1, name: 'x (2)' }],
}
this.ProjectGetter.promises.findAllUsersProjects.resolves(
usersProjects
)
})
it('should produce "x (3)" uploading a zip with name "x (2)"', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'x (2)',
[]
)
expect(name).to.equal('x (3)')
})
})
describe('when there are 2 projects "x (2)" and "x (3)"', function () {
beforeEach(function () {
const usersProjects = {
owned: [
{ _id: 1, name: 'x (2)' },
{ _id: 2, name: 'x (3)' },
],
}
this.ProjectGetter.promises.findAllUsersProjects.resolves(
usersProjects
)
})
it('should produce "x (4)" when uploading a zip with name "x (2)"', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'x (2)',
[]
)
expect(name).to.equal('x (4)')
})
})
describe('when there are 2 projects "x (2)" and "x (4)"', function () {
beforeEach(function () {
const usersProjects = {
owned: [
{ _id: 1, name: 'x (2)' },
{ _id: 2, name: 'x (4)' },
],
}
this.ProjectGetter.promises.findAllUsersProjects.resolves(
usersProjects
)
})
it('should produce "x (3)" when uploading a zip with name "x (2)"', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'x (2)',
[]
)
expect(name).to.equal('x (3)')
})
it('should produce "x (5)" when uploading a zip with name "x (4)"', async function () {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'x (4)',
[]
)
expect(name).to.equal('x (5)')
})
})
})
})
describe('fixProjectName', function () {
it('should change empty names to Untitled', function () {
expect(this.handler.fixProjectName('')).to.equal('Untitled')
})
it('should replace / with -', function () {
expect(this.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
})
it("should replace \\ with ''", function () {
expect(this.handler.fixProjectName('foo \\ bar')).to.equal('foo bar')
})
it('should truncate long names', function () {
expect(this.handler.fixProjectName('a'.repeat(1000))).to.equal(
'a'.repeat(150)
)
})
it('should accept normal names', function () {
expect(this.handler.fixProjectName('foobar')).to.equal('foobar')
})
it('should trim name after truncation', function () {
expect(this.handler.fixProjectName('a'.repeat(149) + ' a')).to.equal(
'a'.repeat(149)
)
})
})
describe('setPublicAccessLevel', function () {
beforeEach(function () {
this.accessLevel = 'tokenBased'
})
it('should update the project with the new level', async function () {
await this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{ publicAccesLevel: this.accessLevel }
)
})
it('should not produce an error', async function () {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
).to.be.fulfilled
})
describe('when update produces an error', function () {
beforeEach(function () {
this.ProjectModelUpdateQuery.exec.rejects(new Error('woops'))
})
it('should produce an error', async function () {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
).to.be.rejected
})
})
})
describe('ensureTokensArePresent', function () {
describe('when the project has tokens', function () {
beforeEach(function () {
this.project = {
_id: this.project._id,
tokens: {
readOnly: 'aaa',
readAndWrite: '42bbb',
readAndWritePrefix: '42',
},
}
this.ProjectGetter.promises.getProject.resolves(this.project)
})
it('should get the project', async function () {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{
tokens: 1,
}
)
})
it('should not update the project with new tokens', async function () {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectModel.updateOne).not.to.have.been.called
})
})
describe('when tokens are missing', function () {
beforeEach(function () {
this.project = { _id: this.project._id }
this.ProjectGetter.promises.getProject.resolves(this.project)
this.readOnlyToken = 'abc'
this.readAndWriteToken = '42def'
this.readAndWriteTokenPrefix = '42'
this.TokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
this.readOnlyToken
)
this.TokenGenerator.readAndWriteToken.returns({
token: this.readAndWriteToken,
numericPrefix: this.readAndWriteTokenPrefix,
})
})
it('should get the project', async function () {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{
tokens: 1,
}
)
})
it('should update the project with new tokens', async function () {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.TokenGenerator.promises.generateUniqueReadOnlyToken).to.have
.been.calledOnce
expect(this.TokenGenerator.readAndWriteToken).to.have.been.calledOnce
expect(this.ProjectModel.updateOne).to.have.been.calledOnce
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{
$set: {
tokens: {
readOnly: this.readOnlyToken,
readAndWrite: this.readAndWriteToken,
readAndWritePrefix: this.readAndWriteTokenPrefix,
},
},
}
)
})
})
})
describe('clearTokens', function () {
it('clears the tokens from the project', async function () {
await this.handler.promises.clearTokens(this.project._id)
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: this.project._id },
{ $unset: { tokens: 1 }, $set: { publicAccesLevel: 'private' } }
)
})
})
})

View File

@@ -0,0 +1,448 @@
const { expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDuplicator.js'
describe('ProjectDuplicator', function () {
beforeEach(function () {
this.doc0 = { _id: 'doc0_id', name: 'rootDocHere' }
this.doc1 = { _id: 'doc1_id', name: 'level1folderDocName' }
this.doc2 = { _id: 'doc2_id', name: 'level2folderDocName' }
this.doc0Lines = ['zero']
this.doc1Lines = ['one']
this.doc2Lines = ['two']
this.file0 = { name: 'file0', _id: 'file0', hash: 'abcde' }
this.file1 = { name: 'file1', _id: 'file1' }
this.file2 = {
name: 'file2',
_id: 'file2',
created: '2024-07-05T14:18:31.401+00:00',
linkedFileData: { provider: 'url' },
hash: '123456',
}
this.level2folder = {
name: 'level2folderName',
_id: 'level2folderId',
docs: [this.doc2, undefined],
folders: [],
fileRefs: [this.file2],
}
this.level1folder = {
name: 'level1folder',
_id: 'level1folderId',
docs: [this.doc1],
folders: [this.level2folder],
fileRefs: [this.file1, null], // the null is intentional to test null docs/files
}
this.rootFolder = {
name: 'rootFolder',
_id: 'rootFolderId',
docs: [this.doc0],
folders: [this.level1folder, {}],
fileRefs: [this.file0],
}
this.project = {
_id: 'this_is_the_old_project_id',
rootDoc_id: this.doc0._id,
rootFolder: [this.rootFolder],
compiler: 'this_is_a_Compiler',
overleaf: { history: { id: 123456 } },
}
this.doc0Path = '/rootDocHere'
this.doc1Path = '/level1folder/level1folderDocName'
this.doc2Path = '/level1folder/level2folderName/level2folderDocName'
this.file0Path = '/file0'
this.file1Path = '/level1folder/file1'
this.file2Path = '/level1folder/level2folderName/file2'
this.docContents = [
{ _id: this.doc0._id, lines: this.doc0Lines },
{ _id: this.doc1._id, lines: this.doc1Lines },
{ _id: this.doc2._id, lines: this.doc2Lines },
]
this.rootDoc = this.doc0
this.rootDocPath = '/rootDocHere'
this.owner = { _id: 'this_is_the_owner' }
this.newBlankProject = {
_id: 'new_project_id',
overleaf: { history: { id: 339123 } },
readOnly_refs: [],
collaberator_refs: [],
rootFolder: [{ _id: 'new_root_folder_id' }],
}
this.newFolder = { _id: 'newFolderId' }
this.filestoreUrl = 'filestore-url'
this.newProjectVersion = 2
this.newDocId = new ObjectId()
this.newFileId = new ObjectId()
this.newDoc0 = { ...this.doc0, _id: this.newDocId }
this.newDoc1 = { ...this.doc1, _id: this.newDocId }
this.newDoc2 = { ...this.doc2, _id: this.newDocId }
this.newFile0 = { ...this.file0, _id: this.newFileId }
this.newFile1 = { ...this.file1, _id: this.newFileId }
this.newFile2 = { ...this.file2, _id: this.newFileId }
this.docEntries = [
{
path: this.doc0Path,
doc: this.newDoc0,
docLines: this.doc0Lines.join('\n'),
},
{
path: this.doc1Path,
doc: this.newDoc1,
docLines: this.doc1Lines.join('\n'),
},
{
path: this.doc2Path,
doc: this.newDoc2,
docLines: this.doc2Lines.join('\n'),
},
]
this.fileEntries = [
{
createdBlob: false,
path: this.file0Path,
file: this.newFile0,
url: this.filestoreUrl,
},
{
createdBlob: false,
path: this.file1Path,
file: this.newFile1,
url: this.filestoreUrl,
},
{
createdBlob: true,
path: this.file2Path,
file: this.newFile2,
url: null,
},
]
this.Doc = sinon
.stub()
.callsFake(props => ({ _id: this.newDocId, ...props }))
this.File = sinon
.stub()
.callsFake(props => ({ _id: this.newFileId, ...props }))
this.DocstoreManager = {
promises: {
updateDoc: sinon.stub().resolves(),
getAllDocs: sinon.stub().resolves(this.docContents),
},
}
this.DocumentUpdaterHandler = {
promises: {
flushProjectToMongo: sinon.stub().resolves(),
updateProjectStructure: sinon.stub().resolves(),
},
}
this.FileStoreHandler = {
promises: {
copyFile: sinon.stub().resolves(this.filestoreUrl),
},
}
this.HistoryManager = {
promises: {
copyBlob: sinon.stub().callsFake((historyId, newHistoryId, hash) => {
if (hash === 'abcde') {
return Promise.reject(new Error('copy blob error'))
}
return Promise.resolve()
}),
},
}
this.TagsHandler = {
promises: {
addProjectToTags: sinon.stub().resolves({
_id: 'project-1',
}),
countTagsForProject: sinon.stub().resolves(1),
},
}
this.ProjectCreationHandler = {
promises: {
createBlankProject: sinon.stub().resolves(this.newBlankProject),
},
}
this.ProjectDeleter = {
promises: {
deleteProject: sinon.stub().resolves(),
},
}
this.ProjectEntityMongoUpdateHandler = {
promises: {
createNewFolderStructure: sinon.stub().resolves(this.newProjectVersion),
},
}
this.ProjectEntityUpdateHandler = {
isPathValidForRootDoc: sinon.stub().returns(true),
promises: {
setRootDoc: sinon.stub().resolves(),
},
}
this.ProjectGetter = {
promises: {
getProject: sinon
.stub()
.withArgs(this.project._id)
.resolves(this.project),
},
}
this.ProjectLocator = {
promises: {
findRootDoc: sinon.stub().resolves({
element: this.rootDoc,
path: { fileSystem: this.rootDocPath },
}),
findElementByPath: sinon
.stub()
.withArgs({
project_id: this.newBlankProject._id,
path: this.rootDocPath,
exactCaseMatch: true,
})
.resolves({ element: this.doc0 }),
},
}
this.ProjectOptionsHandler = {
promises: {
setCompiler: sinon.stub().resolves(),
},
}
this.TpdsProjectFlusher = {
promises: {
flushProjectToTpds: sinon.stub().resolves(),
},
}
this.Features = {
hasFeature: sinon.stub().withArgs('project-history-blobs').returns(true),
}
this.ProjectDuplicator = SandboxedModule.require(MODULE_PATH, {
requires: {
'../../models/Doc': { Doc: this.Doc },
'../../models/File': { File: this.File },
'../Docstore/DocstoreManager': this.DocstoreManager,
'../DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../FileStore/FileStoreHandler': this.FileStoreHandler,
'./ProjectCreationHandler': this.ProjectCreationHandler,
'./ProjectDeleter': this.ProjectDeleter,
'./ProjectEntityMongoUpdateHandler':
this.ProjectEntityMongoUpdateHandler,
'./ProjectEntityUpdateHandler': this.ProjectEntityUpdateHandler,
'./ProjectGetter': this.ProjectGetter,
'./ProjectLocator': this.ProjectLocator,
'./ProjectOptionsHandler': this.ProjectOptionsHandler,
'../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
'../Tags/TagsHandler': this.TagsHandler,
'../History/HistoryManager': this.HistoryManager,
'../../infrastructure/Features': this.Features,
'../Compile/ClsiCacheManager': {
prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
},
},
})
})
describe('when the copy succeeds', function () {
beforeEach(async function () {
this.newProjectName = 'New project name'
this.newProject = await this.ProjectDuplicator.promises.duplicate(
this.owner,
this.project._id,
this.newProjectName
)
})
it('should flush the original project to mongo', function () {
this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
this.project._id
)
})
it('should copy docs to docstore', function () {
for (const docLines of [this.doc0Lines, this.doc1Lines, this.doc2Lines]) {
this.DocstoreManager.promises.updateDoc.should.have.been.calledWith(
this.newProject._id.toString(),
this.newDocId.toString(),
docLines,
0,
{}
)
}
})
it('should duplicate the files with hashes by copying the blobs in history v1', function () {
for (const file of [this.file0, this.file2]) {
this.HistoryManager.promises.copyBlob.should.have.been.calledWith(
this.project.overleaf.history.id,
this.newProject.overleaf.history.id,
file.hash
)
}
})
it('should ignore any errors when copying the blobs in history v1', async function () {
await expect(
this.HistoryManager.promises.copyBlob(
this.project.overleaf.history.id,
this.newProject.overleaf.history.id,
this.file0.hash
)
).to.be.rejectedWith('copy blob error')
})
it('should not try to copy the blobs for any files without hashes', function () {
for (const file of [this.file1]) {
this.HistoryManager.promises.copyBlob.should.not.have.been.calledWith(
this.project.overleaf.history.id,
this.newProject.overleaf.history.id,
file.hash
)
}
})
it('should copy files to the filestore', function () {
for (const file of [this.file0, this.file1]) {
this.FileStoreHandler.promises.copyFile.should.have.been.calledWith(
this.project._id,
file._id,
this.newProject._id,
this.newFileId
)
}
})
it('should not copy files that have been sent to history-v1 to the filestore', function () {
this.FileStoreHandler.promises.copyFile.should.not.have.been.calledWith(
this.project._id,
this.file2._id,
this.newProject._id,
this.newFileId
)
})
it('should create a blank project', function () {
this.ProjectCreationHandler.promises.createBlankProject.should.have.been.calledWith(
this.owner._id,
this.newProjectName
)
this.newProject._id.should.equal(this.newBlankProject._id)
})
it('should use the same compiler', function () {
this.ProjectOptionsHandler.promises.setCompiler.should.have.been.calledWith(
this.newProject._id,
this.project.compiler
)
})
it('should use the same root doc', function () {
this.ProjectEntityUpdateHandler.promises.setRootDoc.should.have.been.calledWith(
this.newProject._id,
this.rootFolder.docs[0]._id
)
})
it('should not copy the collaborators or read only refs', function () {
this.newProject.collaberator_refs.length.should.equal(0)
this.newProject.readOnly_refs.length.should.equal(0)
})
it('should copy all documents and files', function () {
this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.should.have.been.calledWith(
this.newProject._id,
this.docEntries,
this.fileEntries
)
})
it('should notify document updater of changes', function () {
this.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
this.newProject._id,
this.newProject.overleaf.history.id,
this.owner._id,
{
newDocs: this.docEntries,
newFiles: this.fileEntries,
newProject: { version: this.newProjectVersion },
},
null
)
})
it('should flush the project to TPDS', function () {
this.TpdsProjectFlusher.promises.flushProjectToTpds.should.have.been.calledWith(
this.newProject._id
)
})
})
describe('without a root doc', function () {
beforeEach(async function () {
this.ProjectLocator.promises.findRootDoc.resolves({
element: null,
path: null,
})
this.newProject = await this.ProjectDuplicator.promises.duplicate(
this.owner,
this.project._id,
'Copy of project'
)
})
it('should not set the root doc on the copy', function () {
this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
.called
})
})
describe('with an invalid root doc', function () {
beforeEach(async function () {
this.ProjectEntityUpdateHandler.isPathValidForRootDoc.returns(false)
this.newProject = await this.ProjectDuplicator.promises.duplicate(
this.owner,
this.project._id,
'Copy of project'
)
})
it('should not set the root doc on the copy', function () {
this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
.called
})
})
describe('when there is an error', function () {
beforeEach(async function () {
this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.rejects()
await expect(
this.ProjectDuplicator.promises.duplicate(
this.owner,
this.project._id,
''
)
).to.be.rejected
})
it('should delete the broken cloned project', function () {
this.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
this.newBlankProject._id
)
})
it('should not delete the original project', function () {
this.ProjectDeleter.promises.deleteProject.should.not.have.been.calledWith(
this.project._id
)
})
})
})

View File

@@ -0,0 +1,436 @@
const _ = require('lodash')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Project/ProjectEditorHandler'
const SandboxedModule = require('sandboxed-module')
describe('ProjectEditorHandler', function () {
beforeEach(function () {
this.project = {
_id: 'project-id',
name: 'Project Name',
rootDoc_id: 'file-id',
publicAccesLevel: 'private',
deletedByExternalDataSource: false,
rootFolder: [
{
_id: 'root-folder-id',
name: '',
docs: [],
fileRefs: [],
folders: [
{
_id: 'sub-folder-id',
name: 'folder',
docs: [
{
_id: 'doc-id',
name: 'main.tex',
lines: (this.lines = ['line 1', 'line 2', 'line 3']),
},
],
fileRefs: [
{
_id: 'file-id',
name: 'image.png',
created: (this.created = new Date()),
size: 1234,
},
],
folders: [],
},
],
},
],
deletedDocs: [
{
_id: 'deleted-doc-id',
name: 'main.tex',
deletedAt: (this.deletedAt = new Date('2017-01-01')),
},
],
}
this.members = [
{
user: (this.owner = {
_id: 'owner-id',
first_name: 'Owner',
last_name: 'Overleaf',
email: 'owner@overleaf.com',
}),
privilegeLevel: 'owner',
},
{
user: {
_id: 'read-only-id',
first_name: 'Read',
last_name: 'Only',
email: 'read-only@overleaf.com',
},
privilegeLevel: 'readOnly',
},
{
user: {
_id: 'read-write-id',
first_name: 'Read',
last_name: 'Write',
email: 'read-write@overleaf.com',
},
privilegeLevel: 'readAndWrite',
},
]
this.invites = [
{
_id: 'invite_one',
email: 'user-one@example.com',
privileges: 'readOnly',
projectId: this.project._id,
token: 'my-secret-token1',
},
{
_id: 'invite_two',
email: 'user-two@example.com',
privileges: 'readOnly',
projectId: this.project._id,
token: 'my-secret-token2',
},
]
this.deletedDocsFromDocstore = [
{ _id: 'deleted-doc-id-from-docstore', name: 'docstore.tex' },
]
this.handler = SandboxedModule.require(modulePath)
})
describe('buildProjectModelView', function () {
describe('with owner, members and invites included', function () {
beforeEach(function () {
this.result = this.handler.buildProjectModelView(
this.project,
this.members,
this.invites,
this.deletedDocsFromDocstore
)
})
it('should include the id', function () {
expect(this.result._id).to.exist
this.result._id.should.equal('project-id')
})
it('should include the name', function () {
expect(this.result.name).to.exist
this.result.name.should.equal('Project Name')
})
it('should include the root doc id', function () {
expect(this.result.rootDoc_id).to.exist
this.result.rootDoc_id.should.equal('file-id')
})
it('should include the public access level', function () {
expect(this.result.publicAccesLevel).to.exist
this.result.publicAccesLevel.should.equal('private')
})
it('should include the owner', function () {
expect(this.result.owner).to.exist
this.result.owner._id.should.equal('owner-id')
this.result.owner.email.should.equal('owner@overleaf.com')
this.result.owner.first_name.should.equal('Owner')
this.result.owner.last_name.should.equal('Overleaf')
this.result.owner.privileges.should.equal('owner')
})
it('should include the deletedDocs', function () {
expect(this.result.deletedDocs).to.exist
this.result.deletedDocs.should.deep.equal([
{
// omit deletedAt field
_id: this.project.deletedDocs[0]._id,
name: this.project.deletedDocs[0].name,
},
this.deletedDocsFromDocstore[0],
])
})
it('should gather readOnly_refs and collaberators_refs into a list of members', function () {
const findMember = id => {
for (const member of this.result.members) {
if (member._id === id) {
return member
}
}
return null
}
this.result.members.length.should.equal(2)
expect(findMember('read-only-id')).to.exist
findMember('read-only-id').privileges.should.equal('readOnly')
findMember('read-only-id').first_name.should.equal('Read')
findMember('read-only-id').last_name.should.equal('Only')
findMember('read-only-id').email.should.equal('read-only@overleaf.com')
expect(findMember('read-write-id')).to.exist
findMember('read-write-id').privileges.should.equal('readAndWrite')
findMember('read-write-id').first_name.should.equal('Read')
findMember('read-write-id').last_name.should.equal('Write')
findMember('read-write-id').email.should.equal(
'read-write@overleaf.com'
)
})
it('should include folders in the project', function () {
this.result.rootFolder[0]._id.should.equal('root-folder-id')
this.result.rootFolder[0].name.should.equal('')
this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id')
this.result.rootFolder[0].folders[0].name.should.equal('folder')
})
it('should not duplicate folder contents', function () {
this.result.rootFolder[0].docs.length.should.equal(0)
this.result.rootFolder[0].fileRefs.length.should.equal(0)
})
it('should include files in the project', function () {
this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal(
'file-id'
)
this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal(
'image.png'
)
this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal(
this.created
)
expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to
.exist
})
it('should include docs in the project but not the lines', function () {
this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id')
this.result.rootFolder[0].folders[0].docs[0].name.should.equal(
'main.tex'
)
expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist
})
it('should include invites', function () {
expect(this.result.invites).to.exist
this.result.invites.should.deep.equal(
this.invites.map(invite =>
_.pick(invite, ['_id', 'email', 'privileges'])
)
)
})
it('invites should not include the token', function () {
for (const invite of this.result.invites) {
expect(invite.token).not.to.exist
}
})
})
describe('when docstore sends a deleted doc that is also present in the project', function () {
beforeEach(function () {
this.deletedDocsFromDocstore.push(this.project.deletedDocs[0])
this.result = this.handler.buildProjectModelView(
this.project,
this.members,
this.invites,
this.deletedDocsFromDocstore
)
})
it('should not send any duplicate', function () {
expect(this.result.deletedDocs).to.exist
this.result.deletedDocs.should.deep.equal([
this.project.deletedDocs[0],
this.deletedDocsFromDocstore[0],
])
})
})
describe('deletedByExternalDataSource', function () {
it('should set the deletedByExternalDataSource flag to false when it is not there', function () {
delete this.project.deletedByExternalDataSource
const result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
result.deletedByExternalDataSource.should.equal(false)
})
it('should set the deletedByExternalDataSource flag to false when it is false', function () {
const result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
result.deletedByExternalDataSource.should.equal(false)
})
it('should set the deletedByExternalDataSource flag to true when it is true', function () {
this.project.deletedByExternalDataSource = true
const result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
result.deletedByExternalDataSource.should.equal(true)
})
})
describe('features', function () {
beforeEach(function () {
this.owner.features = {
versioning: true,
collaborators: 3,
compileGroup: 'priority',
compileTimeout: 96,
}
this.result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
})
it('should copy the owner features to the project', function () {
this.result.features.versioning.should.equal(
this.owner.features.versioning
)
this.result.features.collaborators.should.equal(
this.owner.features.collaborators
)
this.result.features.compileGroup.should.equal(
this.owner.features.compileGroup
)
this.result.features.compileTimeout.should.equal(
this.owner.features.compileTimeout
)
})
})
describe('trackChangesState', function () {
describe('when the owner does not have the trackChanges feature', function () {
beforeEach(function () {
this.owner.features = {
trackChanges: false,
}
this.result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
})
it('should not emit trackChangesState', function () {
expect(this.result.trackChangesState).to.not.exist
})
})
describe('when the owner has got the trackChanges feature', function () {
beforeEach(function () {
this.owner.features = {
trackChanges: true,
}
})
function genCase([dbEntry, expected]) {
describe(`when track_changes is ${JSON.stringify(
dbEntry
)}`, function () {
beforeEach(function () {
this.project.track_changes = dbEntry
this.result = this.handler.buildProjectModelView(
this.project,
this.members,
[],
[]
)
})
it(`should set trackChangesState=${expected}`, function () {
expect(this.result.trackChangesState).to.deep.equal(expected)
})
})
}
const CASES = [
[null, false],
[false, false],
[true, true],
[{ someId: true }, { someId: true }],
]
CASES.map(genCase)
})
})
})
describe('buildOwnerAndMembersViews', function () {
beforeEach(function () {
this.owner.features = {
versioning: true,
collaborators: 3,
compileGroup: 'priority',
compileTimeout: 22,
}
this.result = this.handler.buildOwnerAndMembersViews(this.members)
})
it('should produce an object with the right keys', function () {
expect(this.result).to.have.all.keys([
'owner',
'ownerFeatures',
'members',
])
})
it('should separate the owner from the members', function () {
this.result.members.length.should.equal(this.members.length - 1)
expect(this.result.owner._id).to.equal(this.owner._id)
expect(this.result.owner.email).to.equal(this.owner.email)
expect(
this.result.members.filter(m => m._id === this.owner._id).length
).to.equal(0)
})
it('should extract the ownerFeatures from the owner object', function () {
expect(this.result.ownerFeatures).to.deep.equal(this.owner.features)
})
describe('when there is no owner', function () {
beforeEach(function () {
// remove the owner from members list
this.membersWithoutOwner = this.members.filter(
m => m.user._id !== this.owner._id
)
this.result = this.handler.buildOwnerAndMembersViews(
this.membersWithoutOwner
)
})
it('should produce an object with the right keys', function () {
expect(this.result).to.have.all.keys([
'owner',
'ownerFeatures',
'members',
])
})
it('should not separate out an owner', function () {
this.result.members.length.should.equal(this.membersWithoutOwner.length)
expect(this.result.owner).to.equal(null)
})
it('should not extract the ownerFeatures from the owner object', function () {
expect(this.result.ownerFeatures).to.equal(null)
})
})
})
})

View File

@@ -0,0 +1,475 @@
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Project/ProjectEntityHandler'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/src/Features/Errors/Errors')
describe('ProjectEntityHandler', function () {
const projectId = '4eecb1c1bffa66588e0000a1'
const docId = '4eecb1c1bffa66588e0000a2'
beforeEach(function () {
this.TpdsUpdateSender = {
addDoc: sinon.stub().callsArg(1),
addFile: sinon.stub().callsArg(1),
}
this.ProjectModel = class Project {
constructor(options) {
this._id = projectId
this.name = 'project_name_here'
this.rev = 0
this.rootFolder = [this.rootFolder]
}
}
this.project = new this.ProjectModel()
this.ProjectLocator = { findElement: sinon.stub() }
this.DocumentUpdaterHandler = {
updateProjectStructure: sinon.stub().yields(),
}
this.callback = sinon.stub()
this.ProjectEntityHandler = SandboxedModule.require(modulePath, {
requires: {
'../Docstore/DocstoreManager': (this.DocstoreManager = {
promises: {},
}),
'../../Features/DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../../models/Project': {
Project: this.ProjectModel,
},
'./ProjectLocator': this.ProjectLocator,
'./ProjectGetter': (this.ProjectGetter = { promises: {} }),
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
},
})
})
describe('getting folders, docs and files', function () {
beforeEach(function () {
this.project.rootFolder = [
{
docs: [
(this.doc1 = {
name: 'doc1',
_id: 'doc1_id',
}),
],
fileRefs: [
(this.file1 = {
rev: 1,
_id: 'file1_id',
name: 'file1',
}),
],
folders: [
(this.folder1 = {
name: 'folder1',
docs: [
(this.doc2 = {
name: 'doc2',
_id: 'doc2_id',
}),
],
fileRefs: [
(this.file2 = {
rev: 2,
name: 'file2',
_id: 'file2_id',
}),
],
folders: [],
}),
],
},
]
this.ProjectGetter.promises.getProjectWithoutDocLines = sinon
.stub()
.resolves(this.project)
})
describe('getAllDocs', function () {
let fetchedDocs
beforeEach(async function () {
this.docs = [
{
_id: this.doc1._id,
lines: (this.lines1 = ['one']),
rev: (this.rev1 = 1),
},
{
_id: this.doc2._id,
lines: (this.lines2 = ['two']),
rev: (this.rev2 = 2),
},
]
this.DocstoreManager.promises.getAllDocs = sinon
.stub()
.resolves(this.docs)
fetchedDocs =
await this.ProjectEntityHandler.promises.getAllDocs(projectId)
})
it('should get the doc lines and rev from the docstore', function () {
this.DocstoreManager.promises.getAllDocs
.calledWith(projectId)
.should.equal(true)
})
it('should call the callback with the docs with the lines and rev included', function () {
expect(fetchedDocs).to.deep.equal({
'/doc1': {
_id: this.doc1._id,
lines: this.lines1,
name: this.doc1.name,
rev: this.rev1,
folder: this.project.rootFolder[0],
},
'/folder1/doc2': {
_id: this.doc2._id,
lines: this.lines2,
name: this.doc2.name,
rev: this.rev2,
folder: this.folder1,
},
})
})
})
describe('getAllFiles', function () {
let allFiles
beforeEach(async function () {
this.callback = sinon.stub()
allFiles = await this.ProjectEntityHandler.promises.getAllFiles(
projectId,
this.callback
)
})
it('should call the callback with the files', function () {
expect(allFiles).to.deep.equal({
'/file1': { ...this.file1, folder: this.project.rootFolder[0] },
'/folder1/file2': { ...this.file2, folder: this.folder1 },
})
})
})
describe('getAllDocPathsFromProject', function () {
beforeEach(function () {
this.docs = [
{
_id: this.doc1._id,
lines: (this.lines1 = ['one']),
rev: (this.rev1 = 1),
},
{
_id: this.doc2._id,
lines: (this.lines2 = ['two']),
rev: (this.rev2 = 2),
},
]
})
it('should call the callback with the path for each docId', function () {
const expected = {
[this.doc1._id]: `/${this.doc1.name}`,
[this.doc2._id]: `/folder1/${this.doc2.name}`,
}
expect(
this.ProjectEntityHandler.getAllDocPathsFromProject(
this.project,
this.callback
)
).to.deep.equal(expected)
})
})
describe('getDocPathByProjectIdAndDocId', function () {
it('should call the callback with the path for an existing doc id at the root level', async function () {
const path =
await this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.doc1._id
)
expect(path).to.deep.equal(`/${this.doc1.name}`)
})
it('should call the callback with the path for an existing doc id nested within a folder', async function () {
const path =
await this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.doc2._id
)
expect(path).to.deep.equal(`/folder1/${this.doc2.name}`)
})
it('should call the callback with a NotFoundError for a non-existing doc', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
'non-existing-id'
)
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should call the callback with a NotFoundError for an existing file', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.file1._id
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('_getAllFolders', async function () {
let folders
beforeEach(async function () {
this.callback = sinon.stub()
folders =
await this.ProjectEntityHandler.promises._getAllFolders(projectId)
})
it('should get the project without the docs lines', function () {
this.ProjectGetter.promises.getProjectWithoutDocLines
.calledWith(projectId)
.should.equal(true)
})
it('should call the callback with the folders', function () {
expect(folders).to.deep.equal([
{ path: '/', folder: this.project.rootFolder[0] },
{ path: '/folder1', folder: this.folder1 },
])
})
})
describe('_getAllFoldersFromProject', function () {
it('should return the folders', function () {
expect(
this.ProjectEntityHandler._getAllFoldersFromProject(this.project)
).to.deep.equal([
{ path: '/', folder: this.project.rootFolder[0] },
{ path: '/folder1', folder: this.folder1 },
])
})
})
})
describe('with an invalid file tree', function () {
beforeEach(function () {
this.project.rootFolder = [
{
docs: [
(this.doc1 = {
name: null, // invalid doc name
_id: 'doc1_id',
}),
],
fileRefs: [
(this.file1 = {
rev: 1,
_id: 'file1_id',
name: null, // invalid file name
}),
],
folders: [
(this.folder1 = {
name: null, // invalid folder name
docs: [
(this.doc2 = {
name: 'doc2',
_id: 'doc2_id',
}),
],
fileRefs: [
(this.file2 = {
rev: 2,
name: 'file2',
_id: 'file2_id',
}),
],
folders: null,
}),
null, // invalid folder
],
},
]
this.ProjectGetter.promises.getProjectWithoutDocLines = sinon
.stub()
.resolves(this.project)
})
describe('getAllDocs', function () {
beforeEach(async function () {
this.docs = [
{
_id: this.doc1._id,
lines: (this.lines1 = ['one']),
rev: (this.rev1 = 1),
},
{
_id: this.doc2._id,
lines: (this.lines2 = ['two']),
rev: (this.rev2 = 2),
},
]
this.DocstoreManager.promises.getAllDocs = sinon
.stub()
.resolves(this.docs)
})
it('should call the callback with an error', async function () {
await expect(this.ProjectEntityHandler.promises.getAllDocs(projectId))
.to.be.rejected
})
})
describe('getAllFiles', function () {
it('should call the callback with and error', async function () {
await expect(this.ProjectEntityHandler.promises.getAllFiles(projectId))
.to.be.rejected
})
})
describe('getDocPathByProjectIdAndDocId', function () {
it('should call the callback with an error for an existing doc id at the root level', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.doc1._id
)
).to.be.rejectedWith(Error)
})
it('should call the callback with an error for an existing doc id nested within a folder', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.doc2._id
)
).to.be.rejectedWith(Error)
})
it('should call the callback with an error for a non-existing doc', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
'non-existing-id'
)
).to.be.rejectedWith(Error)
})
it('should call the callback with an error for an existing file', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
this.file1._id
)
).to.be.rejectedWith(Error)
})
})
describe('_getAllFolders', function () {
it('should call the callback with an error', async function () {
await expect(
this.ProjectEntityHandler.promises._getAllFolders(projectId)
).to.be.rejected
})
})
describe('getAllEntities', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon
.stub()
.resolves(this.project)
})
it('should call the callback with an error', async function () {
await expect(
this.ProjectEntityHandler.promises.getAllEntities(projectId)
).to.be.rejected
})
})
describe('getAllDocPathsFromProjectById', function () {
it('should call the callback with an error', async function () {
await expect(
this.ProjectEntityHandler.promises.getAllDocPathsFromProjectById(
projectId
)
).to.be.rejected
})
})
describe('getDocPathFromProjectByDocId', function () {
it('should call the callback with an error', async function () {
await expect(
this.ProjectEntityHandler.promises.getDocPathFromProjectByDocId(
projectId,
this.doc1._id
)
).to.be.rejected
})
})
})
describe('getDoc', function () {
beforeEach(function () {
this.lines = ['mock', 'doc', 'lines']
this.rev = 5
this.version = 42
this.ranges = { mock: 'ranges' }
this.callback = sinon.stub()
this.DocstoreManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
rev: this.rev,
version: this.version,
ranges: this.ranges,
})
})
it('should call the callback with the lines, version and rev', function (done) {
this.ProjectEntityHandler.getDoc(projectId, docId, doc => {
this.DocstoreManager.promises.getDoc
.calledWith(projectId, docId)
.should.equal(true)
expect(doc).to.exist
done()
})
})
})
describe('promises.getDoc', function () {
let result
beforeEach(async function () {
this.lines = ['mock', 'doc', 'lines']
this.rev = 5
this.version = 42
this.ranges = { mock: 'ranges' }
this.DocstoreManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
rev: this.rev,
version: this.version,
ranges: this.ranges,
})
result = await this.ProjectEntityHandler.promises.getDoc(projectId, docId)
})
it('should call the docstore', function () {
this.DocstoreManager.promises.getDoc
.calledWith(projectId, docId)
.should.equal(true)
})
it('should return the lines, rev, version and ranges', function () {
expect(result.lines).to.equal(this.lines)
expect(result.rev).to.equal(this.rev)
expect(result.version).to.equal(this.version)
expect(result.ranges).to.equal(this.ranges)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
const { expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const MODULE_PATH =
'../../../../app/src/Features/Project/ProjectEntityRestoreHandler.js'
describe('ProjectEntityRestoreHandler', function () {
beforeEach(function () {
this.project = {
_id: '123213jlkj9kdlsaj',
}
this.user = {
_id: '588f3ddae8ebc1bac07c9fa4',
first_name: 'bjkdsjfk',
features: {},
}
this.docId = '4eecb1c1bffa66588e0000a2'
this.DocModel = class Doc {
constructor(options) {
this.name = options.name
this.lines = options.lines
this._id = this.docId
this.rev = 0
}
}
this.ProjectEntityHandler = {
promises: {
getDoc: sinon.stub(),
},
}
this.EditorController = {
promises: {
addDocWithRanges: sinon.stub(),
},
}
this.ProjectEntityRestoreHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'./ProjectEntityHandler': this.ProjectEntityHandler,
'../Editor/EditorController': this.EditorController,
},
})
})
it('should add a new doc with timestamp name and old content', async function () {
const docName = 'deletedDoc'
this.docLines = ['line one', 'line two']
this.rev = 3
this.ranges = { comments: [{ id: 123 }] }
this.newDoc = new this.DocModel({
name: this.docName,
lines: undefined,
_id: this.docId,
rev: 0,
})
this.ProjectEntityHandler.promises.getDoc.resolves({
lines: this.docLines,
rev: this.rev,
version: 'version',
ranges: this.ranges,
})
this.EditorController.promises.addDocWithRanges = sinon
.stub()
.resolves(this.newDoc)
await this.ProjectEntityRestoreHandler.promises.restoreDeletedDoc(
this.project._id,
this.docId,
docName,
this.user._id
)
const docNameMatcher = new RegExp(docName + '-\\d{4}-\\d{2}-\\d{2}-\\d+')
expect(
this.EditorController.promises.addDocWithRanges
).to.have.been.calledWith(
this.project._id,
null,
sinon.match(docNameMatcher),
this.docLines,
this.ranges,
null,
this.user._id
)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Project/ProjectGetter.js'
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
describe('ProjectGetter', function () {
beforeEach(function () {
this.project = { _id: new ObjectId() }
this.projectIdStr = this.project._id.toString()
this.deletedProject = { deleterData: { wombat: 'potato' } }
this.userId = new ObjectId()
this.DeletedProject = {
find: sinon.stub().returns({
exec: sinon.stub().resolves([this.deletedProject]),
}),
}
this.Project = {
find: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.project),
}),
}
this.CollaboratorsGetter = {
promises: {
getProjectsUserIsMemberOf: sinon.stub().resolves({
readAndWrite: [],
readOnly: [],
tokenReadAndWrite: [],
tokenReadOnly: [],
}),
},
}
this.LockManager = {
promises: {
runWithLock: sinon
.stub()
.callsFake((namespace, id, runner) => runner()),
},
}
this.db = {
projects: {
findOne: sinon.stub().resolves(this.project),
},
users: {},
}
this.ProjectEntityMongoUpdateHandler = {
lockKey: sinon.stub().returnsArg(0),
}
this.ProjectGetter = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/mongodb': { db: this.db, ObjectId },
'../../models/Project': {
Project: this.Project,
},
'../../models/DeletedProject': {
DeletedProject: this.DeletedProject,
},
'../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
'../../infrastructure/LockManager': this.LockManager,
'./ProjectEntityMongoUpdateHandler':
this.ProjectEntityMongoUpdateHandler,
},
})
})
describe('getProjectWithoutDocLines', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves()
})
describe('passing an id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProjectWithoutDocLines(
this.project._id
)
})
it('should call find with the project id', function () {
this.ProjectGetter.promises.getProject
.calledWith(this.project._id)
.should.equal(true)
})
it('should exclude the doc lines', function () {
const excludes = {
'rootFolder.docs.lines': 0,
'rootFolder.folders.docs.lines': 0,
'rootFolder.folders.folders.docs.lines': 0,
'rootFolder.folders.folders.folders.docs.lines': 0,
'rootFolder.folders.folders.folders.folders.docs.lines': 0,
'rootFolder.folders.folders.folders.folders.folders.docs.lines': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.docs.lines': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.folders.docs.lines': 0,
}
this.ProjectGetter.promises.getProject
.calledWith(this.project._id, excludes)
.should.equal(true)
})
})
})
describe('getProjectWithOnlyFolders', function () {
beforeEach(function () {
this.ProjectGetter.promises.getProject = sinon.stub().resolves()
})
describe('passing an id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProjectWithOnlyFolders(
this.project._id
)
})
it('should call find with the project id', function () {
this.ProjectGetter.promises.getProject
.calledWith(this.project._id)
.should.equal(true)
})
it('should exclude the docs and files lines', function () {
const excludes = {
'rootFolder.docs': 0,
'rootFolder.fileRefs': 0,
'rootFolder.folders.docs': 0,
'rootFolder.folders.fileRefs': 0,
'rootFolder.folders.folders.docs': 0,
'rootFolder.folders.folders.fileRefs': 0,
'rootFolder.folders.folders.folders.docs': 0,
'rootFolder.folders.folders.folders.fileRefs': 0,
'rootFolder.folders.folders.folders.folders.docs': 0,
'rootFolder.folders.folders.folders.folders.fileRefs': 0,
'rootFolder.folders.folders.folders.folders.folders.docs': 0,
'rootFolder.folders.folders.folders.folders.folders.fileRefs': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.docs': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.fileRefs': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.folders.docs': 0,
'rootFolder.folders.folders.folders.folders.folders.folders.folders.fileRefs': 0,
}
this.ProjectGetter.promises.getProject
.calledWith(this.project._id, excludes)
.should.equal(true)
})
})
})
describe('getProject', function () {
describe('without projection', function () {
describe('with project id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProject(this.projectIdStr)
})
it('should call findOne with the project id', function () {
expect(this.db.projects.findOne.callCount).to.equal(1)
expect(
this.db.projects.findOne.lastCall.args[0]._id.toString()
).to.equal(this.projectIdStr)
})
})
describe('without project id', function () {
it('should be rejected', function () {
expect(
this.ProjectGetter.promises.getProject(null)
).to.be.rejectedWith('no project id provided')
expect(this.db.projects.findOne.callCount).to.equal(0)
})
})
})
describe('with projection', function () {
beforeEach(function () {
this.projection = { _id: 1 }
})
describe('with project id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProject(
this.projectIdStr,
this.projection
)
})
it('should call findOne with the project id', function () {
expect(this.db.projects.findOne.callCount).to.equal(1)
expect(
this.db.projects.findOne.lastCall.args[0]._id.toString()
).to.equal(this.projectIdStr)
expect(this.db.projects.findOne.lastCall.args[1]).to.deep.equal({
projection: this.projection,
})
})
})
describe('without project id', function () {
it('should be rejected', function () {
expect(
this.ProjectGetter.promises.getProject(null)
).to.be.rejectedWith('no project id provided')
expect(this.db.projects.findOne.callCount).to.equal(0)
})
})
})
})
describe('getProjectWithoutLock', function () {
describe('without projection', function () {
describe('with project id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProjectWithoutLock(
this.projectIdStr
)
})
it('should call findOne with the project id', function () {
expect(this.db.projects.findOne.callCount).to.equal(1)
expect(
this.db.projects.findOne.lastCall.args[0]._id.toString()
).to.equal(this.projectIdStr)
})
})
describe('without project id', function () {
it('should be rejected', function () {
expect(
this.ProjectGetter.promises.getProjectWithoutLock(null)
).to.be.rejectedWith('no project id provided')
expect(this.db.projects.findOne.callCount).to.equal(0)
})
})
})
describe('with projection', function () {
beforeEach(function () {
this.projection = { _id: 1 }
})
describe('with project id', function () {
beforeEach(async function () {
await this.ProjectGetter.promises.getProjectWithoutLock(
this.project._id,
this.projection
)
})
it('should call findOne with the project id', function () {
expect(this.db.projects.findOne.callCount).to.equal(1)
expect(
this.db.projects.findOne.lastCall.args[0]._id.toString()
).to.equal(this.projectIdStr)
expect(this.db.projects.findOne.lastCall.args[1]).to.deep.equal({
projection: this.projection,
})
})
})
describe('without project id', function () {
it('should be rejected', function () {
expect(
this.ProjectGetter.promises.getProjectWithoutLock(null)
).to.be.rejectedWith('no project id provided')
expect(this.db.projects.findOne.callCount).to.equal(0)
})
})
})
})
describe('findAllUsersProjects', function () {
beforeEach(function () {
this.fields = { mock: 'fields' }
this.projectOwned = { _id: 'mock-owned-projects' }
this.projectRW = { _id: 'mock-rw-projects' }
this.projectReview = { _id: 'mock-review-projects' }
this.projectRO = { _id: 'mock-ro-projects' }
this.projectTokenRW = { _id: 'mock-token-rw-projects' }
this.projectTokenRO = { _id: 'mock-token-ro-projects' }
this.Project.find
.withArgs({ owner_ref: this.userId }, this.fields)
.returns({ exec: sinon.stub().resolves([this.projectOwned]) })
})
it('should return a promise with all the projects', async function () {
this.CollaboratorsGetter.promises.getProjectsUserIsMemberOf.resolves({
readAndWrite: [this.projectRW],
readOnly: [this.projectRO],
tokenReadAndWrite: [this.projectTokenRW],
tokenReadOnly: [this.projectTokenRO],
review: [this.projectReview],
})
const projects = await this.ProjectGetter.promises.findAllUsersProjects(
this.userId,
this.fields
)
expect(projects).to.deep.equal({
owned: [this.projectOwned],
readAndWrite: [this.projectRW],
readOnly: [this.projectRO],
tokenReadAndWrite: [this.projectTokenRW],
tokenReadOnly: [this.projectTokenRO],
review: [this.projectReview],
})
})
it('should remove duplicate projects', async function () {
this.CollaboratorsGetter.promises.getProjectsUserIsMemberOf.resolves({
readAndWrite: [this.projectRW, this.projectOwned],
readOnly: [this.projectRO, this.projectRW],
tokenReadAndWrite: [this.projectTokenRW, this.projectRO],
tokenReadOnly: [
this.projectTokenRW,
this.projectTokenRO,
this.projectRO,
],
review: [this.projectReview],
})
const projects = await this.ProjectGetter.promises.findAllUsersProjects(
this.userId,
this.fields
)
expect(projects).to.deep.equal({
owned: [this.projectOwned],
readAndWrite: [this.projectRW],
readOnly: [this.projectRO],
tokenReadAndWrite: [this.projectTokenRW],
tokenReadOnly: [this.projectTokenRO],
review: [this.projectReview],
})
})
})
describe('getProjectIdByReadAndWriteToken', function () {
describe('when project find returns project', function () {
this.beforeEach(async function () {
this.projectIdFound =
await this.ProjectGetter.promises.getProjectIdByReadAndWriteToken(
'token'
)
})
it('should find project with token', function () {
this.Project.findOne
.calledWithMatch({ 'tokens.readAndWrite': 'token' })
.should.equal(true)
})
it('should return the project id', function () {
expect(this.projectIdFound).to.equal(this.project._id)
})
})
describe('when project not found', function () {
it('should return undefined', async function () {
this.Project.findOne.returns({ exec: sinon.stub().resolves(null) })
const projectId =
await this.ProjectGetter.promises.getProjectIdByReadAndWriteToken(
'token'
)
expect(projectId).to.equal(undefined)
})
})
describe('when project find returns error', function () {
this.beforeEach(async function () {
this.Project.findOne.returns({ exec: sinon.stub().rejects() })
})
it('should be rejected', function () {
expect(
this.ProjectGetter.promises.getProjectIdByReadAndWriteToken('token')
).to.be.rejected
})
})
})
describe('findUsersProjectsByName', function () {
it('should perform a case-insensitive search', async function () {
this.project1 = { _id: 1, name: 'find me!' }
this.project2 = { _id: 2, name: 'not me!' }
this.project3 = { _id: 3, name: 'FIND ME!' }
this.project4 = { _id: 4, name: 'Find Me!' }
this.Project.find.withArgs({ owner_ref: this.userId }).returns({
exec: sinon
.stub()
.resolves([
this.project1,
this.project2,
this.project3,
this.project4,
]),
})
const projects =
await this.ProjectGetter.promises.findUsersProjectsByName(
this.userId,
this.project1.name
)
const projectNames = projects.map(project => project.name)
expect(projectNames).to.have.members([
this.project1.name,
this.project3.name,
this.project4.name,
])
})
it('should search collaborations as well', async function () {
this.project1 = { _id: 1, name: 'find me!' }
this.project2 = { _id: 2, name: 'FIND ME!' }
this.project3 = { _id: 3, name: 'Find Me!' }
this.project4 = { _id: 4, name: 'find ME!' }
this.project5 = { _id: 5, name: 'FIND me!' }
this.Project.find
.withArgs({ owner_ref: this.userId })
.returns({ exec: sinon.stub().resolves([this.project1]) })
this.CollaboratorsGetter.promises.getProjectsUserIsMemberOf.resolves({
readAndWrite: [this.project2],
readOnly: [this.project3],
tokenReadAndWrite: [this.project4],
tokenReadOnly: [this.project5],
})
const projects =
await this.ProjectGetter.promises.findUsersProjectsByName(
this.userId,
this.project1.name
)
expect(projects.map(project => project.name)).to.have.members([
this.project1.name,
this.project2.name,
])
})
})
describe('getUsersDeletedProjects', function () {
it('should look up the deleted projects by deletedProjectOwnerId', async function () {
await this.ProjectGetter.promises.getUsersDeletedProjects('giraffe')
sinon.assert.calledWith(this.DeletedProject.find, {
'deleterData.deletedProjectOwnerId': 'giraffe',
})
})
it('should pass the found projects to the callback', async function () {
const docs =
await this.ProjectGetter.promises.getUsersDeletedProjects('giraffe')
expect(docs).to.deep.equal([this.deletedProject])
})
})
})

View File

@@ -0,0 +1,287 @@
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.js'
describe('ProjectHelper', function () {
beforeEach(function () {
this.project = {
_id: '123213jlkj9kdlsaj',
}
this.user = {
_id: '588f3ddae8ebc1bac07c9fa4',
first_name: 'bjkdsjfk',
features: {},
}
this.adminUser = {
_id: 'admin-user-id',
isAdmin: true,
alphaProgram: true,
}
this.Settings = {
adminPrivilegeAvailable: true,
allowedImageNames: [
{ imageName: 'texlive-full:2018.1', imageDesc: 'TeX Live 2018' },
{ imageName: 'texlive-full:2019.1', imageDesc: 'TeX Live 2019' },
{
imageName: 'texlive-full:2020.1',
imageDesc: 'TeX Live 2020',
alphaOnly: true,
},
],
}
this.ProjectHelper = SandboxedModule.require(MODULE_PATH, {
requires: {
'mongodb-legacy': { ObjectId },
'@overleaf/settings': this.Settings,
},
})
})
describe('isArchived', function () {
describe('project.archived being an array', function () {
it('returns true if user id is found', function () {
this.project.archived = [
new ObjectId('588f3ddae8ebc1bac07c9fa4'),
new ObjectId('5c41deb2b4ca500153340809'),
]
expect(
this.ProjectHelper.isArchived(this.project, this.user._id)
).to.equal(true)
})
it('returns false if user id is not found', function () {
this.project.archived = []
expect(
this.ProjectHelper.isArchived(this.project, this.user._id)
).to.equal(false)
})
})
describe('project.archived being undefined', function () {
it('returns false if archived is undefined', function () {
this.project.archived = undefined
expect(
this.ProjectHelper.isArchived(this.project, this.user._id)
).to.equal(false)
})
})
})
describe('isTrashed', function () {
it('returns true if user id is found', function () {
this.project.trashed = [
new ObjectId('588f3ddae8ebc1bac07c9fa4'),
new ObjectId('5c41deb2b4ca500153340809'),
]
expect(
this.ProjectHelper.isTrashed(this.project, this.user._id)
).to.equal(true)
})
it('returns false if user id is not found', function () {
this.project.trashed = []
expect(
this.ProjectHelper.isTrashed(this.project, this.user._id)
).to.equal(false)
})
describe('project.trashed being undefined', function () {
it('returns false if trashed is undefined', function () {
this.project.trashed = undefined
expect(
this.ProjectHelper.isTrashed(this.project, this.user._id)
).to.equal(false)
})
})
})
describe('calculateArchivedArray', function () {
describe('project.archived being an array', function () {
it('returns an array adding the current user id when archiving', function () {
const project = { archived: [] }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'ARCHIVE'
)
expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')])
})
it('returns an array without the current user id when unarchiving', function () {
const project = { archived: [new ObjectId('5c922599cdb09e014aa7d499')] }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'UNARCHIVE'
)
expect(result).to.deep.equal([])
})
})
describe('project.archived being a boolean and being true', function () {
it('returns an array of all associated user ids when archiving', function () {
const project = {
archived: true,
owner_ref: this.user._id,
collaberator_refs: [
new ObjectId('4f2cfb341eb5855a5b000f8b'),
new ObjectId('5c45f3bd425ead01488675aa'),
],
readOnly_refs: [new ObjectId('5c92243fcdb09e014aa7d487')],
tokenAccessReadAndWrite_refs: [
new ObjectId('5c922599cdb09e014aa7d499'),
],
tokenAccessReadOnly_refs: [],
}
const result = this.ProjectHelper.calculateArchivedArray(
project,
this.user._id,
'ARCHIVE'
)
expect(result).to.deep.equal([
this.user._id,
new ObjectId('4f2cfb341eb5855a5b000f8b'),
new ObjectId('5c45f3bd425ead01488675aa'),
new ObjectId('5c92243fcdb09e014aa7d487'),
new ObjectId('5c922599cdb09e014aa7d499'),
])
})
it('returns an array of all associated users without the current user id when unarchived', function () {
const project = {
archived: true,
owner_ref: this.user._id,
collaberator_refs: [
new ObjectId('4f2cfb341eb5855a5b000f8b'),
new ObjectId('5c45f3bd425ead01488675aa'),
new ObjectId('5c922599cdb09e014aa7d499'),
],
readOnly_refs: [new ObjectId('5c92243fcdb09e014aa7d487')],
tokenAccessReadAndWrite_refs: [
new ObjectId('5c922599cdb09e014aa7d499'),
],
tokenAccessReadOnly_refs: [],
}
const result = this.ProjectHelper.calculateArchivedArray(
project,
this.user._id,
'UNARCHIVE'
)
expect(result).to.deep.equal([
new ObjectId('4f2cfb341eb5855a5b000f8b'),
new ObjectId('5c45f3bd425ead01488675aa'),
new ObjectId('5c922599cdb09e014aa7d499'),
new ObjectId('5c92243fcdb09e014aa7d487'),
])
})
})
describe('project.archived being a boolean and being false', function () {
it('returns an array adding the current user id when archiving', function () {
const project = { archived: false }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'ARCHIVE'
)
expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')])
})
it('returns an empty array when unarchiving', function () {
const project = { archived: false }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'UNARCHIVE'
)
expect(result).to.deep.equal([])
})
})
describe('project.archived not being set', function () {
it('returns an array adding the current user id when archiving', function () {
const project = { archived: undefined }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'ARCHIVE'
)
expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')])
})
it('returns an empty array when unarchiving', function () {
const project = { archived: undefined }
const result = this.ProjectHelper.calculateArchivedArray(
project,
new ObjectId('5c922599cdb09e014aa7d499'),
'UNARCHIVE'
)
expect(result).to.deep.equal([])
})
})
})
describe('compilerFromV1Engine', function () {
it('returns the correct engine for latex_dvipdf', function () {
expect(this.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal(
'latex'
)
})
it('returns the correct engine for pdflatex', function () {
expect(this.ProjectHelper.compilerFromV1Engine('pdflatex')).to.equal(
'pdflatex'
)
})
it('returns the correct engine for xelatex', function () {
expect(this.ProjectHelper.compilerFromV1Engine('xelatex')).to.equal(
'xelatex'
)
})
it('returns the correct engine for lualatex', function () {
expect(this.ProjectHelper.compilerFromV1Engine('lualatex')).to.equal(
'lualatex'
)
})
})
describe('getAllowedImagesForUser', function () {
it('filters out alpha-only images when the user is anonymous', function () {
const images = this.ProjectHelper.getAllowedImagesForUser(null)
const imageNames = images.map(image => image.imageName)
expect(imageNames).to.deep.equal([
'texlive-full:2018.1',
'texlive-full:2019.1',
])
})
it('filters out alpha-only images when the user is not admin', function () {
const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
const imageNames = images.map(image => image.imageName)
expect(imageNames).to.deep.equal([
'texlive-full:2018.1',
'texlive-full:2019.1',
])
})
it('returns all images when the user is admin', function () {
const images = this.ProjectHelper.getAllowedImagesForUser(this.adminUser)
const imageNames = images.map(image => image.imageName)
expect(imageNames).to.deep.equal([
'texlive-full:2018.1',
'texlive-full:2019.1',
'texlive-full:2020.1',
])
})
})
})

View File

@@ -0,0 +1,163 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { assert, expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Project/ProjectHistoryHandler'
const SandboxedModule = require('sandboxed-module')
describe('ProjectHistoryHandler', function () {
const projectId = '4eecb1c1bffa66588e0000a1'
const userId = 1234
beforeEach(function () {
let Project
this.ProjectModel = Project = (function () {
Project = class Project {
static initClass() {
this.prototype.rootFolder = [this.rootFolder]
}
constructor(options) {
this._id = projectId
this.name = 'project_name_here'
this.rev = 0
}
}
Project.initClass()
return Project
})()
this.project = new this.ProjectModel()
this.historyId = this.project._id.toString()
this.callback = sinon.stub()
return (this.ProjectHistoryHandler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.Settings = {}),
'../../models/Project': {
Project: this.ProjectModel,
},
'./ProjectDetailsHandler': (this.ProjectDetailsHandler = {
promises: {},
}),
'../History/HistoryManager': (this.HistoryManager = {
promises: {},
}),
'./ProjectEntityUpdateHandler': (this.ProjectEntityUpdateHandler = {
promises: {},
}),
},
}))
})
describe('starting history for an existing project', function () {
beforeEach(async function () {
this.HistoryManager.promises.initializeProject = sinon
.stub()
.resolves(this.historyId)
this.HistoryManager.promises.flushProject = sinon.stub()
return (this.ProjectEntityUpdateHandler.promises.resyncProjectHistory =
sinon.stub())
})
describe('when the history does not already exist', function () {
beforeEach(async function () {
this.ProjectDetailsHandler.promises.getDetails = sinon
.stub()
.withArgs(projectId)
.resolves(this.project)
this.ProjectModel.updateOne = sinon.stub().resolves({ matchedCount: 1 })
return this.ProjectHistoryHandler.promises.ensureHistoryExistsForProject(
projectId
)
})
it('should get any existing history id for the project', async function () {
return this.ProjectDetailsHandler.promises.getDetails
.calledWith(projectId)
.should.equal(true)
})
it('should initialize a new history in the v1 history service', async function () {
return this.HistoryManager.promises.initializeProject.called.should.equal(
true
)
})
it('should set the new history id on the project', async function () {
return this.ProjectModel.updateOne
.calledWith(
{ _id: projectId, 'overleaf.history.id': { $exists: false } },
{ 'overleaf.history.id': this.historyId }
)
.should.equal(true)
})
it('should resync the project history', async function () {
return this.ProjectEntityUpdateHandler.promises.resyncProjectHistory
.calledWith(projectId)
.should.equal(true)
})
it('should flush the project history', async function () {
return this.HistoryManager.promises.flushProject
.calledWith(projectId)
.should.equal(true)
})
})
describe('when the history already exists', function () {
beforeEach(function () {
this.project.overleaf = { history: { id: 1234 } }
this.ProjectDetailsHandler.promises.getDetails = sinon
.stub()
.withArgs(projectId)
.resolves(this.project)
this.ProjectModel.updateOne = sinon.stub().resolves({ matchedCount: 1 })
return this.ProjectHistoryHandler.promises.ensureHistoryExistsForProject(
projectId
)
})
it('should get any existing history id for the project', async function () {
return this.ProjectDetailsHandler.promises.getDetails
.calledWith(projectId)
.should.equal(true)
})
it('should not initialize a new history in the v1 history service', async function () {
return this.HistoryManager.promises.initializeProject.called.should.equal(
false
)
})
it('should not set the new history id on the project', async function () {
return this.ProjectModel.updateOne.called.should.equal(false)
})
it('should not resync the project history', async function () {
return this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.called.should.equal(
false
)
})
it('should not flush the project history', async function () {
return this.HistoryManager.promises.flushProject.called.should.equal(
false
)
})
})
})
})

View File

@@ -0,0 +1,764 @@
import esmock from 'esmock'
import sinon from 'sinon'
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const ObjectId = mongodb.ObjectId
const MODULE_PATH = new URL(
'../../../../app/src/Features/Project/ProjectListController',
import.meta.url
).pathname
describe('ProjectListController', function () {
beforeEach(async function () {
this.project_id = new ObjectId('abcdefabcdefabcdefabcdef')
this.user = {
_id: new 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.SplitTestSessionHandler = {
promises: {
sessionMaintenance: sinon.stub().resolves(),
},
}
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.SubscriptionLocator = {
promises: {
getUserSubscription: sinon.stub().resolves({}),
},
}
this.GeoIpLookup = {
promises: {
getCurrencyCode: sinon.stub().resolves({
countryCode: 'US',
currencyCode: 'USD',
}),
},
}
this.TutorialHandler = {
getInactiveTutorials: sinon.stub().returns([]),
}
this.Modules = {
promises: {
hooks: {
fire: sinon.stub().resolves([]),
},
},
}
this.ProjectListController = await esmock.strict(MODULE_PATH, {
'mongodb-legacy': { ObjectId },
'@overleaf/settings': this.settings,
'@overleaf/metrics': this.Metrics,
'../../../../app/src/Features/SplitTests/SplitTestHandler':
this.SplitTestHandler,
'../../../../app/src/Features/SplitTests/SplitTestSessionHandler':
this.SplitTestSessionHandler,
'../../../../app/src/Features/User/UserController': this.UserController,
'../../../../app/src/Features/Project/ProjectHelper': this.ProjectHelper,
'../../../../app/src/Features/Subscription/LimitationsManager':
this.LimitationsManager,
'../../../../app/src/Features/Tags/TagsHandler': this.TagsHandler,
'../../../../app/src/Features/Notifications/NotificationsHandler':
this.NotificationsHandler,
'../../../../app/src/models/User': { User: this.UserModel },
'../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter,
'../../../../app/src/Features/Authentication/SessionManager':
this.SessionManager,
'../../../../app/src/infrastructure/Features': this.Features,
'../../../../app/src/Features/User/UserGetter': this.UserGetter,
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder':
this.SubscriptionViewModelBuilder,
'../../../../app/src/infrastructure/Modules': this.Modules,
'../../../../app/src/Features/Survey/SurveyHandler': this.SurveyHandler,
'../../../../app/src/Features/User/UserPrimaryEmailCheckHandler':
this.UserPrimaryEmailCheckHandler,
'../../../../app/src/Features/Notifications/NotificationsBuilder':
this.NotificationBuilder,
'../../../../app/src/Features/Subscription/SubscriptionLocator':
this.SubscriptionLocator,
'../../../../app/src/infrastructure/GeoIpLookup': this.GeoIpLookup,
'../../../../app/src/Features/Tutorial/TutorialHandler':
this.TutorialHandler,
})
this.req = {
query: {},
params: {
Project_id: this.project_id,
},
headers: {},
session: {
user: this.user,
},
body: {},
i18n: {
translate() {},
},
}
this.res = {}
})
describe('projectListPage', 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.review = [{ _id: 8, lastUpdated: 4, owner_ref: 'user-6' }]
this.allProjects = {
owned: this.projects,
readAndWrite: this.readAndWrite,
readOnly: this.readOnly,
tokenReadAndWrite: this.tokenReadAndWrite,
tokenReadOnly: this.tokenReadOnly,
review: this.review,
}
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.projectListPage(this.req, this.res)
})
it('should invoke the session maintenance', function (done) {
this.Features.hasFeature.withArgs('saas').returns(true)
this.res.render = () => {
this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
this.req,
this.user
)
done()
}
this.ProjectListController.projectListPage(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.projectListPage(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.projectListPage(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 +
this.review.length
)
done()
}
this.ProjectListController.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(this.req, this.res)
})
it('should show INR Banner for Indian users with free account', function (done) {
// usersBestSubscription is only available when saas feature is present
this.Features.hasFeature.withArgs('saas').returns(true)
this.SubscriptionViewModelBuilder.promises.getBestSubscription.resolves({
type: 'free',
})
this.GeoIpLookup.promises.getCurrencyCode.resolves({
countryCode: 'IN',
})
this.res.render = (pageName, opts) => {
expect(opts.showInrGeoBanner).to.be.true
done()
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('should not show INR Banner for Indian users with premium account', function (done) {
// usersBestSubscription is only available when saas feature is present
this.Features.hasFeature.withArgs('saas').returns(true)
this.SubscriptionViewModelBuilder.promises.getBestSubscription.resolves({
type: 'individual',
})
this.GeoIpLookup.promises.getCurrencyCode.resolves({
countryCode: 'IN',
})
this.res.render = (pageName, opts) => {
expect(opts.showInrGeoBanner).to.be.false
done()
}
this.ProjectListController.projectListPage(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('saas').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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(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.projectListPage(this.req, this.res)
})
})
describe('enterprise banner', function () {
beforeEach(function (done) {
this.Features.hasFeature.withArgs('saas').returns(true)
this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves(
{ isMember: false }
)
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@test-domain.com',
},
])
done()
})
describe('normal enterprise banner', function () {
it('shows banner', function () {
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.true
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('does not show banner if user is part of any affiliation', function () {
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
affiliation: {
licence: 'pro_plus',
institution: {
id: 1,
confirmed: true,
name: 'Overleaf',
ssoBeta: false,
ssoEnabled: true,
},
},
},
])
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('does not show banner if user is part of any group subscription', function () {
this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves(
{ isMember: true }
)
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('have a banner variant of "FOMO" or "on-premise"', function () {
this.res.render = (pageName, opts) => {
expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([
'FOMO',
'on-premise',
])
}
this.ProjectListController.projectListPage(this.req, this.res)
})
})
describe('US government enterprise banner', function () {
it('does not show enterprise banner if US government enterprise banner is shown', function () {
const emails = [
{
email: 'test@test.mil',
confirmedAt: new Date('2024-01-01'),
},
]
this.UserGetter.promises.getUserFullEmails.resolves(emails)
this.Modules.promises.hooks.fire
.withArgs('getUSGovBanner', emails, false, [])
.resolves([
{
showUSGovBanner: true,
usGovBannerVariant: 'variant',
},
])
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
expect(opts.showUSGovBanner).to.be.true
}
this.ProjectListController.projectListPage(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.review = [{ _id: 8, lastUpdated: 5, owner_ref: 'user-6' }]
this.allProjects = {
owned: this.projects,
readAndWrite: this.readAndWrite,
readOnly: this.readOnly,
tokenReadAndWrite: this.tokenReadAndWrite,
tokenReadOnly: this.tokenReadOnly,
review: this.review,
}
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.projectListPage(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 +
this.review.length -
1
)
done()
}
this.ProjectListController.projectListPage(this.req, this.res)
})
})
})

View File

@@ -0,0 +1,600 @@
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Project/ProjectLocator'
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const project = { _id: '1234566', rootFolder: [] }
class Project {}
const rootDoc = { name: 'rootDoc', _id: 'das239djd' }
const doc1 = { name: 'otherDoc.txt', _id: 'dsad2ddd' }
const doc2 = { name: 'docname.txt', _id: 'dsad2ddddd' }
const file1 = { name: 'file1', _id: 'dsa9lkdsad' }
const subSubFile = { name: 'subSubFile', _id: 'd1d2dk' }
const subSubDoc = { name: 'subdoc.txt', _id: '321dmdwi' }
const secondSubFolder = {
name: 'secondSubFolder',
_id: 'dsa3e23',
docs: [subSubDoc],
fileRefs: [subSubFile],
folders: [],
}
const subFolder = {
name: 'subFolder',
_id: 'dsadsa93',
folders: [secondSubFolder, null],
docs: [],
fileRefs: [],
}
const subFolder1 = { name: 'subFolder1', _id: '123asdjoij' }
const rootFolder = {
_id: '123sdskd',
docs: [doc1, doc2, null, rootDoc],
fileRefs: [file1],
folders: [subFolder1, subFolder],
}
project.rootFolder[0] = rootFolder
project.rootDoc_id = rootDoc._id
describe('ProjectLocator', function () {
beforeEach(function () {
Project.findById = (projectId, callback) => {
callback(null, project)
}
this.ProjectGetter = {
getProject: sinon.stub().callsArgWith(2, null, project),
}
this.ProjectHelper = {
isArchived: sinon.stub(),
isTrashed: sinon.stub(),
isArchivedOrTrashed: sinon.stub(),
}
this.locator = SandboxedModule.require(modulePath, {
requires: {
'../../models/Project': { Project },
'../../models/User': { User: this.User },
'./ProjectGetter': this.ProjectGetter,
'./ProjectHelper': this.ProjectHelper,
},
})
})
describe('finding a doc', function () {
it('finds one at the root level', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: doc2._id, type: 'docs' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(doc2._id)
path.fileSystem.should.equal(`/${doc2.name}`)
parentFolder._id.should.equal(project.rootFolder[0]._id)
path.mongo.should.equal('rootFolder.0.docs.1')
done()
}
)
})
it('when it is nested', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: subSubDoc._id, type: 'doc' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
expect(foundElement._id).to.equal(subSubDoc._id)
path.fileSystem.should.equal(
`/${subFolder.name}/${secondSubFolder.name}/${subSubDoc.name}`
)
parentFolder._id.should.equal(secondSubFolder._id)
path.mongo.should.equal('rootFolder.0.folders.1.folders.0.docs.0')
done()
}
)
})
it('should give error if element could not be found', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: 'ddsd432nj42', type: 'docs' },
(err, foundElement, path, parentFolder) => {
expect(err).to.be.instanceOf(Errors.NotFoundError)
expect(err).to.have.property('message', 'entity not found')
done()
}
)
})
})
describe('finding a folder', function () {
it('should return root folder when looking for root folder', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: rootFolder._id, type: 'folder' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(rootFolder._id)
done()
}
)
})
it('when at root', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: subFolder._id, type: 'folder' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(subFolder._id)
path.fileSystem.should.equal(`/${subFolder.name}`)
parentFolder._id.should.equal(rootFolder._id)
path.mongo.should.equal('rootFolder.0.folders.1')
done()
}
)
})
it('when deeply nested', function (done) {
this.locator.findElement(
{
project_id: project._id,
element_id: secondSubFolder._id,
type: 'folder',
},
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(secondSubFolder._id)
path.fileSystem.should.equal(
`/${subFolder.name}/${secondSubFolder.name}`
)
parentFolder._id.should.equal(subFolder._id)
path.mongo.should.equal('rootFolder.0.folders.1.folders.0')
done()
}
)
})
})
describe('finding a file', function () {
it('when at root', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: file1._id, type: 'fileRefs' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(file1._id)
path.fileSystem.should.equal(`/${file1.name}`)
parentFolder._id.should.equal(rootFolder._id)
path.mongo.should.equal('rootFolder.0.fileRefs.0')
done()
}
)
})
it('when deeply nested', function (done) {
this.locator.findElement(
{
project_id: project._id,
element_id: subSubFile._id,
type: 'fileRefs',
},
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(subSubFile._id)
path.fileSystem.should.equal(
`/${subFolder.name}/${secondSubFolder.name}/${subSubFile.name}`
)
parentFolder._id.should.equal(secondSubFolder._id)
path.mongo.should.equal('rootFolder.0.folders.1.folders.0.fileRefs.0')
done()
}
)
})
})
describe('finding an element with wrong element type', function () {
it('should add an s onto the element type', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: subSubDoc._id, type: 'doc' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(subSubDoc._id)
done()
}
)
})
it('should convert file to fileRefs', function (done) {
this.locator.findElement(
{ project_id: project._id, element_id: file1._id, type: 'fileRefs' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(file1._id)
done()
}
)
})
})
describe('should be able to take actual project as well as id', function () {
const doc3 = {
_id: '123dsdj3',
name: 'doc3',
}
const rootFolder2 = {
_id: '123sddedskd',
docs: [doc3],
}
const project2 = {
_id: '1234566',
rootFolder: [rootFolder2],
}
it('should find doc in project', function (done) {
this.locator.findElement(
{ project: project2, element_id: doc3._id, type: 'docs' },
(err, foundElement, path, parentFolder) => {
if (err != null) {
return done(err)
}
foundElement._id.should.equal(doc3._id)
path.fileSystem.should.equal(`/${doc3.name}`)
parentFolder._id.should.equal(project2.rootFolder[0]._id)
path.mongo.should.equal('rootFolder.0.docs.0')
done()
}
)
})
})
describe('finding root doc', function () {
it('should return root doc when passed project', function (done) {
this.locator.findRootDoc(project, (err, doc) => {
if (err != null) {
return done(err)
}
doc._id.should.equal(rootDoc._id)
done()
})
})
it('should return root doc when passed project_id', function (done) {
this.locator.findRootDoc(project._id, (err, doc) => {
if (err != null) {
return done(err)
}
doc._id.should.equal(rootDoc._id)
done()
})
})
it('should return null when the project has no rootDoc', function (done) {
project.rootDoc_id = null
this.locator.findRootDoc(project, (err, doc) => {
if (err != null) {
return done(err)
}
expect(doc).to.equal(null)
done()
})
})
it('should return null when the rootDoc_id no longer exists', function (done) {
project.rootDoc_id = 'doesntexist'
this.locator.findRootDoc(project, (err, doc) => {
if (err != null) {
return done(err)
}
expect(doc).to.equal(null)
done()
})
})
})
describe('findElementByPath', function () {
it('should take a doc path and return the element for a root level document', function (done) {
const path = `${doc1.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(doc1)
expect(type).to.equal('doc')
expect(folder).to.equal(rootFolder)
done()
}
)
})
it('should take a doc path and return the element for a root level document with a starting slash', function (done) {
const path = `/${doc1.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(doc1)
expect(type).to.equal('doc')
expect(folder).to.equal(rootFolder)
done()
}
)
})
it('should take a doc path and return the element for a nested document', function (done) {
const path = `${subFolder.name}/${secondSubFolder.name}/${subSubDoc.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(subSubDoc)
expect(type).to.equal('doc')
expect(folder).to.equal(secondSubFolder)
done()
}
)
})
it('should take a file path and return the element for a root level document', function (done) {
const path = `${file1.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(file1)
expect(type).to.equal('file')
expect(folder).to.equal(rootFolder)
done()
}
)
})
it('should take a file path and return the element for a nested document', function (done) {
const path = `${subFolder.name}/${secondSubFolder.name}/${subSubFile.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(subSubFile)
expect(type).to.equal('file')
expect(folder).to.equal(secondSubFolder)
done()
}
)
})
it('should take a file path and return the element for a nested document case insenstive', function (done) {
const path = `${subFolder.name.toUpperCase()}/${secondSubFolder.name.toUpperCase()}/${subSubFile.name.toUpperCase()}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(subSubFile)
expect(type).to.equal('file')
expect(folder).to.equal(secondSubFolder)
done()
}
)
})
it('should not return elements with a case-insensitive match when exactCaseMatch is true', function (done) {
const path = `${subFolder.name.toUpperCase()}/${secondSubFolder.name.toUpperCase()}/${subSubFile.name.toUpperCase()}`
this.locator.findElementByPath(
{ project, path, exactCaseMatch: true },
(err, element, type, folder) => {
err.should.not.equal(undefined)
expect(element).to.be.undefined
expect(type).to.be.undefined
done()
}
)
})
it('should take a file path and return the element for a nested folder', function (done) {
const path = `${subFolder.name}/${secondSubFolder.name}`
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(secondSubFolder)
expect(type).to.equal('folder')
expect(folder).to.equal(subFolder)
done()
}
)
})
it('should take a file path and return the root folder', function (done) {
const path = '/'
this.locator.findElementByPath(
{ project, path },
(err, element, type, folder) => {
if (err != null) {
return done(err)
}
element.should.deep.equal(rootFolder)
expect(type).to.equal('folder')
expect(folder).to.equal(null)
done()
}
)
})
it('should return an error if the file can not be found inside know folder', function (done) {
const path = `${subFolder.name}/${secondSubFolder.name}/exist.txt`
this.locator.findElementByPath(
{ project, path },
(err, element, type) => {
err.should.not.equal(undefined)
expect(element).to.be.undefined
expect(type).to.be.undefined
done()
}
)
})
it('should return an error if the file can not be found inside unknown folder', function (done) {
const path = 'this/does/not/exist.txt'
this.locator.findElementByPath(
{ project, path },
(err, element, type) => {
err.should.not.equal(undefined)
expect(element).to.be.undefined
expect(type).to.be.undefined
done()
}
)
})
describe('where duplicate folder exists', function () {
beforeEach(function () {
this.duplicateFolder = {
name: 'duplicate1',
_id: '1234',
folders: [
{
name: '1',
docs: [{ name: 'main.tex', _id: '456' }],
folders: [],
fileRefs: [],
},
],
docs: [(this.doc = { name: 'main.tex', _id: '456' })],
fileRefs: [],
}
this.project = {
rootFolder: [
{
folders: [this.duplicateFolder, this.duplicateFolder],
fileRefs: [],
docs: [],
},
],
}
})
it('should not call the callback more than once', function (done) {
const path = `${this.duplicateFolder.name}/${this.doc.name}`
this.locator.findElementByPath({ project: this.project, path }, () =>
done()
)
}) // mocha will throw exception if done called multiple times
it('should not call the callback more than once when the path is longer than 1 level below the duplicate level', function (done) {
const path = `${this.duplicateFolder.name}/1/main.tex`
this.locator.findElementByPath({ project: this.project, path }, () =>
done()
)
})
}) // mocha will throw exception if done called multiple times
describe('with a null doc', function () {
beforeEach(function () {
this.project = {
rootFolder: [
{
folders: [],
fileRefs: [],
docs: [{ name: 'main.tex' }, null, { name: 'other.tex' }],
},
],
}
})
it('should not crash with a null', function (done) {
const path = '/other.tex'
this.locator.findElementByPath(
{ project: this.project, path },
(err, element) => {
if (err != null) {
return done(err)
}
element.name.should.equal('other.tex')
done()
}
)
})
})
describe('with a null project', function () {
beforeEach(function () {
this.ProjectGetter = { getProject: sinon.stub().callsArg(2) }
})
it('should not crash with a null', function (done) {
const path = '/other.tex'
this.locator.findElementByPath(
{ project_id: project._id, path },
(err, element) => {
expect(err).to.exist
done()
}
)
})
})
describe('with a project_id', function () {
it('should take a doc path and return the element for a root level document', function (done) {
const path = `${doc1.name}`
this.locator.findElementByPath(
{ project_id: project._id, path },
(err, element, type) => {
if (err != null) {
return done(err)
}
this.ProjectGetter.getProject
.calledWith(project._id, { rootFolder: true, rootDoc_id: true })
.should.equal(true)
element.should.deep.equal(doc1)
expect(type).to.equal('doc')
done()
}
)
})
})
})
describe('findElementByMongoPath', function () {
it('traverses the file tree like Mongo would do', function () {
const element = this.locator.findElementByMongoPath(
project,
'rootFolder.0.folders.1.folders.0.fileRefs.0'
)
expect(element).to.equal(subSubFile)
})
it('throws an error if no element is found', function () {
expect(() =>
this.locator.findElementByMongoPath(
project,
'rootolder.0.folders.0.folders.0.fileRefs.0'
)
).to.throw
})
})
})

View File

@@ -0,0 +1,233 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
no-useless-constructor,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Project/ProjectOptionsHandler.js'
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
describe('ProjectOptionsHandler', function () {
const projectId = '4eecaffcbffa66588e000008'
beforeEach(function () {
let Project
this.projectModel = Project = class Project {
constructor(options) {}
}
this.projectModel.updateOne = sinon.stub().resolves()
this.db = {
projects: {
updateOne: sinon.stub().resolves(),
},
}
this.handler = SandboxedModule.require(modulePath, {
requires: {
'../../models/Project': { Project: this.projectModel },
'@overleaf/settings': {
languages: [
{ name: 'English', code: 'en' },
{ name: 'French', code: 'fr' },
],
imageRoot: 'docker-repo/subdir',
allowedImageNames: [
{ imageName: 'texlive-0000.0', imageDesc: 'test image 0' },
{ imageName: 'texlive-1234.5', imageDesc: 'test image 1' },
],
},
'../../infrastructure/mongodb': { db: this.db, ObjectId },
},
})
})
describe('Setting the compiler', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.setCompiler(projectId, 'xeLaTeX')
const args = this.projectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].compiler.should.equal('xelatex')
})
it('should not perform and update on mongo if it is not a recognised compiler', async function () {
const fakeComplier = 'something'
expect(
this.handler.promises.setCompiler(projectId, 'something')
).to.be.rejectedWith(`invalid compiler: ${fakeComplier}`)
this.projectModel.updateOne.called.should.equal(false)
})
describe('when called without arg', function () {
it('should callback with null', async function () {
await this.handler.promises.setCompiler(projectId, null)
this.projectModel.updateOne.callCount.should.equal(0)
})
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.projectModel.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.setCompiler(projectId, 'xeLaTeX')).to.be
.rejected
})
})
})
describe('Setting the imageName', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.setImageName(projectId, 'texlive-1234.5')
const args = this.projectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].imageName.should.equal('docker-repo/subdir/texlive-1234.5')
})
it('should not perform and update on mongo if it is not a reconised image name', async function () {
const fakeImageName = 'something'
expect(
this.handler.promises.setImageName(projectId, fakeImageName)
).to.be.rejectedWith(`invalid imageName: ${fakeImageName}`)
this.projectModel.updateOne.called.should.equal(false)
})
describe('when called without arg', function () {
it('should callback with null', async function () {
await this.handler.promises.setImageName(projectId, null)
this.projectModel.updateOne.callCount.should.equal(0)
})
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.projectModel.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.setImageName(projectId, 'texlive-1234.5'))
.to.be.rejected
})
})
})
describe('setting the spellCheckLanguage', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.setSpellCheckLanguage(projectId, 'fr')
const args = this.projectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].spellCheckLanguage.should.equal('fr')
})
it('should not perform and update on mongo if it is not a reconised langauge', async function () {
const fakeLanguageCode = 'not a lang'
expect(
this.handler.promises.setSpellCheckLanguage(projectId, fakeLanguageCode)
).to.be.rejectedWith(`invalid languageCode: ${fakeLanguageCode}`)
this.projectModel.updateOne.called.should.equal(false)
})
it('should perform and update on mongo if the language is blank (means turn it off)', async function () {
await this.handler.promises.setSpellCheckLanguage(projectId, '')
this.projectModel.updateOne.called.should.equal(true)
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.projectModel.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.setSpellCheckLanguage(projectId)).to.be
.rejected
})
})
})
describe('setting the brandVariationId', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.setBrandVariationId(projectId, '123')
const args = this.projectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].brandVariationId.should.equal('123')
})
it('should not perform and update on mongo if there is no brand variation', async function () {
await this.handler.promises.setBrandVariationId(projectId, null)
this.projectModel.updateOne.called.should.equal(false)
})
it('should not perform and update on mongo if brand variation is an empty string', async function () {
await this.handler.promises.setBrandVariationId(projectId, '')
this.projectModel.updateOne.called.should.equal(false)
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.projectModel.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.setBrandVariationId(projectId, '123')).to
.be.rejected
})
})
})
describe('setting the rangesSupportEnabled', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.setHistoryRangesSupport(projectId, true)
sinon.assert.calledWith(
this.db.projects.updateOne,
{ _id: new ObjectId(projectId) },
{ $set: { 'overleaf.history.rangesSupportEnabled': true } }
)
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.db.projects.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.setHistoryRangesSupport(projectId, true))
.to.be.rejected
})
})
})
describe('unsetting the brandVariationId', function () {
it('should perform and update on mongo', async function () {
await this.handler.promises.unsetBrandVariationId(projectId)
const args = this.projectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
expect(args[1]).to.deep.equal({ $unset: { brandVariationId: 1 } })
})
describe('when mongo update error occurs', function () {
beforeEach(function () {
this.projectModel.updateOne = sinon.stub().yields('error')
})
it('should be rejected', async function () {
expect(this.handler.promises.unsetBrandVariationId(projectId)).to.be
.rejected
})
})
})
})

View File

@@ -0,0 +1,754 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
no-useless-escape,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const sinon = require('sinon')
const modulePath =
'../../../../app/src/Features/Project/ProjectRootDocManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ProjectRootDocManager', function () {
beforeEach(function () {
this.project_id = 'project-123'
this.docPaths = {}
this.docId1 = new ObjectId()
this.docId2 = new ObjectId()
this.docId3 = new ObjectId()
this.docId4 = new ObjectId()
this.docPaths[this.docId1] = '/chapter1.tex'
this.docPaths[this.docId2] = '/main.tex'
this.docPaths[this.docId3] = '/nested/chapter1a.tex'
this.docPaths[this.docId4] = '/nested/chapter1b.tex'
this.sl_req_id = 'sl-req-id-123'
this.callback = sinon.stub()
this.globbyFiles = ['a.tex', 'b.tex', 'main.tex']
this.globby = sinon.stub().returns(
new Promise(resolve => {
return resolve(this.globbyFiles)
})
)
this.fs = {
readFile: sinon.stub().callsArgWith(2, new Error('file not found')),
stat: sinon.stub().callsArgWith(1, null, { size: 100 }),
}
return (this.ProjectRootDocManager = SandboxedModule.require(modulePath, {
requires: {
'./ProjectEntityHandler': (this.ProjectEntityHandler = {}),
'./ProjectEntityUpdateHandler': (this.ProjectEntityUpdateHandler = {}),
'./ProjectGetter': (this.ProjectGetter = {}),
'../../infrastructure/GracefulShutdown': {
BackgroundTaskTracker: class {
add() {}
done() {}
},
},
globby: this.globby,
fs: this.fs,
},
}))
})
describe('setRootDocAutomatically', function () {
beforeEach(function () {
this.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
this.ProjectEntityUpdateHandler.isPathValidForRootDoc = sinon
.stub()
.returns(true)
})
describe('when there is a suitable root doc', function () {
beforeEach(function (done) {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
lines: [
'something else',
'\\begin{document}',
'Hello world',
'\\end{document}',
],
},
'/main.tex': {
_id: this.docId2,
lines: [
'different line',
'\\documentclass{article}',
'\\input{chapter1}',
],
},
'/nested/chapter1a.tex': {
_id: this.docId3,
lines: ['Hello world'],
},
'/nested/chapter1b.tex': {
_id: this.docId4,
lines: ['Hello world'],
},
}
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
done
)
})
it('should check the docs of the project', function () {
return this.ProjectEntityHandler.getAllDocs
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to the doc containing a documentclass', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2)
.should.equal(true)
})
})
describe('when the root doc is an Rtex file', function () {
beforeEach(function (done) {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
lines: ['\\begin{document}', 'Hello world', '\\end{document}'],
},
'/main.Rtex': {
_id: this.docId2,
lines: ['\\documentclass{article}', '\\input{chapter1}'],
},
}
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
return this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
done
)
})
it('should set the root doc to the doc containing a documentclass', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2)
.should.equal(true)
})
})
describe('when there is no suitable root doc', function () {
beforeEach(function (done) {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
lines: ['\\begin{document}', 'Hello world', '\\end{document}'],
},
'/style.bst': {
_id: this.docId2,
lines: ['%Example: \\documentclass{article}'],
},
}
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
return this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
done
)
})
it('should not set the root doc to the doc containing a documentclass', function () {
return this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(
false
)
})
})
})
describe('findRootDocFileFromDirectory', function () {
beforeEach(function () {
this.fs.readFile
.withArgs('/foo/a.tex')
.callsArgWith(2, null, 'Hello World!')
this.fs.readFile
.withArgs('/foo/b.tex')
.callsArgWith(2, null, "I'm a little teapot, get me out of here.")
this.fs.readFile
.withArgs('/foo/main.tex')
.callsArgWith(2, null, "Help, I'm trapped in a unit testing factory")
this.fs.readFile
.withArgs('/foo/c.tex')
.callsArgWith(2, null, 'Tomato, tomahto.')
this.fs.readFile
.withArgs('/foo/a/a.tex')
.callsArgWith(2, null, 'Potato? Potahto. Potootee!')
return (this.documentclassContent = '% test\n\\documentclass\n% test')
})
describe('when there is a file in a subfolder', function () {
beforeEach(function () {
// have to splice globbyFiles weirdly because of the way the stubbed globby method handles references
return this.globbyFiles.splice(
0,
this.globbyFiles.length,
'c.tex',
'a.tex',
'a/a.tex',
'b.tex'
)
})
it('processes the root folder files first, and then the subfolder, in alphabetical order', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path) => {
expect(error).not.to.exist
expect(path).to.equal('a.tex')
sinon.assert.callOrder(
this.fs.readFile.withArgs('/foo/a.tex'),
this.fs.readFile.withArgs('/foo/b.tex'),
this.fs.readFile.withArgs('/foo/c.tex'),
this.fs.readFile.withArgs('/foo/a/a.tex')
)
return done()
}
)
})
it('processes smaller files first', function (done) {
this.fs.stat.withArgs('/foo/c.tex').callsArgWith(1, null, { size: 1 })
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path) => {
expect(error).not.to.exist
expect(path).to.equal('c.tex')
sinon.assert.callOrder(
this.fs.readFile.withArgs('/foo/c.tex'),
this.fs.readFile.withArgs('/foo/a.tex'),
this.fs.readFile.withArgs('/foo/b.tex'),
this.fs.readFile.withArgs('/foo/a/a.tex')
)
return done()
}
)
})
})
describe('when main.tex contains a documentclass', function () {
beforeEach(function () {
return this.fs.readFile
.withArgs('/foo/main.tex')
.callsArgWith(2, null, this.documentclassContent)
})
it('returns main.tex', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path, content) => {
expect(error).not.to.exist
expect(path).to.equal('main.tex')
expect(content).to.equal(this.documentclassContent)
return done()
}
)
})
it('processes main.text first and stops processing when it finds the content', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
() => {
expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
expect(this.fs.readFile).not.to.be.calledWith('/foo/a.tex')
return done()
}
)
})
})
describe('when main.tex does not contain a line starting with \\documentclass', function () {
beforeEach(function () {
this.fs.readFile.withArgs('/foo/a.tex').callsArgWith(2, null, 'foo')
this.fs.readFile.withArgs('/foo/main.tex').callsArgWith(2, null, 'foo')
this.fs.readFile.withArgs('/foo/z.tex').callsArgWith(2, null, 'foo')
this.fs.readFile
.withArgs('/foo/nested/chapter1a.tex')
.callsArgWith(2, null, 'foo')
})
it('returns the first .tex file from the root folder', function (done) {
this.globbyFiles.splice(
0,
this.globbyFiles.length,
'a.tex',
'z.tex',
'nested/chapter1a.tex'
)
this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path, content) => {
expect(error).not.to.exist
expect(path).to.equal('a.tex')
expect(content).to.equal('foo')
return done()
}
)
})
it('returns main.tex file from the root folder', function (done) {
this.globbyFiles.splice(
0,
this.globbyFiles.length,
'a.tex',
'z.tex',
'main.tex',
'nested/chapter1a.tex'
)
this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path, content) => {
expect(error).not.to.exist
expect(path).to.equal('main.tex')
expect(content).to.equal('foo')
return done()
}
)
})
})
describe('when a.tex contains a documentclass', function () {
beforeEach(function () {
return this.fs.readFile
.withArgs('/foo/a.tex')
.callsArgWith(2, null, this.documentclassContent)
})
it('returns a.tex', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path, content) => {
expect(error).not.to.exist
expect(path).to.equal('a.tex')
expect(content).to.equal(this.documentclassContent)
return done()
}
)
})
it('processes main.text first and stops processing when it finds the content', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
() => {
expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
expect(this.fs.readFile).not.to.be.calledWith('/foo/b.tex')
return done()
}
)
})
})
describe('when there is no documentclass', function () {
it('returns with no error', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
error => {
expect(error).not.to.exist
return done()
}
)
})
it('processes all the files', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
() => {
expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
expect(this.fs.readFile).to.be.calledWith('/foo/b.tex')
return done()
}
)
})
})
describe('when there is an error reading a file', function () {
beforeEach(function () {
return this.fs.readFile
.withArgs('/foo/a.tex')
.callsArgWith(2, new Error('something went wrong'))
})
it('returns an error', function (done) {
return this.ProjectRootDocManager.findRootDocFileFromDirectory(
'/foo',
(error, path, content) => {
expect(error).to.exist
expect(path).not.to.exist
expect(content).not.to.exist
return done()
}
)
})
})
})
describe('setRootDocFromName', function () {
describe('when there is a suitable root doc', function () {
beforeEach(function (done) {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocFromName(
this.project_id,
'/main.tex',
done
)
})
it('should check the docs of the project', function () {
return this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc but the leading slash is missing', function () {
beforeEach(function (done) {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocFromName(
this.project_id,
'main.tex',
done
)
})
it('should check the docs of the project', function () {
return this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc with a basename match', function () {
beforeEach(function (done) {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocFromName(
this.project_id,
'chapter1a.tex',
done
)
})
it('should check the docs of the project', function () {
return this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc using the basename', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId3.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc but the filename is in quotes', function () {
beforeEach(function (done) {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocFromName(
this.project_id,
"'main.tex'",
done
)
})
it('should check the docs of the project', function () {
return this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is no suitable root doc', function () {
beforeEach(function (done) {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocFromName(
this.project_id,
'other.tex',
done
)
})
it('should not set the root doc', function () {
return this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(
false
)
})
})
})
describe('ensureRootDocumentIsSet', function () {
beforeEach(function () {
this.project = {}
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(2, null, this.project)
return (this.ProjectRootDocManager.setRootDocAutomatically = sinon
.stub()
.callsArgWith(1, null))
})
describe('when the root doc is set', function () {
beforeEach(function () {
this.project.rootDoc_id = this.docId2
return this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should find the project fetching only the rootDoc_id field', function () {
return this.ProjectGetter.getProject
.calledWith(this.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should not try to update the project rootDoc_id', function () {
return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
false
)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('when the root doc is not set', function () {
beforeEach(function () {
return this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should find the project with only the rootDoc_id field', function () {
return this.ProjectGetter.getProject
.calledWith(this.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should update the project rootDoc_id', function () {
return this.ProjectRootDocManager.setRootDocAutomatically
.calledWith(this.project_id)
.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('when the project does not exist', function () {
beforeEach(function () {
this.ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, null)
return this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'project not found'))
)
.should.equal(true)
})
})
})
describe('ensureRootDocumentIsValid', function () {
beforeEach(function () {
this.project = {}
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(2, null, this.project)
this.ProjectGetter.getProjectWithoutDocLines = sinon
.stub()
.callsArgWith(1, null, this.project)
this.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().yields()
this.ProjectEntityUpdateHandler.unsetRootDoc = sinon.stub().yields()
this.ProjectRootDocManager.setRootDocAutomatically = sinon
.stub()
.callsArgWith(1, null)
})
describe('when the root doc is set', function () {
describe('when the root doc is valid', function () {
beforeEach(function () {
this.project.rootDoc_id = this.docId2
this.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
.stub()
.callsArgWith(2, null, this.docPaths[this.docId2])
return this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
return this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should not try to update the project rootDoc_id', function () {
return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
false
)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('when the root doc is not valid', function () {
beforeEach(function () {
this.project.rootDoc_id = new ObjectId()
this.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
.stub()
.callsArgWith(2, null, null)
return this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
return this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should unset the root doc', function () {
return this.ProjectEntityUpdateHandler.unsetRootDoc
.calledWith(this.project_id)
.should.equal(true)
})
it('should try to find a new rootDoc', function () {
return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
true
)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
})
describe('when the root doc is not set', function () {
beforeEach(function () {
return this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
return this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should update the project rootDoc_id', function () {
return this.ProjectRootDocManager.setRootDocAutomatically
.calledWith(this.project_id)
.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('when the project does not exist', function () {
beforeEach(function () {
this.ProjectGetter.getProjectWithoutDocLines = sinon
.stub()
.callsArgWith(1, null, null)
return this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'project not found'))
)
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,102 @@
const sinon = require('sinon')
const modulePath =
'../../../../app/src/Features/Project/ProjectUpdateHandler.js'
const SandboxedModule = require('sandboxed-module')
describe('ProjectUpdateHandler', function () {
beforeEach(function () {
this.fakeTime = new Date()
this.clock = sinon.useFakeTimers(this.fakeTime.getTime())
})
afterEach(function () {
this.clock.restore()
})
beforeEach(function () {
this.ProjectModel = class Project {}
this.ProjectModel.updateOne = sinon.stub().returns({
exec: sinon.stub(),
})
this.handler = SandboxedModule.require(modulePath, {
requires: {
'../../models/Project': { Project: this.ProjectModel },
},
})
})
describe('marking a project as recently updated', function () {
beforeEach(function () {
this.project_id = 'project_id'
this.lastUpdatedAt = 987654321
this.lastUpdatedBy = 'fake-last-updater-id'
})
it('should send an update to mongo', async function () {
await this.handler.promises.markAsUpdated(
this.project_id,
this.lastUpdatedAt,
this.lastUpdatedBy
)
sinon.assert.calledWith(
this.ProjectModel.updateOne,
{
_id: this.project_id,
lastUpdated: { $lt: this.lastUpdatedAt },
},
{
lastUpdated: this.lastUpdatedAt,
lastUpdatedBy: this.lastUpdatedBy,
}
)
})
it('should set smart fallbacks', async function () {
await this.handler.promises.markAsUpdated(this.project_id, null, null)
sinon.assert.calledWithMatch(
this.ProjectModel.updateOne,
{
_id: this.project_id,
lastUpdated: { $lt: this.fakeTime },
},
{
lastUpdated: this.fakeTime,
lastUpdatedBy: null,
}
)
})
})
describe('markAsOpened', function () {
it('should send an update to mongo', async function () {
const projectId = 'project_id'
await this.handler.promises.markAsOpened(projectId)
const args = this.ProjectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
const date = args[1].lastOpened + ''
const now = Date.now() + ''
date.substring(0, 5).should.equal(now.substring(0, 5))
})
})
describe('markAsInactive', function () {
it('should send an update to mongo', async function () {
const projectId = 'project_id'
await this.handler.promises.markAsInactive(projectId)
const args = this.ProjectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].active.should.equal(false)
})
})
describe('markAsActive', function () {
it('should send an update to mongo', async function () {
const projectId = 'project_id'
await this.handler.promises.markAsActive(projectId)
const args = this.ProjectModel.updateOne.args[0]
args[0]._id.should.equal(projectId)
args[1].active.should.equal(true)
})
})
})

View File

@@ -0,0 +1,277 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { assert, expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Project/SafePath'
const SandboxedModule = require('sandboxed-module')
describe('SafePath', function () {
beforeEach(function () {
return (this.SafePath = SandboxedModule.require(modulePath))
})
describe('isCleanFilename', function () {
it('should accept a valid filename "main.tex"', function () {
const result = this.SafePath.isCleanFilename('main.tex')
return result.should.equal(true)
})
it('should not accept an empty filename', function () {
const result = this.SafePath.isCleanFilename('')
return result.should.equal(false)
})
it('should not accept / anywhere', function () {
const result = this.SafePath.isCleanFilename('foo/bar')
return result.should.equal(false)
})
it('should not accept .', function () {
const result = this.SafePath.isCleanFilename('.')
return result.should.equal(false)
})
it('should not accept ..', function () {
const result = this.SafePath.isCleanFilename('..')
return result.should.equal(false)
})
it('should not accept * anywhere', function () {
const result = this.SafePath.isCleanFilename('foo*bar')
return result.should.equal(false)
})
it('should not accept leading whitespace', function () {
const result = this.SafePath.isCleanFilename(' foobar.tex')
return result.should.equal(false)
})
it('should not accept trailing whitespace', function () {
const result = this.SafePath.isCleanFilename('foobar.tex ')
return result.should.equal(false)
})
it('should not accept leading and trailing whitespace', function () {
const result = this.SafePath.isCleanFilename(' foobar.tex ')
return result.should.equal(false)
})
it('should not accept control characters (0-31)', function () {
const result = this.SafePath.isCleanFilename('foo\u0010bar')
return result.should.equal(false)
})
it('should not accept control characters (127, delete)', function () {
const result = this.SafePath.isCleanFilename('foo\u007fbar')
return result.should.equal(false)
})
it('should not accept control characters (128-159)', function () {
const result = this.SafePath.isCleanFilename('foo\u0080\u0090bar')
return result.should.equal(false)
})
it('should not accept surrogate characters (128-159)', function () {
const result = this.SafePath.isCleanFilename('foo\uD800\uDFFFbar')
return result.should.equal(false)
})
it('should accept javascript property names', function () {
const result = this.SafePath.isCleanFilename('prototype')
return result.should.equal(true)
})
it('should accept javascript property names in the prototype', function () {
const result = this.SafePath.isCleanFilename('hasOwnProperty')
return result.should.equal(true)
})
// this test never worked correctly because the spaces are not replaced by underscores in isCleanFilename
// it 'should not accept javascript property names resulting from substitutions', ->
// result = @SafePath.isCleanFilename ' proto '
// result.should.equal false
// it 'should not accept a trailing .', ->
// result = @SafePath.isCleanFilename 'hello.'
// result.should.equal false
it('should not accept \\', function () {
const result = this.SafePath.isCleanFilename('foo\\bar')
return result.should.equal(false)
})
it('should reject filenames regardless of order (/g) for bad characters', function () {
const result1 = this.SafePath.isCleanFilename('foo*bar.tex') // * is not allowed
const result2 = this.SafePath.isCleanFilename('*foobar.tex') // bad char location is before previous match
return result1.should.equal(false) && result2.should.equal(false)
})
it('should reject filenames regardless of order (/g) for bad filenames', function () {
const result1 = this.SafePath.isCleanFilename('foo ') // trailing space
const result2 = this.SafePath.isCleanFilename(' foobar') // leading space, match location is before previous match
return result1.should.equal(false) && result2.should.equal(false)
})
})
describe('isCleanPath', function () {
it('should accept a valid filename "main.tex"', function () {
const result = this.SafePath.isCleanPath('main.tex')
return result.should.equal(true)
})
it('should accept a valid path "foo/main.tex"', function () {
const result = this.SafePath.isCleanPath('foo/main.tex')
return result.should.equal(true)
})
it('should accept empty path elements', function () {
const result = this.SafePath.isCleanPath('foo//main.tex')
return result.should.equal(true)
})
it('should not accept an empty filename', function () {
const result = this.SafePath.isCleanPath('foo/bar/')
return result.should.equal(false)
})
it('should accept a path that starts with a slash', function () {
const result = this.SafePath.isCleanPath('/etc/passwd')
return result.should.equal(true)
})
it('should not accept a path that has an asterisk as the 0th element', function () {
const result = this.SafePath.isCleanPath('*/foo/bar')
return result.should.equal(false)
})
it('should not accept a path that has an asterisk as a middle element', function () {
const result = this.SafePath.isCleanPath('foo/*/bar')
return result.should.equal(false)
})
it('should not accept a path that has an asterisk as the filename', function () {
const result = this.SafePath.isCleanPath('foo/bar/*')
return result.should.equal(false)
})
it('should not accept a path that contains an asterisk in the 0th element', function () {
const result = this.SafePath.isCleanPath('f*o/bar/baz')
return result.should.equal(false)
})
it('should not accept a path that contains an asterisk in a middle element', function () {
const result = this.SafePath.isCleanPath('foo/b*r/baz')
return result.should.equal(false)
})
it('should not accept a path that contains an asterisk in the filename', function () {
const result = this.SafePath.isCleanPath('foo/bar/b*z')
return result.should.equal(false)
})
it('should not accept multiple problematic elements', function () {
const result = this.SafePath.isCleanPath('f*o/b*r/b*z')
return result.should.equal(false)
})
it('should not accept a problematic path with an empty element', function () {
const result = this.SafePath.isCleanPath('foo//*/bar')
return result.should.equal(false)
})
it('should not accept javascript property names', function () {
const result = this.SafePath.isCleanPath('prototype')
return result.should.equal(false)
})
it('should not accept javascript property names in the prototype', function () {
const result = this.SafePath.isCleanPath('hasOwnProperty')
return result.should.equal(false)
})
it('should not accept javascript property names resulting from substitutions', function () {
const result = this.SafePath.isCleanPath(' proto ')
return result.should.equal(false)
})
})
describe('isAllowedLength', function () {
it('should accept a valid path "main.tex"', function () {
const result = this.SafePath.isAllowedLength('main.tex')
return result.should.equal(true)
})
it('should not accept an extremely long path', function () {
const longPath = new Array(1000).join('/subdir') + '/main.tex'
const result = this.SafePath.isAllowedLength(longPath)
return result.should.equal(false)
})
it('should not accept an empty path', function () {
const result = this.SafePath.isAllowedLength('')
return result.should.equal(false)
})
})
describe('clean', function () {
it('should not modify a valid filename', function () {
const result = this.SafePath.clean('main.tex')
return result.should.equal('main.tex')
})
it('should replace invalid characters with _', function () {
const result = this.SafePath.clean('foo/bar*/main.tex')
return result.should.equal('foo_bar__main.tex')
})
it('should replace "." with "_"', function () {
const result = this.SafePath.clean('.')
return result.should.equal('_')
})
it('should replace ".." with "__"', function () {
const result = this.SafePath.clean('..')
return result.should.equal('__')
})
it('should replace a single trailing space with _', function () {
const result = this.SafePath.clean('foo ')
return result.should.equal('foo_')
})
it('should replace a multiple trailing spaces with ___', function () {
const result = this.SafePath.clean('foo ')
return result.should.equal('foo__')
})
it('should replace a single leading space with _', function () {
const result = this.SafePath.clean(' foo')
return result.should.equal('_foo')
})
it('should replace a multiple leading spaces with ___', function () {
const result = this.SafePath.clean(' foo')
return result.should.equal('__foo')
})
it('should prefix javascript property names with @', function () {
const result = this.SafePath.clean('prototype')
return result.should.equal('@prototype')
})
it('should prefix javascript property names in the prototype with @', function () {
const result = this.SafePath.clean('hasOwnProperty')
return result.should.equal('@hasOwnProperty')
})
})
})

Some files were not shown because too many files have changed in this diff Show More