ProjectListController.test.mjs 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  1. import { beforeEach, describe, it, expect, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. import mongodb from 'mongodb-legacy'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import Settings from '@overleaf/settings'
  6. const ObjectId = mongodb.ObjectId
  7. const MODULE_PATH = `${import.meta.dirname}/../../../../app/src/Features/Project/ProjectListController`
  8. // Mock AnalyticsManager as it isn't used in these tests but causes the User model to be imported and redeclares queues
  9. vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.mjs', () => {
  10. return {
  11. default: {
  12. setUserPropertyForUserInBackground: () => {},
  13. },
  14. }
  15. })
  16. describe('ProjectListController', function () {
  17. beforeEach(async function (ctx) {
  18. ctx.project_id = new ObjectId('abcdefabcdefabcdefabcdef')
  19. ctx.user = {
  20. _id: new ObjectId('123456123456123456123456'),
  21. email: 'test@overleaf.com',
  22. first_name: 'bjkdsjfk',
  23. features: {},
  24. emails: [{ email: 'test@overleaf.com' }],
  25. lastActive: new Date(2),
  26. signUpDate: new Date(1),
  27. lastLoginIp: '111.111.111.112',
  28. ace: {
  29. syntaxValidation: true,
  30. pdfViewer: 'pdfjs',
  31. spellCheckLanguage: 'en',
  32. autoPairDelimiters: true,
  33. autoComplete: true,
  34. fontSize: 12,
  35. theme: 'textmate',
  36. mode: 'none',
  37. },
  38. aiFeatures: { enabled: false },
  39. }
  40. ctx.users = {
  41. 'user-1': {
  42. first_name: 'James',
  43. },
  44. 'user-2': {
  45. first_name: 'Henry',
  46. },
  47. }
  48. ctx.users[ctx.user._id] = ctx.user // Owner
  49. ctx.usersArr = Object.entries(ctx.users).map(([key, value]) => ({
  50. _id: key,
  51. ...value,
  52. }))
  53. ctx.tags = [
  54. { name: 1, project_ids: ['1', '2', '3'] },
  55. { name: 2, project_ids: ['a', '1'] },
  56. { name: 3, project_ids: ['a', 'b', 'c', 'd'] },
  57. ]
  58. ctx.notifications = [
  59. {
  60. _id: '1',
  61. user_id: '2',
  62. templateKey: '3',
  63. messageOpts: '4',
  64. key: '5',
  65. },
  66. ]
  67. ctx.settings = {
  68. ...Settings,
  69. siteUrl: 'https://overleaf.com',
  70. }
  71. ctx.onboardingDataCollection = {
  72. companyDivisionDepartment: '',
  73. companyJobTitle: '',
  74. firstName: 'Dos',
  75. governmentJobTitle: '',
  76. institutionName: '',
  77. lastName: 'Mukasan',
  78. nonprofitDivisionDepartment: '',
  79. nonprofitJobTitle: '',
  80. otherJobTitle: '',
  81. primaryOccupation: 'company',
  82. role: 'conductor',
  83. subjectArea: 'music',
  84. updatedAt: '2025-09-04T12:12:21.628Z',
  85. usedLatex: 'occasionally',
  86. }
  87. ctx.TagsHandler = {
  88. promises: {
  89. getAllTags: sinon.stub().resolves(ctx.tags),
  90. },
  91. }
  92. ctx.NotificationsHandler = {
  93. promises: {
  94. getUserNotifications: sinon.stub().resolves(ctx.notifications),
  95. },
  96. }
  97. ctx.UserModel = {
  98. findById: sinon.stub().resolves(ctx.user),
  99. }
  100. ctx.OnboardingDataCollectionModel = {
  101. findById: sinon.stub().resolves(ctx.onboardingDataCollection),
  102. }
  103. ctx.UserPrimaryEmailCheckHandler = {
  104. requiresPrimaryEmailCheck: sinon.stub().returns(false),
  105. }
  106. ctx.ProjectGetter = {
  107. promises: {
  108. findAllUsersProjects: sinon.stub(),
  109. },
  110. }
  111. ctx.ProjectHelper = {
  112. isArchived: sinon.stub(),
  113. isTrashed: sinon.stub(),
  114. }
  115. ctx.SessionManager = {
  116. getLoggedInUserId: sinon.stub().returns(ctx.user._id),
  117. }
  118. ctx.UserController = {
  119. logout: sinon.stub(),
  120. }
  121. ctx.UserGetter = {
  122. promises: {
  123. getUsers: sinon.stub().resolves(ctx.usersArr),
  124. getUserFullEmails: sinon.stub().resolves([]),
  125. getWritefullData: sinon.stub().resolves({ isPremium: true }),
  126. },
  127. }
  128. ctx.Features = {
  129. hasFeature: sinon.stub(),
  130. }
  131. ctx.SplitTestHandler = {
  132. promises: {
  133. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  134. featureFlagEnabledForUser: sinon.stub().resolves(false),
  135. hasUserBeenAssignedToVariant: sinon.stub().resolves(false),
  136. },
  137. }
  138. ctx.SplitTestSessionHandler = {
  139. promises: {
  140. sessionMaintenance: sinon.stub().resolves(),
  141. },
  142. }
  143. ctx.SubscriptionViewModelBuilder = {
  144. promises: {
  145. getUsersSubscriptionDetails: sinon.stub().resolves({
  146. bestSubscription: { type: 'free' },
  147. individualSubscription: null,
  148. memberGroupSubscriptions: [],
  149. managedGroupSubscriptions: [],
  150. }),
  151. },
  152. }
  153. ctx.SurveyHandler = {
  154. promises: {
  155. getSurvey: sinon.stub().resolves({}),
  156. },
  157. }
  158. ctx.NotificationBuilder = {
  159. promises: {
  160. ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
  161. },
  162. }
  163. ctx.GeoIpLookup = {
  164. promises: {
  165. getCurrencyCode: sinon.stub().resolves({
  166. countryCode: 'US',
  167. currencyCode: 'USD',
  168. }),
  169. },
  170. }
  171. ctx.TutorialHandler = {
  172. getInactiveTutorials: sinon.stub().returns([]),
  173. }
  174. ctx.Modules = {
  175. promises: {
  176. hooks: {
  177. fire: sinon.stub().resolves([]),
  178. },
  179. },
  180. }
  181. ctx.PermissionsManager = {
  182. promises: {
  183. checkUserPermissions: sinon.stub().resolves(true),
  184. },
  185. }
  186. ctx.SubscriptionLocator = {
  187. promises: {
  188. getUsersSubscription: sinon.stub().resolves({}),
  189. },
  190. }
  191. vi.doMock('mongodb-legacy', () => ({
  192. default: { ObjectId },
  193. }))
  194. vi.doMock('@overleaf/settings', () => ({
  195. default: ctx.settings,
  196. }))
  197. vi.doMock(
  198. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  199. () => ({
  200. default: ctx.SplitTestHandler,
  201. })
  202. )
  203. vi.doMock(
  204. '../../../../app/src/Features/SplitTests/SplitTestSessionHandler',
  205. () => ({
  206. default: ctx.SplitTestSessionHandler,
  207. })
  208. )
  209. vi.doMock('../../../../app/src/Features/User/UserController', () => ({
  210. default: ctx.UserController,
  211. }))
  212. vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({
  213. default: ctx.ProjectHelper,
  214. }))
  215. vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
  216. default: ctx.TagsHandler,
  217. }))
  218. vi.doMock(
  219. '../../../../app/src/Features/Notifications/NotificationsHandler',
  220. () => ({
  221. default: ctx.NotificationsHandler,
  222. })
  223. )
  224. vi.doMock('../../../../app/src/models/User', () => ({
  225. User: ctx.UserModel,
  226. }))
  227. vi.doMock('../../../../app/src/models/OnboardingDataCollection', () => ({
  228. OnboardingDataCollection: ctx.OnboardingDataCollectionModel,
  229. }))
  230. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  231. default: ctx.ProjectGetter,
  232. }))
  233. vi.doMock(
  234. '../../../../app/src/Features/Authentication/SessionManager',
  235. () => ({
  236. default: ctx.SessionManager,
  237. })
  238. )
  239. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  240. default: ctx.Features,
  241. }))
  242. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  243. default: ctx.UserGetter,
  244. }))
  245. vi.doMock(
  246. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  247. () => ({
  248. default: ctx.SubscriptionViewModelBuilder,
  249. })
  250. )
  251. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  252. default: ctx.Modules,
  253. }))
  254. vi.doMock('../../../../app/src/Features/Survey/SurveyHandler', () => ({
  255. default: ctx.SurveyHandler,
  256. }))
  257. vi.doMock(
  258. '../../../../app/src/Features/User/UserPrimaryEmailCheckHandler',
  259. () => ({
  260. default: ctx.UserPrimaryEmailCheckHandler,
  261. })
  262. )
  263. vi.doMock(
  264. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  265. () => ({
  266. default: ctx.NotificationBuilder,
  267. })
  268. )
  269. vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
  270. default: ctx.GeoIpLookup,
  271. }))
  272. vi.doMock('../../../../app/src/Features/Tutorial/TutorialHandler', () => ({
  273. default: ctx.TutorialHandler,
  274. }))
  275. vi.doMock(
  276. '../../../../app/src/Features/Authorization/PermissionsManager',
  277. () => ({
  278. default: ctx.PermissionsManager,
  279. })
  280. )
  281. vi.doMock(
  282. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  283. () => ({
  284. default: ctx.SubscriptionLocator,
  285. })
  286. )
  287. ctx.ProjectListController = (await import(MODULE_PATH)).default
  288. ctx.req = {
  289. query: {},
  290. params: {
  291. Project_id: ctx.project_id,
  292. },
  293. headers: {},
  294. session: {
  295. user: ctx.user,
  296. },
  297. body: {},
  298. i18n: {
  299. translate() {},
  300. },
  301. }
  302. ctx.res = {}
  303. })
  304. describe('projectListPage', function () {
  305. beforeEach(function (ctx) {
  306. ctx.projects = [
  307. { _id: 1, lastUpdated: new Date(1), owner_ref: 'user-1' },
  308. {
  309. _id: 2,
  310. lastUpdated: new Date(2),
  311. owner_ref: 'user-2',
  312. lastUpdatedBy: 'user-1',
  313. },
  314. ]
  315. ctx.readAndWrite = [
  316. { _id: 5, lastUpdated: new Date(5), owner_ref: 'user-1' },
  317. ]
  318. ctx.readOnly = [{ _id: 3, lastUpdated: new Date(3), owner_ref: 'user-1' }]
  319. ctx.tokenReadAndWrite = [
  320. { _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' },
  321. ]
  322. ctx.tokenReadOnly = [
  323. { _id: 7, lastUpdated: new Date(4), owner_ref: 'user-5' },
  324. ]
  325. ctx.review = [{ _id: 8, lastUpdated: new Date(4), owner_ref: 'user-6' }]
  326. ctx.allProjects = {
  327. owned: ctx.projects,
  328. readAndWrite: ctx.readAndWrite,
  329. readOnly: ctx.readOnly,
  330. tokenReadAndWrite: ctx.tokenReadAndWrite,
  331. tokenReadOnly: ctx.tokenReadOnly,
  332. review: ctx.review,
  333. }
  334. ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects)
  335. })
  336. it('should render the project/list-react page', async function (ctx) {
  337. ctx.res.render = (pageName, opts) => {
  338. pageName.should.equal('project/list-react')
  339. }
  340. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  341. })
  342. it('should invoke the session maintenance', async function (ctx) {
  343. ctx.Features.hasFeature.withArgs('saas').returns(true)
  344. ctx.res.render = () => {
  345. ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
  346. ctx.req,
  347. ctx.user
  348. )
  349. }
  350. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  351. })
  352. it('should send the tags', async function (ctx) {
  353. ctx.res.render = (pageName, opts) => {
  354. opts.tags.length.should.equal(ctx.tags.length)
  355. }
  356. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  357. })
  358. it('should create trigger ip matcher notifications', async function (ctx) {
  359. ctx.settings.overleaf = true
  360. ctx.req.ip = '111.111.111.111'
  361. ctx.res.render = (pageName, opts) => {
  362. ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal(
  363. true
  364. )
  365. }
  366. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  367. })
  368. it('should send the projects', async function (ctx) {
  369. ctx.res.render = (pageName, opts) => {
  370. opts.prefetchedProjectsBlob.projects.length.should.equal(
  371. ctx.projects.length +
  372. ctx.readAndWrite.length +
  373. ctx.readOnly.length +
  374. ctx.tokenReadAndWrite.length +
  375. ctx.tokenReadOnly.length +
  376. ctx.review.length
  377. )
  378. }
  379. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  380. })
  381. it('should send the user', async function (ctx) {
  382. ctx.res.render = (pageName, opts) => {
  383. opts.user.should.deep.equal(ctx.user)
  384. }
  385. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  386. })
  387. it('should inject the users', async function (ctx) {
  388. ctx.res.render = (pageName, opts) => {
  389. const projects = opts.prefetchedProjectsBlob.projects
  390. projects
  391. .filter(p => p.id === '1')[0]
  392. .owner.firstName.should.equal(
  393. ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref]
  394. .first_name
  395. )
  396. projects
  397. .filter(p => p.id === '2')[0]
  398. .owner.firstName.should.equal(
  399. ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref]
  400. .first_name
  401. )
  402. projects
  403. .filter(p => p.id === '2')[0]
  404. .lastUpdatedBy.firstName.should.equal(
  405. ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy]
  406. .first_name
  407. )
  408. }
  409. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  410. })
  411. it("should send the user's best subscription when saas feature present", async function (ctx) {
  412. ctx.Features.hasFeature.withArgs('saas').returns(true)
  413. ctx.res.render = (pageName, opts) => {
  414. expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
  415. }
  416. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  417. })
  418. it('should not return a best subscription without saas feature', async function (ctx) {
  419. ctx.Features.hasFeature.withArgs('saas').returns(false)
  420. ctx.res.render = (pageName, opts) => {
  421. expect(opts.usersBestSubscription).to.be.undefined
  422. }
  423. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  424. })
  425. it('should look up geo IP in saas', async function (ctx) {
  426. ctx.Features.hasFeature.withArgs('saas').returns(true)
  427. ctx.res.render = () => {}
  428. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  429. expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.calledOnce
  430. })
  431. it('should not look up geo IP in a non-saas environment', async function (ctx) {
  432. ctx.Features.hasFeature.withArgs('saas').returns(false)
  433. ctx.res.render = () => {}
  434. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  435. expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.not.have.been.called
  436. })
  437. it('should send groupRole to customer.io for group admins', async function (ctx) {
  438. ctx.Features.hasFeature.withArgs('saas').returns(true)
  439. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  440. {
  441. bestSubscription: { type: 'free' },
  442. individualSubscription: null,
  443. memberGroupSubscriptions: [],
  444. managedGroupSubscriptions: [
  445. {
  446. planCode: 'group_professional',
  447. membersLimit: 12,
  448. admin_id: { _id: ctx.user._id },
  449. },
  450. ],
  451. }
  452. )
  453. ctx.res.render = () => {}
  454. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  455. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  456. 'setUserProperties',
  457. ctx.user._id,
  458. sinon.match({
  459. group_role: 'admin',
  460. })
  461. )
  462. })
  463. it('should send groupRole to customer.io for group managers', async function (ctx) {
  464. ctx.Features.hasFeature.withArgs('saas').returns(true)
  465. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  466. {
  467. bestSubscription: { type: 'free' },
  468. individualSubscription: null,
  469. memberGroupSubscriptions: [],
  470. managedGroupSubscriptions: [
  471. {
  472. planCode: 'group_professional',
  473. membersLimit: 12,
  474. admin_id: { _id: new ObjectId() },
  475. },
  476. ],
  477. }
  478. )
  479. ctx.res.render = () => {}
  480. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  481. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  482. 'setUserProperties',
  483. ctx.user._id,
  484. sinon.match({
  485. group_role: 'manager',
  486. })
  487. )
  488. })
  489. it('should send groupRole to customer.io for group members', async function (ctx) {
  490. ctx.Features.hasFeature.withArgs('saas').returns(true)
  491. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  492. {
  493. bestSubscription: { type: 'free' },
  494. individualSubscription: null,
  495. memberGroupSubscriptions: [
  496. {
  497. planCode: 'group_professional',
  498. userIsGroupManager: false,
  499. },
  500. ],
  501. managedGroupSubscriptions: [],
  502. }
  503. )
  504. ctx.res.render = () => {}
  505. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  506. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  507. 'setUserProperties',
  508. ctx.user._id,
  509. sinon.match({
  510. group_role: 'member',
  511. })
  512. )
  513. })
  514. it('should send enterprise_commons=true when user has commons from an enterprise_commons institution', async function (ctx) {
  515. ctx.Features.hasFeature.withArgs('saas').returns(true)
  516. ctx.UserGetter.promises.getUserFullEmails.resolves([
  517. {
  518. email: 'test@overleaf.com',
  519. emailHasInstitutionLicence: true,
  520. affiliation: {
  521. institution: {
  522. id: 1,
  523. name: 'Overleaf',
  524. commonsAccount: true,
  525. enterpriseCommons: true,
  526. },
  527. },
  528. },
  529. ])
  530. ctx.res.render = () => {}
  531. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  532. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  533. 'setUserProperties',
  534. ctx.user._id,
  535. sinon.match({ enterprise_commons: true })
  536. )
  537. })
  538. it('should send enterprise_commons=false when affiliated with an enterprise_commons institution but without active commons access', async function (ctx) {
  539. ctx.Features.hasFeature.withArgs('saas').returns(true)
  540. ctx.UserGetter.promises.getUserFullEmails.resolves([
  541. {
  542. email: 'test@overleaf.com',
  543. emailHasInstitutionLicence: false,
  544. affiliation: {
  545. institution: {
  546. id: 1,
  547. name: 'Overleaf',
  548. commonsAccount: true,
  549. enterpriseCommons: true,
  550. },
  551. },
  552. },
  553. ])
  554. ctx.res.render = () => {}
  555. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  556. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  557. 'setUserProperties',
  558. ctx.user._id,
  559. sinon.match({ enterprise_commons: false })
  560. )
  561. })
  562. it('should send domain_capture=true when user has an affiliation with domain capture enabled', async function (ctx) {
  563. ctx.Features.hasFeature.withArgs('saas').returns(true)
  564. ctx.UserGetter.promises.getUserFullEmails.resolves([
  565. {
  566. email: 'test@overleaf.com',
  567. affiliation: {
  568. institution: { id: 1, name: 'Overleaf' },
  569. group: {
  570. _id: 'g1',
  571. domainCaptureEnabled: true,
  572. managedUsersEnabled: false,
  573. },
  574. },
  575. },
  576. ])
  577. ctx.res.render = () => {}
  578. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  579. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  580. 'setUserProperties',
  581. ctx.user._id,
  582. sinon.match({ domain_capture: true })
  583. )
  584. })
  585. it('should send domain_capture=false when no affiliation has domain capture enabled', async function (ctx) {
  586. ctx.Features.hasFeature.withArgs('saas').returns(true)
  587. ctx.UserGetter.promises.getUserFullEmails.resolves([
  588. {
  589. email: 'test@overleaf.com',
  590. affiliation: {
  591. institution: { id: 1, name: 'Overleaf' },
  592. },
  593. },
  594. ])
  595. ctx.res.render = () => {}
  596. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  597. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  598. 'setUserProperties',
  599. ctx.user._id,
  600. sinon.match({ domain_capture: false })
  601. )
  602. })
  603. it('should show INR Banner for Indian users with free account', async function (ctx) {
  604. // usersBestSubscription is only available when saas feature is present
  605. ctx.Features.hasFeature.withArgs('saas').returns(true)
  606. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  607. {
  608. memberGroupSubscriptions: [],
  609. managedGroupSubscriptions: [],
  610. bestSubscription: {
  611. type: 'free',
  612. },
  613. }
  614. )
  615. ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
  616. countryCode: 'IN',
  617. })
  618. ctx.res.render = (pageName, opts) => {
  619. expect(opts.showInrGeoBanner).to.be.true
  620. }
  621. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  622. })
  623. it('should not show INR Banner for Indian users with premium account', async function (ctx) {
  624. // usersBestSubscription is only available when saas feature is present
  625. ctx.Features.hasFeature.withArgs('saas').returns(true)
  626. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  627. {
  628. memberGroupSubscriptions: [],
  629. managedGroupSubscriptions: [],
  630. bestSubscription: {
  631. type: 'individual',
  632. },
  633. }
  634. )
  635. ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
  636. countryCode: 'IN',
  637. })
  638. ctx.res.render = (pageName, opts) => {
  639. expect(opts.showInrGeoBanner).to.be.false
  640. }
  641. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  642. })
  643. it('should redirect to domain capture page', async function (ctx) {
  644. ctx.Features.hasFeature.withArgs('saas').returns(true)
  645. ctx.SplitTestHandler.promises.getAssignment
  646. .withArgs(ctx.req, ctx.res, 'domain-capture-redirect')
  647. .resolves({ variant: 'enabled' })
  648. ctx.Modules.promises.hooks.fire
  649. .withArgs('findDomainCaptureGroupsUserCouldBePartOf', ctx.user._id)
  650. .resolves([
  651. [
  652. {
  653. subscription: { managedUsersEnabled: true },
  654. },
  655. ],
  656. ])
  657. ctx.res.redirect = url => {
  658. url.should.equal('/domain-capture')
  659. }
  660. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  661. })
  662. it('should not redirect to domain capture page when no domain capture groups found', async function (ctx) {
  663. ctx.Features.hasFeature.withArgs('saas').returns(true)
  664. ctx.SplitTestHandler.promises.getAssignment
  665. .withArgs(ctx.req, ctx.res, 'domain-capture-redirect')
  666. .resolves({ variant: 'enabled' })
  667. ctx.Modules.promises.hooks.fire
  668. .withArgs('findDomainCaptureGroupsUserCouldBePartOf', ctx.user._id)
  669. .resolves([[]])
  670. let redirectCalled = false
  671. ctx.res.redirect = () => {
  672. redirectCalled = true
  673. }
  674. let redirectTo = ''
  675. ctx.res.render = (pageName, opts) => {
  676. redirectTo = pageName
  677. }
  678. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  679. expect(redirectCalled).to.be.false
  680. expect(redirectTo).to.equal('project/list-react')
  681. })
  682. describe('when user linked to SSO', function () {
  683. const linkedEmail = 'picard@starfleet.com'
  684. const universityName = 'Starfleet'
  685. const notificationData = {
  686. email: linkedEmail,
  687. institutionName: universityName,
  688. }
  689. beforeEach(function (ctx) {
  690. ctx.Features.hasFeature.withArgs('saml').returns(true)
  691. ctx.req.session.saml = {
  692. institutionEmail: linkedEmail,
  693. linked: {
  694. universityName,
  695. },
  696. }
  697. })
  698. it('should render with Commons template when Commons was linked', async function (ctx) {
  699. ctx.res.render = (pageName, opts) => {
  700. expect(opts.notificationsInstitution).to.deep.equal([
  701. Object.assign(
  702. { templateKey: 'notification_institution_sso_linked' },
  703. notificationData
  704. ),
  705. ])
  706. }
  707. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  708. })
  709. describe('when via domain capture', function () {
  710. beforeEach(function (ctx) {
  711. ctx.req.session.saml.domainCaptureEnabled = true
  712. })
  713. it('should render with group template', async function (ctx) {
  714. ctx.res.render = (pageName, opts) => {
  715. expect(opts.notificationsInstitution).to.deep.equal([
  716. Object.assign(
  717. { templateKey: 'notification_group_sso_linked' },
  718. notificationData
  719. ),
  720. ])
  721. }
  722. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  723. })
  724. describe('user created via domain capture and group is managed', function () {
  725. beforeEach(function (ctx) {
  726. ctx.req.session.saml.userCreatedViaDomainCapture = true
  727. })
  728. it('should render with notification_group_sso_linked', async function (ctx) {
  729. ctx.res.render = (pageName, opts) => {
  730. expect(opts.notificationsInstitution).to.deep.equal([
  731. Object.assign(
  732. {
  733. templateKey: 'notification_group_sso_linked',
  734. },
  735. notificationData
  736. ),
  737. ])
  738. }
  739. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  740. })
  741. it('should render with notification_account_created_via_group_domain_capture_and_managed_users_enabled when managed user is enabled', async function (ctx) {
  742. ctx.req.session.saml.managedUsersEnabled = true
  743. ctx.res.render = (pageName, opts) => {
  744. expect(opts.notificationsInstitution).to.deep.equal([
  745. Object.assign(
  746. {
  747. templateKey:
  748. 'notification_account_created_via_group_domain_capture_and_managed_users_enabled',
  749. },
  750. notificationData
  751. ),
  752. ])
  753. }
  754. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  755. })
  756. })
  757. })
  758. })
  759. describe('With Institution SSO feature', function () {
  760. beforeEach(function (ctx) {
  761. ctx.institutionEmail = 'test@overleaf.com'
  762. ctx.institutionName = 'Overleaf'
  763. ctx.Features.hasFeature.withArgs('saml').returns(true)
  764. ctx.Features.hasFeature.withArgs('affiliations').returns(true)
  765. ctx.Features.hasFeature.withArgs('saas').returns(true)
  766. })
  767. it('should show institution SSO available notification for confirmed domains', async function (ctx) {
  768. ctx.UserGetter.promises.getUserFullEmails.resolves([
  769. {
  770. email: 'test@overleaf.com',
  771. affiliation: {
  772. institution: {
  773. id: 1,
  774. confirmed: true,
  775. name: 'Overleaf',
  776. ssoBeta: false,
  777. ssoEnabled: true,
  778. },
  779. },
  780. },
  781. ])
  782. ctx.res.render = (pageName, opts) => {
  783. expect(opts.notificationsInstitution).to.deep.include({
  784. email: ctx.institutionEmail,
  785. institutionId: 1,
  786. institutionName: ctx.institutionName,
  787. templateKey: 'notification_institution_sso_available',
  788. })
  789. }
  790. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  791. })
  792. it('should show a linked notification', async function (ctx) {
  793. ctx.req.session.saml = {
  794. institutionEmail: ctx.institutionEmail,
  795. linked: {
  796. hasEntitlement: false,
  797. universityName: ctx.institutionName,
  798. },
  799. }
  800. ctx.res.render = (pageName, opts) => {
  801. expect(opts.notificationsInstitution).to.deep.include({
  802. email: ctx.institutionEmail,
  803. institutionName: ctx.institutionName,
  804. templateKey: 'notification_institution_sso_linked',
  805. })
  806. }
  807. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  808. })
  809. it('should show a group linked notification when domain capture enabled', async function (ctx) {
  810. ctx.req.session.saml = {
  811. institutionEmail: ctx.institutionEmail,
  812. linked: {
  813. hasEntitlement: false,
  814. universityName: ctx.institutionName,
  815. },
  816. domainCaptureEnabled: true,
  817. }
  818. ctx.res.render = (pageName, opts) => {
  819. expect(opts.notificationsInstitution).to.deep.include({
  820. email: ctx.institutionEmail,
  821. institutionName: ctx.institutionName,
  822. templateKey: 'notification_group_sso_linked',
  823. })
  824. }
  825. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  826. })
  827. it('should show a success notification when joining group via domain capture page', async function (ctx) {
  828. ctx.req.session.saml = {
  829. linkedGroup: true,
  830. universityName: ctx.institutionName,
  831. domainCaptureJoin: true,
  832. }
  833. ctx.res.render = (pageName, opts) => {
  834. expect(opts).to.deep.include({
  835. groupSsoSetupSuccess: true,
  836. joinedGroupName: ctx.institutionName,
  837. viaDomainCapture: true,
  838. })
  839. }
  840. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  841. })
  842. it('should show a linked another email notification', async function (ctx) {
  843. // when they request to link an email but the institution returns
  844. // a different email
  845. ctx.res.render = (pageName, opts) => {
  846. expect(opts.notificationsInstitution).to.deep.include({
  847. institutionEmail: ctx.institutionEmail,
  848. requestedEmail: 'requested@overleaf.com',
  849. templateKey: 'notification_institution_sso_non_canonical',
  850. })
  851. }
  852. ctx.req.session.saml = {
  853. emailNonCanonical: ctx.institutionEmail,
  854. institutionEmail: ctx.institutionEmail,
  855. requestedEmail: 'requested@overleaf.com',
  856. linked: {
  857. hasEntitlement: false,
  858. universityName: ctx.institutionName,
  859. },
  860. }
  861. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  862. })
  863. it('should show a notification when intent was to register via SSO but account existed', async function (ctx) {
  864. ctx.res.render = (pageName, opts) => {
  865. expect(opts.notificationsInstitution).to.deep.include({
  866. email: ctx.institutionEmail,
  867. templateKey: 'notification_institution_sso_already_registered',
  868. })
  869. }
  870. ctx.req.session.saml = {
  871. institutionEmail: ctx.institutionEmail,
  872. linked: {
  873. hasEntitlement: false,
  874. universityName: 'Overleaf',
  875. },
  876. registerIntercept: {
  877. id: 1,
  878. name: 'Example University',
  879. },
  880. }
  881. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  882. })
  883. it('should not show a register notification if the flow was abandoned', async function (ctx) {
  884. // could initially start to register with an SSO email and then
  885. // abandon flow and login with an existing non-institution SSO email
  886. ctx.res.render = (pageName, opts) => {
  887. expect(opts.notificationsInstitution).to.deep.not.include({
  888. email: 'test@overleaf.com',
  889. templateKey: 'notification_institution_sso_already_registered',
  890. })
  891. }
  892. ctx.req.session.saml = {
  893. registerIntercept: {
  894. id: 1,
  895. name: 'Example University',
  896. },
  897. }
  898. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  899. })
  900. it('should show error notification', async function (ctx) {
  901. ctx.res.render = (pageName, opts) => {
  902. expect(opts.notificationsInstitution.length).to.equal(1)
  903. expect(opts.notificationsInstitution[0].templateKey).to.equal(
  904. 'notification_institution_sso_error'
  905. )
  906. expect(opts.notificationsInstitution[0].error).to.be.instanceof(
  907. Errors.SAMLAlreadyLinkedError
  908. )
  909. }
  910. ctx.req.session.saml = {
  911. institutionEmail: ctx.institutionEmail,
  912. error: new Errors.SAMLAlreadyLinkedError(),
  913. }
  914. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  915. })
  916. describe('for an unconfirmed domain for an SSO institution', function () {
  917. beforeEach(function (ctx) {
  918. ctx.UserGetter.promises.getUserFullEmails.resolves([
  919. {
  920. email: 'test@overleaf-uncofirmed.com',
  921. affiliation: {
  922. institution: {
  923. id: 1,
  924. confirmed: false,
  925. name: 'Overleaf',
  926. ssoBeta: false,
  927. ssoEnabled: true,
  928. },
  929. },
  930. },
  931. ])
  932. })
  933. it('should not show institution SSO available notification', async function (ctx) {
  934. ctx.res.render = (pageName, opts) => {
  935. expect(opts.notificationsInstitution.length).to.equal(0)
  936. }
  937. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  938. })
  939. })
  940. describe('when linking/logging in initiated on institution side', function () {
  941. it('should not show a linked another email notification', async function (ctx) {
  942. // this is only used when initated on Overleaf,
  943. // because we keep track of the requested email they tried to link
  944. ctx.res.render = (pageName, opts) => {
  945. expect(opts.notificationsInstitution).to.not.deep.include({
  946. institutionEmail: ctx.institutionEmail,
  947. requestedEmail: undefined,
  948. templateKey: 'notification_institution_sso_non_canonical',
  949. })
  950. }
  951. ctx.req.session.saml = {
  952. emailNonCanonical: ctx.institutionEmail,
  953. institutionEmail: ctx.institutionEmail,
  954. linked: {
  955. hasEntitlement: false,
  956. universityName: ctx.institutionName,
  957. },
  958. }
  959. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  960. })
  961. })
  962. describe('Institution with SSO beta testable', function () {
  963. beforeEach(function (ctx) {
  964. ctx.UserGetter.promises.getUserFullEmails.resolves([
  965. {
  966. email: 'beta@beta.com',
  967. affiliation: {
  968. institution: {
  969. id: 2,
  970. confirmed: true,
  971. name: 'Beta University',
  972. ssoBeta: true,
  973. ssoEnabled: false,
  974. },
  975. },
  976. },
  977. ])
  978. })
  979. it('should show institution SSO available notification when on a beta testing session', async function (ctx) {
  980. ctx.req.session.samlBeta = true
  981. ctx.res.render = (pageName, opts) => {
  982. expect(opts.notificationsInstitution).to.deep.include({
  983. email: 'beta@beta.com',
  984. institutionId: 2,
  985. institutionName: 'Beta University',
  986. templateKey: 'notification_institution_sso_available',
  987. })
  988. }
  989. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  990. })
  991. it('should not show institution SSO available notification when not on a beta testing session', async function (ctx) {
  992. ctx.req.session.samlBeta = false
  993. ctx.res.render = (pageName, opts) => {
  994. expect(opts.notificationsInstitution).to.deep.not.include({
  995. email: 'test@overleaf.com',
  996. institutionId: 1,
  997. institutionName: 'Overleaf',
  998. templateKey: 'notification_institution_sso_available',
  999. })
  1000. }
  1001. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1002. })
  1003. })
  1004. describe('group domain capture enabled for domain', function () {
  1005. it('does not show institution SSO available notification', async function (ctx) {
  1006. ctx.UserGetter.promises.getUserFullEmails.resolves([
  1007. {
  1008. email: 'test@overleaf.com',
  1009. affiliation: {
  1010. group: { domainCaptureEnabled: true },
  1011. institution: {
  1012. id: 1,
  1013. confirmed: true,
  1014. name: 'Overleaf',
  1015. ssoBeta: false,
  1016. ssoEnabled: true,
  1017. },
  1018. },
  1019. },
  1020. ])
  1021. ctx.res.render = (pageName, opts) => {
  1022. expect(opts.notificationsInstitution).to.deep.equal([])
  1023. }
  1024. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1025. })
  1026. })
  1027. })
  1028. describe('Without Institution SSO feature', function () {
  1029. beforeEach(function (ctx) {
  1030. ctx.Features.hasFeature.withArgs('saml').returns(false)
  1031. })
  1032. it('should not show institution sso available notification', async function (ctx) {
  1033. ctx.res.render = (pageName, opts) => {
  1034. expect(opts.notificationsInstitution).to.deep.not.include({
  1035. email: 'test@overleaf.com',
  1036. institutionId: 1,
  1037. institutionName: 'Overleaf',
  1038. templateKey: 'notification_institution_sso_available',
  1039. })
  1040. }
  1041. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1042. })
  1043. })
  1044. describe('enterprise banner', function () {
  1045. beforeEach(function (ctx) {
  1046. ctx.Features.hasFeature.withArgs('saas').returns(true)
  1047. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  1048. { memberGroupSubscriptions: [], managedGroupSubscriptions: [] }
  1049. )
  1050. ctx.UserGetter.promises.getUserFullEmails.resolves([
  1051. {
  1052. email: 'test@test-domain.com',
  1053. },
  1054. ])
  1055. })
  1056. describe('normal enterprise banner', function () {
  1057. it('shows banner', async function (ctx) {
  1058. ctx.res.render = (pageName, opts) => {
  1059. expect(opts.showGroupsAndEnterpriseBanner).to.be.true
  1060. }
  1061. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1062. })
  1063. it('does not show banner if user is part of any affiliation', async function (ctx) {
  1064. ctx.UserGetter.promises.getUserFullEmails.resolves([
  1065. {
  1066. email: 'test@overleaf.com',
  1067. affiliation: {
  1068. licence: 'pro_plus',
  1069. institution: {
  1070. id: 1,
  1071. confirmed: true,
  1072. name: 'Overleaf',
  1073. ssoBeta: false,
  1074. ssoEnabled: true,
  1075. },
  1076. },
  1077. },
  1078. ])
  1079. ctx.res.render = (pageName, opts) => {
  1080. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  1081. }
  1082. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1083. })
  1084. it('does not show banner if user is part of any group subscription', async function (ctx) {
  1085. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  1086. { memberGroupSubscriptions: [{}] }
  1087. )
  1088. ctx.res.render = (pageName, opts) => {
  1089. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  1090. }
  1091. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1092. })
  1093. it('have a banner variant of "FOMO" or "on-premise"', async function (ctx) {
  1094. ctx.res.render = (pageName, opts) => {
  1095. expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([
  1096. 'FOMO',
  1097. 'on-premise',
  1098. ])
  1099. }
  1100. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1101. })
  1102. })
  1103. describe('US government enterprise banner', function () {
  1104. it('does not show enterprise banner if US government enterprise banner is shown', async function (ctx) {
  1105. const emails = [
  1106. {
  1107. email: 'test@test.mil',
  1108. confirmedAt: new Date('2024-01-01'),
  1109. },
  1110. ]
  1111. ctx.UserGetter.promises.getUserFullEmails.resolves(emails)
  1112. ctx.Modules.promises.hooks.fire
  1113. .withArgs('getUSGovBanner', emails, false, [])
  1114. .resolves([
  1115. {
  1116. showUSGovBanner: true,
  1117. usGovBannerVariant: 'variant',
  1118. },
  1119. ])
  1120. ctx.res.render = (pageName, opts) => {
  1121. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  1122. expect(opts.showUSGovBanner).to.be.true
  1123. }
  1124. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1125. })
  1126. })
  1127. })
  1128. })
  1129. describe('projectListReactPage with duplicate projects', function () {
  1130. beforeEach(function (ctx) {
  1131. ctx.projects = [
  1132. { _id: 1, lastUpdated: new Date(1), owner_ref: 'user-1' },
  1133. { _id: 2, lastUpdated: new Date(2), owner_ref: 'user-2' },
  1134. ]
  1135. ctx.readAndWrite = [
  1136. { _id: 5, lastUpdated: new Date(5), owner_ref: 'user-1' },
  1137. ]
  1138. ctx.readOnly = [{ _id: 3, lastUpdated: new Date(3), owner_ref: 'user-1' }]
  1139. ctx.tokenReadAndWrite = [
  1140. { _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' },
  1141. ]
  1142. ctx.tokenReadOnly = [
  1143. { _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' }, // Also in tokenReadAndWrite
  1144. { _id: 7, lastUpdated: new Date(4), owner_ref: 'user-5' },
  1145. ]
  1146. ctx.review = [{ _id: 8, lastUpdated: new Date(5), owner_ref: 'user-6' }]
  1147. ctx.allProjects = {
  1148. owned: ctx.projects,
  1149. readAndWrite: ctx.readAndWrite,
  1150. readOnly: ctx.readOnly,
  1151. tokenReadAndWrite: ctx.tokenReadAndWrite,
  1152. tokenReadOnly: ctx.tokenReadOnly,
  1153. review: ctx.review,
  1154. }
  1155. ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects)
  1156. })
  1157. it('should render the project/list-react page', async function (ctx) {
  1158. ctx.res.render = (pageName, opts) => {
  1159. pageName.should.equal('project/list-react')
  1160. }
  1161. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1162. })
  1163. it('should omit one of the projects', async function (ctx) {
  1164. ctx.res.render = (pageName, opts) => {
  1165. opts.prefetchedProjectsBlob.projects.length.should.equal(
  1166. ctx.projects.length +
  1167. ctx.readAndWrite.length +
  1168. ctx.readOnly.length +
  1169. ctx.tokenReadAndWrite.length +
  1170. ctx.tokenReadOnly.length +
  1171. ctx.review.length -
  1172. 1
  1173. )
  1174. }
  1175. await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  1176. })
  1177. })
  1178. })