first commit
This commit is contained in:
132
services/web/test/unit/bootstrap.js
vendored
Normal file
132
services/web/test/unit/bootstrap.js
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
const Path = require('path')
|
||||
const chai = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
/*
|
||||
* Chai configuration
|
||||
*/
|
||||
|
||||
// add chai.should()
|
||||
chai.should()
|
||||
|
||||
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
|
||||
// has a nicer failure messages
|
||||
chai.use(require('sinon-chai'))
|
||||
|
||||
// Load promise support for chai
|
||||
chai.use(require('chai-as-promised'))
|
||||
|
||||
// Do not truncate assertion errors
|
||||
chai.config.truncateThreshold = 0
|
||||
|
||||
// add support for mongoose in sinon
|
||||
require('sinon-mongoose')
|
||||
|
||||
// ensure every ObjectId has the id string as a property for correct comparisons
|
||||
require('mongodb-legacy').ObjectId.cacheHexString = true
|
||||
|
||||
/*
|
||||
* Global stubs
|
||||
*/
|
||||
const globalStubsSandbox = sinon.createSandbox()
|
||||
const globalStubs = {
|
||||
logger: {
|
||||
debug: globalStubsSandbox.stub(),
|
||||
info: globalStubsSandbox.stub(),
|
||||
log: globalStubsSandbox.stub(),
|
||||
warn: globalStubsSandbox.stub(),
|
||||
err: globalStubsSandbox.stub(),
|
||||
error: globalStubsSandbox.stub(),
|
||||
fatal: globalStubsSandbox.stub(),
|
||||
},
|
||||
}
|
||||
|
||||
/*
|
||||
* Sandboxed module configuration
|
||||
*/
|
||||
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
SandboxedModule.configure({
|
||||
ignoreMissing: true,
|
||||
requires: getSandboxedModuleRequires(),
|
||||
globals: {
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
Buffer,
|
||||
Promise,
|
||||
console,
|
||||
process,
|
||||
URL,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
},
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function getSandboxedModuleRequires() {
|
||||
const requires = {
|
||||
'@overleaf/logger': globalStubs.logger,
|
||||
}
|
||||
|
||||
const internalModules = [
|
||||
'../../app/src/Features/Errors/Errors',
|
||||
'../../app/src/Features/Helpers/Mongo',
|
||||
]
|
||||
const externalLibs = [
|
||||
'async',
|
||||
'bull',
|
||||
'json2csv',
|
||||
'lodash',
|
||||
'marked',
|
||||
'moment',
|
||||
'@overleaf/o-error',
|
||||
'sanitize-html',
|
||||
'sshpk',
|
||||
'xml2js',
|
||||
'mongodb',
|
||||
]
|
||||
for (const modulePath of internalModules) {
|
||||
requires[Path.resolve(__dirname, modulePath)] = require(modulePath)
|
||||
}
|
||||
for (const lib of externalLibs) {
|
||||
requires[lib] = require(lib)
|
||||
}
|
||||
return requires
|
||||
}
|
||||
|
||||
/*
|
||||
* Mocha hooks
|
||||
*/
|
||||
|
||||
// sandboxed-module somehow registers every fake module it creates in this
|
||||
// module's children array, which uses quite a big amount of memory. We'll take
|
||||
// a copy of the module children array and restore it after each test so that
|
||||
// the garbage collector has a chance to reclaim the fake modules.
|
||||
let initialModuleChildren
|
||||
|
||||
exports.mochaHooks = {
|
||||
beforeAll() {
|
||||
// Record initial module children
|
||||
initialModuleChildren = module.children.slice()
|
||||
},
|
||||
|
||||
beforeEach() {
|
||||
// Install logger stub
|
||||
this.logger = globalStubs.logger
|
||||
},
|
||||
|
||||
afterEach() {
|
||||
// Delete leaking sandboxed modules
|
||||
module.children = initialModuleChildren.slice()
|
||||
|
||||
// Reset global stubs
|
||||
globalStubsSandbox.reset()
|
||||
|
||||
// Restore other stubs
|
||||
sinon.restore()
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
424
services/web/test/unit/src/Analytics/AnalyticsManagerTests.js
Normal file
424
services/web/test/unit/src/Analytics/AnalyticsManagerTests.js
Normal 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)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
114
services/web/test/unit/src/Authentication/SessionManagerTests.js
Normal file
114
services/web/test/unit/src/Authentication/SessionManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
230
services/web/test/unit/src/Chat/ChatApiHandlerTests.js
Normal file
230
services/web/test/unit/src/Chat/ChatApiHandlerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
127
services/web/test/unit/src/Chat/ChatControllerTests.js
Normal file
127
services/web/test/unit/src/Chat/ChatControllerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal file
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
366
services/web/test/unit/src/Compile/ClsiCookieManagerTests.js
Normal file
366
services/web/test/unit/src/Compile/ClsiCookieManagerTests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
236
services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js
Normal file
236
services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1072
services/web/test/unit/src/Compile/ClsiManagerTests.js
Normal file
1072
services/web/test/unit/src/Compile/ClsiManagerTests.js
Normal file
File diff suppressed because it is too large
Load Diff
204
services/web/test/unit/src/Compile/ClsiStateManagerTests.js
Normal file
204
services/web/test/unit/src/Compile/ClsiStateManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
929
services/web/test/unit/src/Compile/CompileControllerTests.js
Normal file
929
services/web/test/unit/src/Compile/CompileControllerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
431
services/web/test/unit/src/Compile/CompileManagerTests.js
Normal file
431
services/web/test/unit/src/Compile/CompileManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
129
services/web/test/unit/src/Contact/ContactControllerTests.mjs
Normal file
129
services/web/test/unit/src/Contact/ContactControllerTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
104
services/web/test/unit/src/Contact/ContactManagerTests.js
Normal file
104
services/web/test/unit/src/Contact/ContactManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
181
services/web/test/unit/src/Cooldown/CooldownManagerTests.js
Normal file
181
services/web/test/unit/src/Cooldown/CooldownManagerTests.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
134
services/web/test/unit/src/Cooldown/CooldownMiddlewareTests.mjs
Normal file
134
services/web/test/unit/src/Cooldown/CooldownMiddlewareTests.mjs
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
631
services/web/test/unit/src/Docstore/DocstoreManagerTests.js
Normal file
631
services/web/test/unit/src/Docstore/DocstoreManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
208
services/web/test/unit/src/Documents/DocumentControllerTests.mjs
Normal file
208
services/web/test/unit/src/Documents/DocumentControllerTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
162
services/web/test/unit/src/Documents/DocumentHelperTests.js
Normal file
162
services/web/test/unit/src/Documents/DocumentHelperTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1089
services/web/test/unit/src/Editor/EditorControllerTests.js
Normal file
1089
services/web/test/unit/src/Editor/EditorControllerTests.js
Normal file
File diff suppressed because it is too large
Load Diff
561
services/web/test/unit/src/Editor/EditorHttpControllerTests.js
Normal file
561
services/web/test/unit/src/Editor/EditorHttpControllerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
823
services/web/test/unit/src/Email/EmailBuilderTests.js
Normal file
823
services/web/test/unit/src/Email/EmailBuilderTests.js
Normal 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(/&/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(/&/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(/&/, '&')
|
||||
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(/&/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
122
services/web/test/unit/src/Email/EmailHandlerTests.js
Normal file
122
services/web/test/unit/src/Email/EmailHandlerTests.js
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
35
services/web/test/unit/src/Email/EmailMessageHelperTests.js
Normal file
35
services/web/test/unit/src/Email/EmailMessageHelperTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
131
services/web/test/unit/src/Email/EmailSenderTests.js
Normal file
131
services/web/test/unit/src/Email/EmailSenderTests.js
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
76
services/web/test/unit/src/Email/SpamSafeTests.js
Normal file
76
services/web/test/unit/src/Email/SpamSafeTests.js
Normal 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(
|
||||
'加美汝VX:hihi661,金沙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')
|
||||
})
|
||||
})
|
||||
377
services/web/test/unit/src/Errors/HttpErrorHandlerTests.js
Normal file
377
services/web/test/unit/src/Errors/HttpErrorHandlerTests.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
196
services/web/test/unit/src/Exports/ExportsControllerTests.mjs
Normal file
196
services/web/test/unit/src/Exports/ExportsControllerTests.mjs
Normal 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()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
698
services/web/test/unit/src/Exports/ExportsHandlerTests.mjs
Normal file
698
services/web/test/unit/src/Exports/ExportsHandlerTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
722
services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
Normal file
722
services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
28
services/web/test/unit/src/HelperFiles/DiffHelperTests.js
Normal file
28
services/web/test/unit/src/HelperFiles/DiffHelperTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
56
services/web/test/unit/src/HelperFiles/EmailHelperTests.js
Normal file
56
services/web/test/unit/src/HelperFiles/EmailHelperTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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(`"`)
|
||||
})
|
||||
|
||||
it('should escape &', function () {
|
||||
expect(SafeHTMLSubstitution.render('&')).to.equal(`&`)
|
||||
})
|
||||
|
||||
it('should escape <', function () {
|
||||
expect(SafeHTMLSubstitution.render('<')).to.equal(`<`)
|
||||
})
|
||||
|
||||
it('should escape >', function () {
|
||||
expect(SafeHTMLSubstitution.render('>')).to.equal(`>`)
|
||||
})
|
||||
|
||||
it('should escape html', function () {
|
||||
expect(SafeHTMLSubstitution.render('<b>bad</b>')).to.equal(
|
||||
'<b>bad</b>'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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><i>inner</i></b>')
|
||||
})
|
||||
|
||||
it('should escape text in front of a component', function () {
|
||||
expect(
|
||||
SafeHTMLSubstitution.render('<i>PRE</i><0>inner</0>', [{ name: 'b' }])
|
||||
).to.equal('<i>PRE</i><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><i>POST</i>')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
44
services/web/test/unit/src/HelperFiles/UrlHelperTests.js
Normal file
44
services/web/test/unit/src/HelperFiles/UrlHelperTests.js
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
265
services/web/test/unit/src/History/HistoryControllerTests.js
Normal file
265
services/web/test/unit/src/History/HistoryControllerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
303
services/web/test/unit/src/History/HistoryManagerTests.js
Normal file
303
services/web/test/unit/src/History/HistoryManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
869
services/web/test/unit/src/History/RestoreManagerTests.js
Normal file
869
services/web/test/unit/src/History/RestoreManagerTests.js
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
423
services/web/test/unit/src/Institutions/InstitutionsAPITests.js
Normal file
423
services/web/test/unit/src/Institutions/InstitutionsAPITests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
163
services/web/test/unit/src/Metadata/MetaControllerTests.mjs
Normal file
163
services/web/test/unit/src/Metadata/MetaControllerTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
218
services/web/test/unit/src/Metadata/MetaHandlerTests.mjs
Normal file
218
services/web/test/unit/src/Metadata/MetaHandlerTests.mjs
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
214
services/web/test/unit/src/Newsletter/NewsletterManagerTests.js
Normal file
214
services/web/test/unit/src/Newsletter/NewsletterManagerTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
18
services/web/test/unit/src/Project/IterablePathTests.js
Normal file
18
services/web/test/unit/src/Project/IterablePathTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1471
services/web/test/unit/src/Project/ProjectControllerTests.js
Normal file
1471
services/web/test/unit/src/Project/ProjectControllerTests.js
Normal file
File diff suppressed because it is too large
Load Diff
848
services/web/test/unit/src/Project/ProjectDeleterTests.js
Normal file
848
services/web/test/unit/src/Project/ProjectDeleterTests.js
Normal 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',
|
||||
}
|
||||
}
|
||||
648
services/web/test/unit/src/Project/ProjectDetailsHandlerTests.js
Normal file
648
services/web/test/unit/src/Project/ProjectDetailsHandlerTests.js
Normal 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' } }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
448
services/web/test/unit/src/Project/ProjectDuplicatorTests.js
Normal file
448
services/web/test/unit/src/Project/ProjectDuplicatorTests.js
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
436
services/web/test/unit/src/Project/ProjectEditorHandlerTests.js
Normal file
436
services/web/test/unit/src/Project/ProjectEditorHandlerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
475
services/web/test/unit/src/Project/ProjectEntityHandlerTests.js
Normal file
475
services/web/test/unit/src/Project/ProjectEntityHandlerTests.js
Normal 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
@@ -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
453
services/web/test/unit/src/Project/ProjectGetterTests.js
Normal file
453
services/web/test/unit/src/Project/ProjectGetterTests.js
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
287
services/web/test/unit/src/Project/ProjectHelperTests.js
Normal file
287
services/web/test/unit/src/Project/ProjectHelperTests.js
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
163
services/web/test/unit/src/Project/ProjectHistoryHandlerTests.js
Normal file
163
services/web/test/unit/src/Project/ProjectHistoryHandlerTests.js
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
600
services/web/test/unit/src/Project/ProjectLocatorTests.js
Normal file
600
services/web/test/unit/src/Project/ProjectLocatorTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
233
services/web/test/unit/src/Project/ProjectOptionsHandlerTests.js
Normal file
233
services/web/test/unit/src/Project/ProjectOptionsHandlerTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
754
services/web/test/unit/src/Project/ProjectRootDocManagerTests.js
Normal file
754
services/web/test/unit/src/Project/ProjectRootDocManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
102
services/web/test/unit/src/Project/ProjectUpdateHandlerTests.js
Normal file
102
services/web/test/unit/src/Project/ProjectUpdateHandlerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user