ProjectControllerTests.js 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720
  1. const SandboxedModule = require('sandboxed-module')
  2. const path = require('path')
  3. const sinon = require('sinon')
  4. const { expect } = require('chai')
  5. const { ObjectId } = require('mongodb')
  6. const Errors = require('../../../../app/src/Features/Errors/Errors')
  7. const MODULE_PATH = path.join(
  8. __dirname,
  9. '../../../../app/src/Features/Project/ProjectController'
  10. )
  11. describe('ProjectController', function () {
  12. beforeEach(function () {
  13. this.project_id = ObjectId('abcdefabcdefabcdefabcdef')
  14. this.user = {
  15. _id: ObjectId('123456123456123456123456'),
  16. email: 'test@overleaf.com',
  17. first_name: 'bjkdsjfk',
  18. features: {},
  19. emails: [{ email: 'test@overleaf.com' }],
  20. }
  21. this.settings = {
  22. apis: {
  23. chat: {
  24. url: 'chat.com',
  25. },
  26. },
  27. siteUrl: 'https://overleaf.com',
  28. algolia: {},
  29. }
  30. this.brandVariationDetails = {
  31. id: '12',
  32. active: true,
  33. brand_name: 'The journal',
  34. home_url: 'http://www.thejournal.com/',
  35. publish_menu_link_html: 'Submit your paper to the <em>The Journal</em>',
  36. }
  37. this.token = 'some-token'
  38. this.ProjectDeleter = {
  39. deleteProject: sinon.stub().callsArg(2),
  40. restoreProject: sinon.stub().callsArg(1),
  41. findArchivedProjects: sinon.stub(),
  42. }
  43. this.ProjectDuplicator = {
  44. duplicate: sinon.stub().callsArgWith(3, null, { _id: this.project_id }),
  45. }
  46. this.ProjectCreationHandler = {
  47. createExampleProject: sinon
  48. .stub()
  49. .callsArgWith(2, null, { _id: this.project_id }),
  50. createBasicProject: sinon
  51. .stub()
  52. .callsArgWith(2, null, { _id: this.project_id }),
  53. }
  54. this.SubscriptionLocator = { getUsersSubscription: sinon.stub() }
  55. this.LimitationsManager = {
  56. hasPaidSubscription: sinon.stub(),
  57. userIsMemberOfGroupSubscription: sinon
  58. .stub()
  59. .callsArgWith(1, null, false),
  60. }
  61. this.TagsHandler = { getAllTags: sinon.stub() }
  62. this.NotificationsHandler = { getUserNotifications: sinon.stub() }
  63. this.UserModel = { findById: sinon.stub(), updateOne: sinon.stub() }
  64. this.AuthorizationManager = {
  65. getPrivilegeLevelForProject: sinon.stub(),
  66. isRestrictedUser: sinon.stub().returns(false),
  67. }
  68. this.EditorController = { renameProject: sinon.stub() }
  69. this.InactiveProjectManager = { reactivateProjectIfRequired: sinon.stub() }
  70. this.ProjectUpdateHandler = { markAsOpened: sinon.stub() }
  71. this.ProjectGetter = {
  72. findAllUsersProjects: sinon.stub(),
  73. getProject: sinon.stub(),
  74. }
  75. this.ProjectHelper = {
  76. isArchived: sinon.stub(),
  77. isTrashed: sinon.stub(),
  78. isArchivedOrTrashed: sinon.stub(),
  79. getAllowedImagesForUser: sinon.stub().returns([]),
  80. }
  81. this.SessionManager = {
  82. getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user),
  83. getLoggedInUserId: sinon.stub().returns(this.user._id),
  84. getSessionUser: sinon.stub().returns(this.user),
  85. isUserLoggedIn: sinon.stub().returns(true),
  86. }
  87. this.UserController = {
  88. logout: sinon.stub(),
  89. }
  90. this.TokenAccessHandler = {
  91. getRequestToken: sinon.stub().returns(this.token),
  92. protectTokens: sinon.stub(),
  93. }
  94. this.CollaboratorsGetter = {
  95. userIsTokenMember: sinon.stub().callsArgWith(2, null, false),
  96. }
  97. this.ProjectEntityHandler = {}
  98. this.NotificationBuilder = {
  99. ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
  100. }
  101. this.UserGetter = {
  102. getUserFullEmails: sinon.stub().yields(null, []),
  103. getUser: sinon
  104. .stub()
  105. .callsArgWith(2, null, { lastLoginIp: '192.170.18.2' }),
  106. }
  107. this.Features = {
  108. hasFeature: sinon.stub(),
  109. }
  110. this.FeaturesUpdater = {
  111. featuresEpochIsCurrent: sinon.stub().returns(true),
  112. refreshFeatures: sinon.stub().yields(null, this.user),
  113. }
  114. this.BrandVariationsHandler = {
  115. getBrandVariationById: sinon
  116. .stub()
  117. .callsArgWith(1, null, this.brandVariationDetails),
  118. }
  119. this.TpdsProjectFlusher = {
  120. flushProjectToTpdsIfNeeded: sinon.stub().yields(),
  121. }
  122. this.Metrics = {
  123. Timer: class {
  124. done() {}
  125. },
  126. inc: sinon.stub(),
  127. }
  128. this.SplitTestHandler = {
  129. promises: {
  130. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  131. },
  132. getAssignment: sinon.stub().yields(null, { variant: 'default' }),
  133. }
  134. this.InstitutionsFeatures = {
  135. hasLicence: sinon.stub().callsArgWith(1, null, false),
  136. }
  137. this.SubscriptionViewModelBuilder = {
  138. getBestSubscription: sinon.stub().yields(null, { type: 'free' }),
  139. }
  140. this.SurveyHandler = {
  141. getSurvey: sinon.stub().yields(null, {}),
  142. }
  143. this.ProjectController = SandboxedModule.require(MODULE_PATH, {
  144. requires: {
  145. mongodb: { ObjectId },
  146. '@overleaf/settings': this.settings,
  147. '@overleaf/metrics': this.Metrics,
  148. '../SplitTests/SplitTestHandler': this.SplitTestHandler,
  149. './ProjectDeleter': this.ProjectDeleter,
  150. './ProjectDuplicator': this.ProjectDuplicator,
  151. './ProjectCreationHandler': this.ProjectCreationHandler,
  152. '../Editor/EditorController': this.EditorController,
  153. '../User/UserController': this.UserController,
  154. './ProjectHelper': this.ProjectHelper,
  155. '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
  156. '../Subscription/LimitationsManager': this.LimitationsManager,
  157. '../Tags/TagsHandler': this.TagsHandler,
  158. '../Notifications/NotificationsHandler': this.NotificationsHandler,
  159. '../../models/User': { User: this.UserModel },
  160. '../Authorization/AuthorizationManager': this.AuthorizationManager,
  161. '../InactiveData/InactiveProjectManager': this.InactiveProjectManager,
  162. './ProjectUpdateHandler': this.ProjectUpdateHandler,
  163. './ProjectGetter': this.ProjectGetter,
  164. './ProjectDetailsHandler': this.ProjectDetailsHandler,
  165. '../Authentication/SessionManager': this.SessionManager,
  166. '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
  167. '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
  168. './ProjectEntityHandler': this.ProjectEntityHandler,
  169. '../../infrastructure/Features': this.Features,
  170. '../Subscription/FeaturesUpdater': this.FeaturesUpdater,
  171. '../Notifications/NotificationsBuilder': this.NotificationBuilder,
  172. '../User/UserGetter': this.UserGetter,
  173. '../BrandVariations/BrandVariationsHandler':
  174. this.BrandVariationsHandler,
  175. '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
  176. '../../models/Project': {},
  177. '../Analytics/AnalyticsManager': { recordEventForUser: () => {} },
  178. '../Subscription/SubscriptionViewModelBuilder':
  179. this.SubscriptionViewModelBuilder,
  180. '../../infrastructure/Modules': {
  181. hooks: { fire: sinon.stub().yields(null, []) },
  182. },
  183. '../Spelling/SpellingHandler': {
  184. getUserDictionary: sinon.stub().yields(null, []),
  185. },
  186. '../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
  187. '../Survey/SurveyHandler': this.SurveyHandler,
  188. },
  189. })
  190. this.projectName = '£12321jkj9ujkljds'
  191. this.req = {
  192. query: {},
  193. params: {
  194. Project_id: this.project_id,
  195. },
  196. headers: {},
  197. connection: {
  198. remoteAddress: '192.170.18.1',
  199. },
  200. session: {
  201. user: this.user,
  202. },
  203. body: {
  204. projectName: this.projectName,
  205. },
  206. i18n: {
  207. translate() {},
  208. },
  209. ip: '192.170.18.1',
  210. }
  211. this.res = {
  212. locals: {
  213. jsPath: 'js path here',
  214. },
  215. setTimeout: sinon.stub(),
  216. }
  217. })
  218. describe('updateProjectSettings', function () {
  219. it('should update the name', function (done) {
  220. this.EditorController.renameProject = sinon.stub().callsArg(2)
  221. this.req.body = { name: (this.name = 'New name') }
  222. this.res.sendStatus = code => {
  223. this.EditorController.renameProject
  224. .calledWith(this.project_id, this.name)
  225. .should.equal(true)
  226. code.should.equal(204)
  227. done()
  228. }
  229. this.ProjectController.updateProjectSettings(this.req, this.res)
  230. })
  231. it('should update the compiler', function (done) {
  232. this.EditorController.setCompiler = sinon.stub().callsArg(2)
  233. this.req.body = { compiler: (this.compiler = 'pdflatex') }
  234. this.res.sendStatus = code => {
  235. this.EditorController.setCompiler
  236. .calledWith(this.project_id, this.compiler)
  237. .should.equal(true)
  238. code.should.equal(204)
  239. done()
  240. }
  241. this.ProjectController.updateProjectSettings(this.req, this.res)
  242. })
  243. it('should update the imageName', function (done) {
  244. this.EditorController.setImageName = sinon.stub().callsArg(2)
  245. this.req.body = { imageName: (this.imageName = 'texlive-1234.5') }
  246. this.res.sendStatus = code => {
  247. this.EditorController.setImageName
  248. .calledWith(this.project_id, this.imageName)
  249. .should.equal(true)
  250. code.should.equal(204)
  251. done()
  252. }
  253. this.ProjectController.updateProjectSettings(this.req, this.res)
  254. })
  255. it('should update the spell check language', function (done) {
  256. this.EditorController.setSpellCheckLanguage = sinon.stub().callsArg(2)
  257. this.req.body = { spellCheckLanguage: (this.languageCode = 'fr') }
  258. this.res.sendStatus = code => {
  259. this.EditorController.setSpellCheckLanguage
  260. .calledWith(this.project_id, this.languageCode)
  261. .should.equal(true)
  262. code.should.equal(204)
  263. done()
  264. }
  265. this.ProjectController.updateProjectSettings(this.req, this.res)
  266. })
  267. it('should update the root doc', function (done) {
  268. this.EditorController.setRootDoc = sinon.stub().callsArg(2)
  269. this.req.body = { rootDocId: (this.rootDocId = 'root-doc-id') }
  270. this.res.sendStatus = code => {
  271. this.EditorController.setRootDoc
  272. .calledWith(this.project_id, this.rootDocId)
  273. .should.equal(true)
  274. code.should.equal(204)
  275. done()
  276. }
  277. this.ProjectController.updateProjectSettings(this.req, this.res)
  278. })
  279. })
  280. describe('updateProjectAdminSettings', function () {
  281. it('should update the public access level', function (done) {
  282. this.EditorController.setPublicAccessLevel = sinon.stub().callsArg(2)
  283. this.req.body = {
  284. publicAccessLevel: (this.publicAccessLevel = 'readonly'),
  285. }
  286. this.res.sendStatus = code => {
  287. this.EditorController.setPublicAccessLevel
  288. .calledWith(this.project_id, this.publicAccessLevel)
  289. .should.equal(true)
  290. code.should.equal(204)
  291. done()
  292. }
  293. this.ProjectController.updateProjectAdminSettings(this.req, this.res)
  294. })
  295. })
  296. describe('deleteProject', function () {
  297. it('should call the project deleter', function (done) {
  298. this.res.sendStatus = code => {
  299. this.ProjectDeleter.deleteProject
  300. .calledWith(this.project_id, {
  301. deleterUser: this.user,
  302. ipAddress: this.req.ip,
  303. })
  304. .should.equal(true)
  305. code.should.equal(200)
  306. done()
  307. }
  308. this.ProjectController.deleteProject(this.req, this.res)
  309. })
  310. })
  311. describe('restoreProject', function () {
  312. it('should tell the project deleter', function (done) {
  313. this.res.sendStatus = code => {
  314. this.ProjectDeleter.restoreProject
  315. .calledWith(this.project_id)
  316. .should.equal(true)
  317. code.should.equal(200)
  318. done()
  319. }
  320. this.ProjectController.restoreProject(this.req, this.res)
  321. })
  322. })
  323. describe('cloneProject', function () {
  324. it('should call the project duplicator', function (done) {
  325. this.res.json = json => {
  326. this.ProjectDuplicator.duplicate
  327. .calledWith(this.user, this.project_id, this.projectName)
  328. .should.equal(true)
  329. json.project_id.should.equal(this.project_id)
  330. done()
  331. }
  332. this.ProjectController.cloneProject(this.req, this.res)
  333. })
  334. })
  335. describe('newProject', function () {
  336. it('should call the projectCreationHandler with createExampleProject', function (done) {
  337. this.req.body.template = 'example'
  338. this.res.json = json => {
  339. this.ProjectCreationHandler.createExampleProject
  340. .calledWith(this.user._id, this.projectName)
  341. .should.equal(true)
  342. this.ProjectCreationHandler.createBasicProject.called.should.equal(
  343. false
  344. )
  345. done()
  346. }
  347. this.ProjectController.newProject(this.req, this.res)
  348. })
  349. it('should call the projectCreationHandler with createBasicProject', function (done) {
  350. this.req.body.template = 'basic'
  351. this.res.json = json => {
  352. this.ProjectCreationHandler.createExampleProject.called.should.equal(
  353. false
  354. )
  355. this.ProjectCreationHandler.createBasicProject
  356. .calledWith(this.user._id, this.projectName)
  357. .should.equal(true)
  358. done()
  359. }
  360. this.ProjectController.newProject(this.req, this.res)
  361. })
  362. })
  363. describe('projectListPage', function () {
  364. beforeEach(function () {
  365. this.tags = [
  366. { name: 1, project_ids: ['1', '2', '3'] },
  367. { name: 2, project_ids: ['a', '1'] },
  368. { name: 3, project_ids: ['a', 'b', 'c', 'd'] },
  369. ]
  370. this.notifications = [
  371. {
  372. _id: '1',
  373. user_id: '2',
  374. templateKey: '3',
  375. messageOpts: '4',
  376. key: '5',
  377. },
  378. ]
  379. this.projects = [
  380. { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
  381. {
  382. _id: 2,
  383. lastUpdated: 2,
  384. owner_ref: 'user-2',
  385. lastUpdatedBy: 'user-1',
  386. },
  387. ]
  388. this.collabertions = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
  389. this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
  390. this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
  391. this.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }]
  392. this.allProjects = {
  393. owned: this.projects,
  394. readAndWrite: this.collabertions,
  395. readOnly: this.readOnly,
  396. tokenReadAndWrite: this.tokenReadAndWrite,
  397. tokenReadOnly: this.tokenReadOnly,
  398. }
  399. this.users = {
  400. 'user-1': {
  401. first_name: 'James',
  402. },
  403. 'user-2': {
  404. first_name: 'Henry',
  405. },
  406. }
  407. this.users[this.user._id] = this.user // Owner
  408. this.UserModel.findById = (id, fields, callback) => {
  409. callback(null, this.users[id])
  410. }
  411. this.UserGetter.getUser = (id, fields, callback) => {
  412. callback(null, this.users[id])
  413. }
  414. this.LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
  415. this.TagsHandler.getAllTags.callsArgWith(1, null, this.tags)
  416. this.NotificationsHandler.getUserNotifications = sinon
  417. .stub()
  418. .callsArgWith(1, null, this.notifications, {})
  419. this.ProjectGetter.findAllUsersProjects.callsArgWith(
  420. 2,
  421. null,
  422. this.allProjects
  423. )
  424. })
  425. it('should render the project/list page', function (done) {
  426. this.res.render = (pageName, opts) => {
  427. pageName.should.equal('project/list')
  428. done()
  429. }
  430. this.ProjectController.projectListPage(this.req, this.res)
  431. })
  432. it('should send the tags', function (done) {
  433. this.res.render = (pageName, opts) => {
  434. opts.tags.length.should.equal(this.tags.length)
  435. done()
  436. }
  437. this.ProjectController.projectListPage(this.req, this.res)
  438. })
  439. it('should create trigger ip matcher notifications', function (done) {
  440. this.settings.overleaf = true
  441. this.res.render = (pageName, opts) => {
  442. this.NotificationBuilder.ipMatcherAffiliation.called.should.equal(true)
  443. done()
  444. }
  445. this.ProjectController.projectListPage(this.req, this.res)
  446. })
  447. it('should send the projects', function (done) {
  448. this.res.render = (pageName, opts) => {
  449. opts.projects.length.should.equal(
  450. this.projects.length +
  451. this.collabertions.length +
  452. this.readOnly.length +
  453. this.tokenReadAndWrite.length +
  454. this.tokenReadOnly.length
  455. )
  456. done()
  457. }
  458. this.ProjectController.projectListPage(this.req, this.res)
  459. })
  460. it('should send the user', function (done) {
  461. this.res.render = (pageName, opts) => {
  462. opts.user.should.deep.equal(this.user)
  463. done()
  464. }
  465. this.ProjectController.projectListPage(this.req, this.res)
  466. })
  467. it('should inject the users', function (done) {
  468. this.res.render = (pageName, opts) => {
  469. opts.projects[0].owner.should.equal(
  470. this.users[this.projects[0].owner_ref]
  471. )
  472. opts.projects[1].owner.should.equal(
  473. this.users[this.projects[1].owner_ref]
  474. )
  475. opts.projects[1].lastUpdatedBy.should.equal(
  476. this.users[this.projects[1].lastUpdatedBy]
  477. )
  478. done()
  479. }
  480. this.ProjectController.projectListPage(this.req, this.res)
  481. })
  482. it('should send hasSubscription == false when no subscription', function (done) {
  483. this.res.render = (pageName, opts) => {
  484. opts.hasSubscription.should.equal(false)
  485. done()
  486. }
  487. this.ProjectController.projectListPage(this.req, this.res)
  488. })
  489. it('should send hasSubscription == true when there is a subscription', function (done) {
  490. this.LimitationsManager.hasPaidSubscription = sinon
  491. .stub()
  492. .callsArgWith(1, null, true)
  493. this.res.render = (pageName, opts) => {
  494. opts.hasSubscription.should.equal(true)
  495. done()
  496. }
  497. this.ProjectController.projectListPage(this.req, this.res)
  498. })
  499. it("should send the user's best subscription when saas feature present", function (done) {
  500. this.Features.hasFeature.withArgs('saas').returns(true)
  501. this.res.render = (pageName, opts) => {
  502. expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
  503. done()
  504. }
  505. this.ProjectController.projectListPage(this.req, this.res)
  506. })
  507. it('should not return a best subscription without saas feature', function (done) {
  508. this.Features.hasFeature.withArgs('saas').returns(false)
  509. this.res.render = (pageName, opts) => {
  510. expect(opts.usersBestSubscription).to.be.undefined
  511. done()
  512. }
  513. this.ProjectController.projectListPage(this.req, this.res)
  514. })
  515. describe('front widget', function (done) {
  516. beforeEach(function () {
  517. this.settings.overleaf = {
  518. front_chat_widget_room_id: 'chat-room-id',
  519. }
  520. })
  521. it('should show for paid users', function (done) {
  522. this.user.features.github = true
  523. this.user.features.dropbox = true
  524. this.res.render = (pageName, opts) => {
  525. opts.frontChatWidgetRoomId.should.equal(
  526. this.settings.overleaf.front_chat_widget_room_id
  527. )
  528. done()
  529. }
  530. this.ProjectController.projectListPage(this.req, this.res)
  531. })
  532. it('should show for sample users', function (done) {
  533. this.user._id = ObjectId('588f3ddae8ebc1bac07c9f00') // last two digits
  534. this.res.render = (pageName, opts) => {
  535. opts.frontChatWidgetRoomId.should.equal(
  536. this.settings.overleaf.front_chat_widget_room_id
  537. )
  538. done()
  539. }
  540. this.ProjectController.projectListPage(this.req, this.res)
  541. })
  542. it('should not show for non sample users', function (done) {
  543. this.user._id = ObjectId('588f3ddae8ebc1bac07c9fff') // last two digits
  544. this.res.render = (pageName, opts) => {
  545. expect(opts.frontChatWidgetRoomId).to.equal(undefined)
  546. done()
  547. }
  548. this.ProjectController.projectListPage(this.req, this.res)
  549. })
  550. })
  551. describe('With Institution SSO feature', function () {
  552. beforeEach(function (done) {
  553. this.institutionEmail = 'test@overleaf.com'
  554. this.institutionName = 'Overleaf'
  555. this.Features.hasFeature.withArgs('saml').returns(true)
  556. this.Features.hasFeature.withArgs('affiliations').returns(true)
  557. this.Features.hasFeature.withArgs('overleaf-integration').returns(true)
  558. done()
  559. })
  560. it('should show institution SSO available notification for confirmed domains', function () {
  561. this.UserGetter.getUserFullEmails.yields(null, [
  562. {
  563. email: 'test@overleaf.com',
  564. affiliation: {
  565. institution: {
  566. id: 1,
  567. confirmed: true,
  568. name: 'Overleaf',
  569. ssoBeta: false,
  570. ssoEnabled: true,
  571. },
  572. },
  573. },
  574. ])
  575. this.res.render = (pageName, opts) => {
  576. expect(opts.notificationsInstitution).to.deep.include({
  577. email: this.institutionEmail,
  578. institutionId: 1,
  579. institutionName: this.institutionName,
  580. templateKey: 'notification_institution_sso_available',
  581. })
  582. }
  583. this.ProjectController.projectListPage(this.req, this.res)
  584. })
  585. it('should show a linked notification', function () {
  586. this.req.session.saml = {
  587. institutionEmail: this.institutionEmail,
  588. linked: {
  589. hasEntitlement: false,
  590. universityName: this.institutionName,
  591. },
  592. }
  593. this.res.render = (pageName, opts) => {
  594. expect(opts.notificationsInstitution).to.deep.include({
  595. email: this.institutionEmail,
  596. institutionName: this.institutionName,
  597. templateKey: 'notification_institution_sso_linked',
  598. })
  599. }
  600. this.ProjectController.projectListPage(this.req, this.res)
  601. })
  602. it('should show a linked another email notification', function () {
  603. // when they request to link an email but the institution returns
  604. // a different email
  605. this.res.render = (pageName, opts) => {
  606. expect(opts.notificationsInstitution).to.deep.include({
  607. institutionEmail: this.institutionEmail,
  608. requestedEmail: 'requested@overleaf.com',
  609. templateKey: 'notification_institution_sso_non_canonical',
  610. })
  611. }
  612. this.req.session.saml = {
  613. emailNonCanonical: this.institutionEmail,
  614. institutionEmail: this.institutionEmail,
  615. requestedEmail: 'requested@overleaf.com',
  616. linked: {
  617. hasEntitlement: false,
  618. universityName: this.institutionName,
  619. },
  620. }
  621. this.ProjectController.projectListPage(this.req, this.res)
  622. })
  623. it('should show a notification when intent was to register via SSO but account existed', function () {
  624. this.res.render = (pageName, opts) => {
  625. expect(opts.notificationsInstitution).to.deep.include({
  626. email: this.institutionEmail,
  627. templateKey: 'notification_institution_sso_already_registered',
  628. })
  629. }
  630. this.req.session.saml = {
  631. institutionEmail: this.institutionEmail,
  632. linked: {
  633. hasEntitlement: false,
  634. universityName: 'Overleaf',
  635. },
  636. registerIntercept: {
  637. id: 1,
  638. name: 'Example University',
  639. },
  640. }
  641. this.ProjectController.projectListPage(this.req, this.res)
  642. })
  643. it('should not show a register notification if the flow was abandoned', function () {
  644. // could initially start to register with an SSO email and then
  645. // abandon flow and login with an existing non-institution SSO email
  646. this.res.render = (pageName, opts) => {
  647. expect(opts.notificationsInstitution).to.deep.not.include({
  648. email: 'test@overleaf.com',
  649. templateKey: 'notification_institution_sso_already_registered',
  650. })
  651. }
  652. this.req.session.saml = {
  653. registerIntercept: {
  654. id: 1,
  655. name: 'Example University',
  656. },
  657. }
  658. this.ProjectController.projectListPage(this.req, this.res)
  659. })
  660. it('should show error notification', function () {
  661. this.res.render = (pageName, opts) => {
  662. expect(opts.notificationsInstitution.length).to.equal(1)
  663. expect(opts.notificationsInstitution[0].templateKey).to.equal(
  664. 'notification_institution_sso_error'
  665. )
  666. expect(opts.notificationsInstitution[0].error).to.be.instanceof(
  667. Errors.SAMLAlreadyLinkedError
  668. )
  669. }
  670. this.req.session.saml = {
  671. institutionEmail: this.institutionEmail,
  672. error: new Errors.SAMLAlreadyLinkedError(),
  673. }
  674. this.ProjectController.projectListPage(this.req, this.res)
  675. })
  676. describe('for an unconfirmed domain for an SSO institution', function () {
  677. beforeEach(function (done) {
  678. this.UserGetter.getUserFullEmails.yields(null, [
  679. {
  680. email: 'test@overleaf-uncofirmed.com',
  681. affiliation: {
  682. institution: {
  683. id: 1,
  684. confirmed: false,
  685. name: 'Overleaf',
  686. ssoBeta: false,
  687. ssoEnabled: true,
  688. },
  689. },
  690. },
  691. ])
  692. done()
  693. })
  694. it('should not show institution SSO available notification', function () {
  695. this.res.render = (pageName, opts) => {
  696. expect(opts.notificationsInstitution.length).to.equal(0)
  697. }
  698. this.ProjectController.projectListPage(this.req, this.res)
  699. })
  700. })
  701. describe('when linking/logging in initiated on institution side', function () {
  702. it('should not show a linked another email notification', function () {
  703. // this is only used when initated on Overleaf,
  704. // because we keep track of the requested email they tried to link
  705. this.res.render = (pageName, opts) => {
  706. expect(opts.notificationsInstitution).to.not.deep.include({
  707. institutionEmail: this.institutionEmail,
  708. requestedEmail: undefined,
  709. templateKey: 'notification_institution_sso_non_canonical',
  710. })
  711. }
  712. this.req.session.saml = {
  713. emailNonCanonical: this.institutionEmail,
  714. institutionEmail: this.institutionEmail,
  715. linked: {
  716. hasEntitlement: false,
  717. universityName: this.institutionName,
  718. },
  719. }
  720. this.ProjectController.projectListPage(this.req, this.res)
  721. })
  722. })
  723. describe('Institution with SSO beta testable', function () {
  724. beforeEach(function (done) {
  725. this.UserGetter.getUserFullEmails.yields(null, [
  726. {
  727. email: 'beta@beta.com',
  728. affiliation: {
  729. institution: {
  730. id: 2,
  731. confirmed: true,
  732. name: 'Beta University',
  733. ssoBeta: true,
  734. ssoEnabled: false,
  735. },
  736. },
  737. },
  738. ])
  739. done()
  740. })
  741. it('should show institution SSO available notification when on a beta testing session', function () {
  742. this.req.session.samlBeta = true
  743. this.res.render = (pageName, opts) => {
  744. expect(opts.notificationsInstitution).to.deep.include({
  745. email: 'beta@beta.com',
  746. institutionId: 2,
  747. institutionName: 'Beta University',
  748. templateKey: 'notification_institution_sso_available',
  749. })
  750. }
  751. this.ProjectController.projectListPage(this.req, this.res)
  752. })
  753. it('should not show institution SSO available notification when not on a beta testing session', function () {
  754. this.req.session.samlBeta = false
  755. this.res.render = (pageName, opts) => {
  756. expect(opts.notificationsInstitution).to.deep.not.include({
  757. email: 'test@overleaf.com',
  758. institutionId: 1,
  759. institutionName: 'Overleaf',
  760. templateKey: 'notification_institution_sso_available',
  761. })
  762. }
  763. this.ProjectController.projectListPage(this.req, this.res)
  764. })
  765. })
  766. })
  767. describe('Without Institution SSO feature', function () {
  768. beforeEach(function (done) {
  769. this.Features.hasFeature.withArgs('saml').returns(false)
  770. done()
  771. })
  772. it('should not show institution sso available notification', function () {
  773. this.res.render = (pageName, opts) => {
  774. expect(opts.notificationsInstitution).to.deep.not.include({
  775. email: 'test@overleaf.com',
  776. institutionId: 1,
  777. institutionName: 'Overleaf',
  778. templateKey: 'notification_institution_sso_available',
  779. })
  780. }
  781. this.ProjectController.projectListPage(this.req, this.res)
  782. })
  783. })
  784. })
  785. describe('projectListPage with duplicate projects', function () {
  786. beforeEach(function () {
  787. this.tags = [
  788. { name: 1, project_ids: ['1', '2', '3'] },
  789. { name: 2, project_ids: ['a', '1'] },
  790. { name: 3, project_ids: ['a', 'b', 'c', 'd'] },
  791. ]
  792. this.notifications = [
  793. {
  794. _id: '1',
  795. user_id: '2',
  796. templateKey: '3',
  797. messageOpts: '4',
  798. key: '5',
  799. },
  800. ]
  801. this.projects = [
  802. { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
  803. { _id: 2, lastUpdated: 2, owner_ref: 'user-2' },
  804. ]
  805. this.collabertions = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
  806. this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
  807. this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
  808. this.tokenReadOnly = [
  809. { _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite
  810. { _id: 7, lastUpdated: 4, owner_ref: 'user-5' },
  811. ]
  812. this.allProjects = {
  813. owned: this.projects,
  814. readAndWrite: this.collabertions,
  815. readOnly: this.readOnly,
  816. tokenReadAndWrite: this.tokenReadAndWrite,
  817. tokenReadOnly: this.tokenReadOnly,
  818. }
  819. this.users = {
  820. 'user-1': {
  821. first_name: 'James',
  822. },
  823. 'user-2': {
  824. first_name: 'Henry',
  825. },
  826. }
  827. this.users[this.user._id] = this.user // Owner
  828. this.UserModel.findById = (id, fields, callback) => {
  829. callback(null, this.users[id])
  830. }
  831. this.LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
  832. this.TagsHandler.getAllTags.callsArgWith(1, null, this.tags)
  833. this.NotificationsHandler.getUserNotifications = sinon
  834. .stub()
  835. .callsArgWith(1, null, this.notifications, {})
  836. this.ProjectGetter.findAllUsersProjects.callsArgWith(
  837. 2,
  838. null,
  839. this.allProjects
  840. )
  841. })
  842. it('should render the project/list page', function (done) {
  843. this.res.render = (pageName, opts) => {
  844. pageName.should.equal('project/list')
  845. done()
  846. }
  847. this.ProjectController.projectListPage(this.req, this.res)
  848. })
  849. it('should omit one of the projects', function (done) {
  850. this.res.render = (pageName, opts) => {
  851. opts.projects.length.should.equal(
  852. this.projects.length +
  853. this.collabertions.length +
  854. this.readOnly.length +
  855. this.tokenReadAndWrite.length +
  856. this.tokenReadOnly.length -
  857. 1
  858. )
  859. done()
  860. }
  861. this.ProjectController.projectListPage(this.req, this.res)
  862. })
  863. })
  864. describe('renameProject', function () {
  865. beforeEach(function () {
  866. this.newProjectName = 'my supper great new project'
  867. this.req.body.newProjectName = this.newProjectName
  868. })
  869. it('should call the editor controller', function (done) {
  870. this.EditorController.renameProject.callsArgWith(2)
  871. this.res.sendStatus = code => {
  872. code.should.equal(200)
  873. this.EditorController.renameProject
  874. .calledWith(this.project_id, this.newProjectName)
  875. .should.equal(true)
  876. done()
  877. }
  878. this.ProjectController.renameProject(this.req, this.res)
  879. })
  880. it('should send an error to next() if there is a problem', function (done) {
  881. let error
  882. this.EditorController.renameProject.callsArgWith(
  883. 2,
  884. (error = new Error('problem'))
  885. )
  886. const next = e => {
  887. e.should.equal(error)
  888. done()
  889. }
  890. this.ProjectController.renameProject(this.req, this.res, next)
  891. })
  892. })
  893. describe('loadEditor', function () {
  894. beforeEach(function () {
  895. this.settings.editorIsOpen = true
  896. this.project = {
  897. name: 'my proj',
  898. _id: '213123kjlkj',
  899. owner_ref: '59fc84d5fbea77482d436e1b',
  900. }
  901. this.brandedProject = {
  902. name: 'my branded proj',
  903. _id: '3252332',
  904. owner_ref: '59fc84d5fbea77482d436e1b',
  905. brandVariationId: '12',
  906. }
  907. this.user = {
  908. _id: this.user._id,
  909. ace: {
  910. fontSize: 'massive',
  911. theme: 'sexy',
  912. },
  913. email: 'bob@bob.com',
  914. refProviders: {
  915. mendeley: { encrypted: 'aaaa' },
  916. zotero: { encrypted: 'bbbb' },
  917. },
  918. }
  919. this.ProjectGetter.getProject.callsArgWith(2, null, this.project)
  920. this.UserModel.findById.callsArgWith(2, null, this.user)
  921. this.SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, {})
  922. this.AuthorizationManager.getPrivilegeLevelForProject.callsArgWith(
  923. 3,
  924. null,
  925. 'owner'
  926. )
  927. this.ProjectDeleter.unmarkAsDeletedByExternalSource = sinon.stub()
  928. this.InactiveProjectManager.reactivateProjectIfRequired.callsArgWith(1)
  929. this.ProjectUpdateHandler.markAsOpened.callsArgWith(1)
  930. })
  931. it('should render the project/editor page', function (done) {
  932. this.res.render = (pageName, opts) => {
  933. pageName.should.equal('project/editor')
  934. done()
  935. }
  936. this.ProjectController.loadEditor(this.req, this.res)
  937. })
  938. it('should add user', function (done) {
  939. this.res.render = (pageName, opts) => {
  940. opts.user.email.should.equal(this.user.email)
  941. done()
  942. }
  943. this.ProjectController.loadEditor(this.req, this.res)
  944. })
  945. it('should sanitize refProviders', function (done) {
  946. this.res.render = (_pageName, opts) => {
  947. expect(opts.user.refProviders).to.deep.equal({
  948. mendeley: true,
  949. zotero: true,
  950. })
  951. done()
  952. }
  953. this.ProjectController.loadEditor(this.req, this.res)
  954. })
  955. it('should add on userSettings', function (done) {
  956. this.res.render = (pageName, opts) => {
  957. opts.userSettings.fontSize.should.equal(this.user.ace.fontSize)
  958. opts.userSettings.editorTheme.should.equal(this.user.ace.theme)
  959. done()
  960. }
  961. this.ProjectController.loadEditor(this.req, this.res)
  962. })
  963. it('should add isRestrictedTokenMember', function (done) {
  964. this.res.render = (pageName, opts) => {
  965. opts.isRestrictedTokenMember.should.exist
  966. opts.isRestrictedTokenMember.should.equal(false)
  967. return done()
  968. }
  969. return this.ProjectController.loadEditor(this.req, this.res)
  970. })
  971. it('should set isRestrictedTokenMember when appropriate', function (done) {
  972. this.AuthorizationManager.isRestrictedUser.returns(true)
  973. this.res.render = (pageName, opts) => {
  974. opts.isRestrictedTokenMember.should.exist
  975. opts.isRestrictedTokenMember.should.equal(true)
  976. return done()
  977. }
  978. return this.ProjectController.loadEditor(this.req, this.res)
  979. })
  980. it('should render the closed page if the editor is closed', function (done) {
  981. this.settings.editorIsOpen = false
  982. this.res.render = (pageName, opts) => {
  983. pageName.should.equal('general/closed')
  984. done()
  985. }
  986. this.ProjectController.loadEditor(this.req, this.res)
  987. })
  988. it('should not render the page if the project can not be accessed', function (done) {
  989. this.AuthorizationManager.getPrivilegeLevelForProject = sinon
  990. .stub()
  991. .callsArgWith(3, null, null)
  992. this.res.sendStatus = (resCode, opts) => {
  993. resCode.should.equal(401)
  994. done()
  995. }
  996. this.ProjectController.loadEditor(this.req, this.res)
  997. })
  998. it('should reactivateProjectIfRequired', function (done) {
  999. this.res.render = (pageName, opts) => {
  1000. this.InactiveProjectManager.reactivateProjectIfRequired
  1001. .calledWith(this.project_id)
  1002. .should.equal(true)
  1003. done()
  1004. }
  1005. this.ProjectController.loadEditor(this.req, this.res)
  1006. })
  1007. it('should mark user as active', function (done) {
  1008. this.res.render = (pageName, opts) => {
  1009. expect(this.UserModel.updateOne).to.have.been.calledOnce
  1010. expect(this.UserModel.updateOne.args[0][0]).to.deep.equal({
  1011. _id: ObjectId(this.user._id),
  1012. })
  1013. expect(this.UserModel.updateOne.args[0][1].$set.lastActive).to.exist
  1014. done()
  1015. }
  1016. this.ProjectController.loadEditor(this.req, this.res)
  1017. })
  1018. it('should mark project as opened', function (done) {
  1019. this.res.render = (pageName, opts) => {
  1020. this.ProjectUpdateHandler.markAsOpened
  1021. .calledWith(this.project_id)
  1022. .should.equal(true)
  1023. done()
  1024. }
  1025. this.ProjectController.loadEditor(this.req, this.res)
  1026. })
  1027. it('should call the brand variations handler for branded projects', function (done) {
  1028. this.ProjectGetter.getProject.callsArgWith(2, null, this.brandedProject)
  1029. this.res.render = (pageName, opts) => {
  1030. this.BrandVariationsHandler.getBrandVariationById
  1031. .calledWith()
  1032. .should.equal(true)
  1033. done()
  1034. }
  1035. this.ProjectController.loadEditor(this.req, this.res)
  1036. })
  1037. it('should not call the brand variations handler for unbranded projects', function (done) {
  1038. this.res.render = (pageName, opts) => {
  1039. this.BrandVariationsHandler.getBrandVariationById.called.should.equal(
  1040. false
  1041. )
  1042. done()
  1043. }
  1044. this.ProjectController.loadEditor(this.req, this.res)
  1045. })
  1046. it('should expose the brand variation details as locals for branded projects', function (done) {
  1047. this.ProjectGetter.getProject.callsArgWith(2, null, this.brandedProject)
  1048. this.res.render = (pageName, opts) => {
  1049. opts.brandVariation.should.deep.equal(this.brandVariationDetails)
  1050. done()
  1051. }
  1052. this.ProjectController.loadEditor(this.req, this.res)
  1053. })
  1054. it('flushes the project to TPDS if a flush is pending', function (done) {
  1055. this.res.render = () => {
  1056. this.TpdsProjectFlusher.flushProjectToTpdsIfNeeded.should.have.been.calledWith(
  1057. this.project_id
  1058. )
  1059. done()
  1060. }
  1061. this.ProjectController.loadEditor(this.req, this.res)
  1062. })
  1063. it('should refresh the user features if the epoch is outdated', function (done) {
  1064. this.FeaturesUpdater.featuresEpochIsCurrent = sinon.stub().returns(false)
  1065. this.res.render = () => {
  1066. this.FeaturesUpdater.refreshFeatures.should.have.been.calledWith(
  1067. this.user._id,
  1068. 'load-editor'
  1069. )
  1070. done()
  1071. }
  1072. this.ProjectController.loadEditor(this.req, this.res)
  1073. })
  1074. describe('wsUrl', function () {
  1075. function checkLoadEditorWsMetric(metric) {
  1076. it(`should inc metric ${metric}`, function (done) {
  1077. this.res.render = () => {
  1078. this.Metrics.inc.calledWith(metric).should.equal(true)
  1079. done()
  1080. }
  1081. this.ProjectController.loadEditor(this.req, this.res)
  1082. })
  1083. }
  1084. function checkWsFallback(isBeta, isV2) {
  1085. describe('with ws=fallback', function () {
  1086. beforeEach(function () {
  1087. this.req.query = {}
  1088. this.req.query.ws = 'fallback'
  1089. })
  1090. it('should unset the wsUrl', function (done) {
  1091. this.res.render = (pageName, opts) => {
  1092. ;(opts.wsUrl || '/socket.io').should.equal('/socket.io')
  1093. done()
  1094. }
  1095. this.ProjectController.loadEditor(this.req, this.res)
  1096. })
  1097. checkLoadEditorWsMetric(
  1098. `load-editor-ws${isBeta ? '-beta' : ''}${
  1099. isV2 ? '-v2' : ''
  1100. }-fallback`
  1101. )
  1102. })
  1103. }
  1104. beforeEach(function () {
  1105. this.settings.wsUrl = '/other.socket.io'
  1106. })
  1107. it('should set the custom wsUrl', function (done) {
  1108. this.res.render = (pageName, opts) => {
  1109. opts.wsUrl.should.equal('/other.socket.io')
  1110. done()
  1111. }
  1112. this.ProjectController.loadEditor(this.req, this.res)
  1113. })
  1114. checkLoadEditorWsMetric('load-editor-ws')
  1115. checkWsFallback(false)
  1116. describe('beta program', function () {
  1117. beforeEach(function () {
  1118. this.settings.wsUrlBeta = '/beta.socket.io'
  1119. })
  1120. describe('for a normal user', function () {
  1121. it('should set the normal custom wsUrl', function (done) {
  1122. this.res.render = (pageName, opts) => {
  1123. opts.wsUrl.should.equal('/other.socket.io')
  1124. done()
  1125. }
  1126. this.ProjectController.loadEditor(this.req, this.res)
  1127. })
  1128. checkLoadEditorWsMetric('load-editor-ws')
  1129. checkWsFallback(false)
  1130. })
  1131. describe('for a beta user', function () {
  1132. beforeEach(function () {
  1133. this.user.betaProgram = true
  1134. })
  1135. it('should set the beta wsUrl', function (done) {
  1136. this.res.render = (pageName, opts) => {
  1137. opts.wsUrl.should.equal('/beta.socket.io')
  1138. done()
  1139. }
  1140. this.ProjectController.loadEditor(this.req, this.res)
  1141. })
  1142. checkLoadEditorWsMetric('load-editor-ws-beta')
  1143. checkWsFallback(true)
  1144. })
  1145. })
  1146. describe('v2-rollout', function () {
  1147. beforeEach(function () {
  1148. this.settings.wsUrlBeta = '/beta.socket.io'
  1149. this.settings.wsUrlV2 = '/socket.io.v2'
  1150. })
  1151. function checkNonMatch() {
  1152. it('should set the normal custom wsUrl', function (done) {
  1153. this.res.render = (pageName, opts) => {
  1154. opts.wsUrl.should.equal('/other.socket.io')
  1155. done()
  1156. }
  1157. this.ProjectController.loadEditor(this.req, this.res)
  1158. })
  1159. checkLoadEditorWsMetric('load-editor-ws')
  1160. checkWsFallback(false)
  1161. }
  1162. function checkMatch() {
  1163. it('should set the v2 wsUrl', function (done) {
  1164. this.res.render = (pageName, opts) => {
  1165. opts.wsUrl.should.equal('/socket.io.v2')
  1166. done()
  1167. }
  1168. this.ProjectController.loadEditor(this.req, this.res)
  1169. })
  1170. checkLoadEditorWsMetric('load-editor-ws-v2')
  1171. checkWsFallback(false, true)
  1172. }
  1173. function checkForBetaUser() {
  1174. describe('for a beta user', function () {
  1175. beforeEach(function () {
  1176. this.user.betaProgram = true
  1177. })
  1178. it('should set the beta wsUrl', function (done) {
  1179. this.res.render = (pageName, opts) => {
  1180. opts.wsUrl.should.equal('/beta.socket.io')
  1181. done()
  1182. }
  1183. this.ProjectController.loadEditor(this.req, this.res)
  1184. })
  1185. checkLoadEditorWsMetric('load-editor-ws-beta')
  1186. checkWsFallback(true)
  1187. })
  1188. }
  1189. describe('when the roll out percentage is 0', function () {
  1190. beforeEach(function () {
  1191. this.settings.wsUrlV2Percentage = 0
  1192. })
  1193. describe('when the projectId does not match (0)', function () {
  1194. beforeEach(function () {
  1195. this.req.params.Project_id = ObjectId.createFromTime(0)
  1196. })
  1197. checkNonMatch()
  1198. })
  1199. describe('when the projectId does not match (42)', function () {
  1200. beforeEach(function () {
  1201. this.req.params.Project_id = ObjectId.createFromTime(42)
  1202. })
  1203. checkNonMatch()
  1204. })
  1205. checkForBetaUser()
  1206. })
  1207. describe('when the roll out percentage is 1', function () {
  1208. beforeEach(function () {
  1209. this.settings.wsUrlV2Percentage = 1
  1210. })
  1211. describe('when the projectId matches (0)', function () {
  1212. beforeEach(function () {
  1213. this.req.params.Project_id = ObjectId.createFromTime(0)
  1214. })
  1215. checkMatch()
  1216. checkForBetaUser()
  1217. })
  1218. describe('when the projectId does not match (1)', function () {
  1219. beforeEach(function () {
  1220. this.req.params.Project_id = ObjectId.createFromTime(1)
  1221. })
  1222. checkNonMatch()
  1223. checkForBetaUser()
  1224. })
  1225. describe('when the projectId does not match (42)', function () {
  1226. beforeEach(function () {
  1227. this.req.params.Project_id = ObjectId.createFromTime(42)
  1228. })
  1229. checkNonMatch()
  1230. })
  1231. })
  1232. describe('when the roll out percentage is 10', function () {
  1233. beforeEach(function () {
  1234. this.settings.wsUrlV2Percentage = 10
  1235. })
  1236. describe('when the projectId matches (0)', function () {
  1237. beforeEach(function () {
  1238. this.req.params.Project_id = ObjectId.createFromTime(0)
  1239. })
  1240. checkMatch()
  1241. })
  1242. describe('when the projectId matches (9)', function () {
  1243. beforeEach(function () {
  1244. this.req.params.Project_id = ObjectId.createFromTime(9)
  1245. })
  1246. checkMatch()
  1247. checkForBetaUser()
  1248. })
  1249. describe('when the projectId does not match (10)', function () {
  1250. beforeEach(function () {
  1251. this.req.params.Project_id = ObjectId.createFromTime(10)
  1252. })
  1253. checkNonMatch()
  1254. })
  1255. describe('when the projectId does not match (42)', function () {
  1256. beforeEach(function () {
  1257. this.req.params.Project_id = ObjectId.createFromTime(42)
  1258. })
  1259. checkNonMatch()
  1260. checkForBetaUser()
  1261. })
  1262. })
  1263. describe('when the roll out percentage is 100', function () {
  1264. beforeEach(function () {
  1265. this.settings.wsUrlV2Percentage = 100
  1266. })
  1267. describe('when the projectId matches (0)', function () {
  1268. beforeEach(function () {
  1269. this.req.params.Project_id = ObjectId.createFromTime(0)
  1270. })
  1271. checkMatch()
  1272. checkForBetaUser()
  1273. })
  1274. describe('when the projectId matches (10)', function () {
  1275. beforeEach(function () {
  1276. this.req.params.Project_id = ObjectId.createFromTime(10)
  1277. })
  1278. checkMatch()
  1279. })
  1280. describe('when the projectId matches (42)', function () {
  1281. beforeEach(function () {
  1282. this.req.params.Project_id = ObjectId.createFromTime(42)
  1283. })
  1284. checkMatch()
  1285. })
  1286. describe('when the projectId matches (99)', function () {
  1287. beforeEach(function () {
  1288. this.req.params.Project_id = ObjectId.createFromTime(99)
  1289. })
  1290. checkMatch()
  1291. })
  1292. })
  1293. })
  1294. })
  1295. describe('upgrade prompt (on header and share project modal)', function () {
  1296. beforeEach(function () {
  1297. // default to saas enabled
  1298. this.Features.hasFeature.withArgs('saas').returns(true)
  1299. // default to without a subscription
  1300. this.SubscriptionLocator.getUsersSubscription = sinon
  1301. .stub()
  1302. .callsArgWith(1, null, null)
  1303. })
  1304. it('should not show without the saas feature', function (done) {
  1305. this.Features.hasFeature.withArgs('saas').returns(false)
  1306. this.res.render = (pageName, opts) => {
  1307. expect(opts.showUpgradePrompt).to.equal(false)
  1308. done()
  1309. }
  1310. this.ProjectController.loadEditor(this.req, this.res)
  1311. })
  1312. it('should show for a user without a subscription or only non-paid affiliations', function (done) {
  1313. this.res.render = (pageName, opts) => {
  1314. expect(opts.showUpgradePrompt).to.equal(true)
  1315. done()
  1316. }
  1317. this.ProjectController.loadEditor(this.req, this.res)
  1318. })
  1319. it('should not show for a user with a personal subscription', function (done) {
  1320. this.SubscriptionLocator.getUsersSubscription = sinon
  1321. .stub()
  1322. .callsArgWith(1, null, {})
  1323. this.res.render = (pageName, opts) => {
  1324. expect(opts.showUpgradePrompt).to.equal(false)
  1325. done()
  1326. }
  1327. this.ProjectController.loadEditor(this.req, this.res)
  1328. })
  1329. it('should not show for a user who is a member of a group subscription', function (done) {
  1330. this.LimitationsManager.userIsMemberOfGroupSubscription = sinon
  1331. .stub()
  1332. .callsArgWith(1, null, true)
  1333. this.res.render = (pageName, opts) => {
  1334. expect(opts.showUpgradePrompt).to.equal(false)
  1335. done()
  1336. }
  1337. this.ProjectController.loadEditor(this.req, this.res)
  1338. })
  1339. it('should not show for a user with an affiliated paid university', function (done) {
  1340. this.InstitutionsFeatures.hasLicence = sinon
  1341. .stub()
  1342. .callsArgWith(1, null, true)
  1343. this.res.render = (pageName, opts) => {
  1344. expect(opts.showUpgradePrompt).to.equal(false)
  1345. done()
  1346. }
  1347. this.ProjectController.loadEditor(this.req, this.res)
  1348. })
  1349. })
  1350. })
  1351. describe('userProjectsJson', function () {
  1352. beforeEach(function (done) {
  1353. const projects = [
  1354. {
  1355. archived: true,
  1356. trashed: true,
  1357. id: 'a',
  1358. name: 'A',
  1359. accessLevel: 'a',
  1360. somethingElse: 1,
  1361. },
  1362. {
  1363. archived: false,
  1364. id: 'b',
  1365. name: 'B',
  1366. accessLevel: 'b',
  1367. somethingElse: 1,
  1368. },
  1369. {
  1370. archived: false,
  1371. trashed: true,
  1372. id: 'c',
  1373. name: 'C',
  1374. accessLevel: 'c',
  1375. somethingElse: 1,
  1376. },
  1377. {
  1378. archived: false,
  1379. trashed: false,
  1380. id: 'd',
  1381. name: 'D',
  1382. accessLevel: 'd',
  1383. somethingElse: 1,
  1384. },
  1385. ]
  1386. this.ProjectHelper.isArchivedOrTrashed
  1387. .withArgs(projects[0], this.user._id)
  1388. .returns(true)
  1389. this.ProjectHelper.isArchivedOrTrashed
  1390. .withArgs(projects[1], this.user._id)
  1391. .returns(false)
  1392. this.ProjectHelper.isArchivedOrTrashed
  1393. .withArgs(projects[2], this.user._id)
  1394. .returns(true)
  1395. this.ProjectHelper.isArchivedOrTrashed
  1396. .withArgs(projects[3], this.user._id)
  1397. .returns(false)
  1398. this.ProjectGetter.findAllUsersProjects = sinon
  1399. .stub()
  1400. .callsArgWith(2, null, [])
  1401. this.ProjectController._buildProjectList = sinon.stub().returns(projects)
  1402. this.SessionManager.getLoggedInUserId = sinon
  1403. .stub()
  1404. .returns(this.user._id)
  1405. done()
  1406. })
  1407. it('should produce a list of projects', function (done) {
  1408. this.res.json = data => {
  1409. expect(data).to.deep.equal({
  1410. projects: [
  1411. { _id: 'b', name: 'B', accessLevel: 'b' },
  1412. { _id: 'd', name: 'D', accessLevel: 'd' },
  1413. ],
  1414. })
  1415. done()
  1416. }
  1417. this.ProjectController.userProjectsJson(this.req, this.res, this.next)
  1418. })
  1419. })
  1420. describe('projectEntitiesJson', function () {
  1421. beforeEach(function () {
  1422. this.SessionManager.getLoggedInUserId = sinon.stub().returns('abc')
  1423. this.req.params = { Project_id: 'abcd' }
  1424. this.project = { _id: 'abcd' }
  1425. this.docs = [
  1426. { path: '/things/b.txt', doc: true },
  1427. { path: '/main.tex', doc: true },
  1428. ]
  1429. this.files = [{ path: '/things/a.txt' }]
  1430. this.ProjectGetter.getProject = sinon
  1431. .stub()
  1432. .callsArgWith(1, null, this.project)
  1433. this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
  1434. .stub()
  1435. .returns({ docs: this.docs, files: this.files })
  1436. })
  1437. it('should produce a list of entities', function (done) {
  1438. this.res.json = data => {
  1439. expect(data).to.deep.equal({
  1440. project_id: 'abcd',
  1441. entities: [
  1442. { path: '/main.tex', type: 'doc' },
  1443. { path: '/things/a.txt', type: 'file' },
  1444. { path: '/things/b.txt', type: 'doc' },
  1445. ],
  1446. })
  1447. expect(this.ProjectGetter.getProject.callCount).to.equal(1)
  1448. expect(
  1449. this.ProjectEntityHandler.getAllEntitiesFromProject.callCount
  1450. ).to.equal(1)
  1451. done()
  1452. }
  1453. this.ProjectController.projectEntitiesJson(this.req, this.res, this.next)
  1454. })
  1455. })
  1456. describe('_buildProjectViewModel', function () {
  1457. beforeEach(function () {
  1458. this.ProjectHelper.isArchived.returns(false)
  1459. this.ProjectHelper.isTrashed.returns(false)
  1460. this.project = {
  1461. _id: 'abcd',
  1462. name: 'netsenits',
  1463. lastUpdated: 1,
  1464. lastUpdatedBy: 2,
  1465. publicAccesLevel: 'private',
  1466. archived: false,
  1467. owner_ref: 'defg',
  1468. tokens: {
  1469. readAndWrite: '1abcd',
  1470. readAndWritePrefix: '1',
  1471. readOnly: 'neiotsranteoia',
  1472. },
  1473. }
  1474. })
  1475. describe('project not being archived or trashed', function () {
  1476. it('should produce a model of the project', function () {
  1477. const result = this.ProjectController._buildProjectViewModel(
  1478. this.project,
  1479. 'readAndWrite',
  1480. 'owner',
  1481. this.user._id
  1482. )
  1483. expect(result).to.exist
  1484. expect(result).to.be.an('object')
  1485. expect(result).to.deep.equal({
  1486. id: 'abcd',
  1487. name: 'netsenits',
  1488. lastUpdated: 1,
  1489. lastUpdatedBy: 2,
  1490. publicAccessLevel: 'private',
  1491. accessLevel: 'readAndWrite',
  1492. source: 'owner',
  1493. archived: false,
  1494. trashed: false,
  1495. owner_ref: 'defg',
  1496. isV1Project: false,
  1497. })
  1498. })
  1499. })
  1500. describe('project being simultaneously archived and trashed', function () {
  1501. beforeEach(function () {
  1502. this.ProjectHelper.isArchived.returns(true)
  1503. this.ProjectHelper.isTrashed.returns(true)
  1504. })
  1505. it('should produce a model of the project', function () {
  1506. const result = this.ProjectController._buildProjectViewModel(
  1507. this.project,
  1508. 'readAndWrite',
  1509. 'owner',
  1510. this.user._id
  1511. )
  1512. expect(result).to.exist
  1513. expect(result).to.be.an('object')
  1514. expect(result).to.deep.equal({
  1515. id: 'abcd',
  1516. name: 'netsenits',
  1517. lastUpdated: 1,
  1518. lastUpdatedBy: 2,
  1519. publicAccessLevel: 'private',
  1520. accessLevel: 'readAndWrite',
  1521. source: 'owner',
  1522. archived: true,
  1523. trashed: false,
  1524. owner_ref: 'defg',
  1525. isV1Project: false,
  1526. })
  1527. })
  1528. })
  1529. describe('when token-read-only access', function () {
  1530. it('should redact the owner and last-updated data', function () {
  1531. const result = this.ProjectController._buildProjectViewModel(
  1532. this.project,
  1533. 'readOnly',
  1534. 'token',
  1535. this.user._id
  1536. )
  1537. expect(result).to.exist
  1538. expect(result).to.be.an('object')
  1539. expect(result).to.deep.equal({
  1540. id: 'abcd',
  1541. name: 'netsenits',
  1542. lastUpdated: 1,
  1543. lastUpdatedBy: null,
  1544. publicAccessLevel: 'private',
  1545. accessLevel: 'readOnly',
  1546. source: 'token',
  1547. archived: false,
  1548. trashed: false,
  1549. owner_ref: null,
  1550. isV1Project: false,
  1551. })
  1552. })
  1553. })
  1554. })
  1555. describe('_isInPercentageRollout', function () {
  1556. before(function () {
  1557. this.ids = [
  1558. '5a05cd7621f9fe22be131740',
  1559. '5a05cd7821f9fe22be131741',
  1560. '5a05cd7921f9fe22be131742',
  1561. '5a05cd7a21f9fe22be131743',
  1562. '5a05cd7b21f9fe22be131744',
  1563. '5a05cd7c21f9fe22be131745',
  1564. '5a05cd7d21f9fe22be131746',
  1565. '5a05cd7e21f9fe22be131747',
  1566. '5a05cd7f21f9fe22be131748',
  1567. '5a05cd8021f9fe22be131749',
  1568. '5a05cd8021f9fe22be13174a',
  1569. '5a05cd8121f9fe22be13174b',
  1570. '5a05cd8221f9fe22be13174c',
  1571. '5a05cd8221f9fe22be13174d',
  1572. '5a05cd8321f9fe22be13174e',
  1573. '5a05cd8321f9fe22be13174f',
  1574. '5a05cd8421f9fe22be131750',
  1575. '5a05cd8421f9fe22be131751',
  1576. '5a05cd8421f9fe22be131752',
  1577. '5a05cd8521f9fe22be131753',
  1578. ]
  1579. })
  1580. it('should produce the expected results', function () {
  1581. expect(
  1582. this.ids.map(i =>
  1583. this.ProjectController._isInPercentageRollout('abcd', i, 50)
  1584. )
  1585. ).to.deep.equal([
  1586. false,
  1587. false,
  1588. false,
  1589. false,
  1590. false,
  1591. false,
  1592. true,
  1593. false,
  1594. true,
  1595. true,
  1596. true,
  1597. true,
  1598. true,
  1599. true,
  1600. false,
  1601. false,
  1602. false,
  1603. true,
  1604. false,
  1605. true,
  1606. ])
  1607. expect(
  1608. this.ids.map(i =>
  1609. this.ProjectController._isInPercentageRollout('efgh', i, 50)
  1610. )
  1611. ).to.deep.equal([
  1612. false,
  1613. false,
  1614. false,
  1615. false,
  1616. true,
  1617. false,
  1618. false,
  1619. true,
  1620. false,
  1621. false,
  1622. true,
  1623. true,
  1624. true,
  1625. false,
  1626. true,
  1627. false,
  1628. true,
  1629. true,
  1630. false,
  1631. false,
  1632. ])
  1633. })
  1634. })
  1635. })