ProjectControllerTests.js 58 KB

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