ProjectListController.test.mjs 36 KB

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