ProjectListController.test.mjs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  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 = new URL(
  7. '../../../../app/src/Features/Project/ProjectListController',
  8. import.meta.url
  9. ).pathname
  10. describe('ProjectListController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.project_id = new ObjectId('abcdefabcdefabcdefabcdef')
  13. ctx.user = {
  14. _id: new ObjectId('123456123456123456123456'),
  15. email: 'test@overleaf.com',
  16. first_name: 'bjkdsjfk',
  17. features: {},
  18. emails: [{ email: 'test@overleaf.com' }],
  19. lastLoginIp: '111.111.111.112',
  20. }
  21. ctx.users = {
  22. 'user-1': {
  23. first_name: 'James',
  24. },
  25. 'user-2': {
  26. first_name: 'Henry',
  27. },
  28. }
  29. ctx.users[ctx.user._id] = ctx.user // Owner
  30. ctx.usersArr = Object.entries(ctx.users).map(([key, value]) => ({
  31. _id: key,
  32. ...value,
  33. }))
  34. ctx.tags = [
  35. { name: 1, project_ids: ['1', '2', '3'] },
  36. { name: 2, project_ids: ['a', '1'] },
  37. { name: 3, project_ids: ['a', 'b', 'c', 'd'] },
  38. ]
  39. ctx.notifications = [
  40. {
  41. _id: '1',
  42. user_id: '2',
  43. templateKey: '3',
  44. messageOpts: '4',
  45. key: '5',
  46. },
  47. ]
  48. ctx.settings = {
  49. siteUrl: 'https://overleaf.com',
  50. }
  51. ctx.TagsHandler = {
  52. promises: {
  53. getAllTags: sinon.stub().resolves(ctx.tags),
  54. },
  55. }
  56. ctx.NotificationsHandler = {
  57. promises: {
  58. getUserNotifications: sinon.stub().resolves(ctx.notifications),
  59. },
  60. }
  61. ctx.UserModel = {
  62. findById: sinon.stub().resolves(ctx.user),
  63. }
  64. ctx.UserPrimaryEmailCheckHandler = {
  65. requiresPrimaryEmailCheck: sinon.stub().returns(false),
  66. }
  67. ctx.ProjectGetter = {
  68. promises: {
  69. findAllUsersProjects: sinon.stub(),
  70. },
  71. }
  72. ctx.ProjectHelper = {
  73. isArchived: sinon.stub(),
  74. isTrashed: sinon.stub(),
  75. }
  76. ctx.SessionManager = {
  77. getLoggedInUserId: sinon.stub().returns(ctx.user._id),
  78. }
  79. ctx.UserController = {
  80. logout: sinon.stub(),
  81. }
  82. ctx.UserGetter = {
  83. promises: {
  84. getUsers: sinon.stub().resolves(ctx.usersArr),
  85. getUserFullEmails: sinon.stub().resolves([]),
  86. },
  87. }
  88. ctx.Features = {
  89. hasFeature: sinon.stub(),
  90. }
  91. ctx.Metrics = {
  92. inc: sinon.stub(),
  93. }
  94. ctx.SplitTestHandler = {
  95. promises: {
  96. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  97. },
  98. }
  99. ctx.SplitTestSessionHandler = {
  100. promises: {
  101. sessionMaintenance: sinon.stub().resolves(),
  102. },
  103. }
  104. ctx.SubscriptionViewModelBuilder = {
  105. promises: {
  106. getUsersSubscriptionDetails: sinon.stub().resolves({
  107. bestSubscription: { type: 'free' },
  108. individualSubscription: null,
  109. memberGroupSubscriptions: [],
  110. }),
  111. },
  112. }
  113. ctx.SurveyHandler = {
  114. promises: {
  115. getSurvey: sinon.stub().resolves({}),
  116. },
  117. }
  118. ctx.NotificationBuilder = {
  119. promises: {
  120. ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
  121. },
  122. }
  123. ctx.GeoIpLookup = {
  124. promises: {
  125. getCurrencyCode: sinon.stub().resolves({
  126. countryCode: 'US',
  127. currencyCode: 'USD',
  128. }),
  129. },
  130. }
  131. ctx.TutorialHandler = {
  132. getInactiveTutorials: sinon.stub().returns([]),
  133. }
  134. ctx.Modules = {
  135. promises: {
  136. hooks: {
  137. fire: sinon.stub().resolves([]),
  138. },
  139. },
  140. }
  141. vi.doMock('mongodb-legacy', () => ({
  142. default: { ObjectId },
  143. }))
  144. vi.doMock('@overleaf/settings', () => ({
  145. default: ctx.settings,
  146. }))
  147. vi.doMock('@overleaf/metrics', () => ({
  148. default: ctx.Metrics,
  149. }))
  150. vi.doMock(
  151. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  152. () => ({
  153. default: ctx.SplitTestHandler,
  154. })
  155. )
  156. vi.doMock(
  157. '../../../../app/src/Features/SplitTests/SplitTestSessionHandler',
  158. () => ({
  159. default: ctx.SplitTestSessionHandler,
  160. })
  161. )
  162. vi.doMock('../../../../app/src/Features/User/UserController', () => ({
  163. default: ctx.UserController,
  164. }))
  165. vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({
  166. default: ctx.ProjectHelper,
  167. }))
  168. vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
  169. default: ctx.TagsHandler,
  170. }))
  171. vi.doMock(
  172. '../../../../app/src/Features/Notifications/NotificationsHandler',
  173. () => ({
  174. default: ctx.NotificationsHandler,
  175. })
  176. )
  177. vi.doMock('../../../../app/src/models/User', () => ({
  178. User: ctx.UserModel,
  179. }))
  180. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  181. default: ctx.ProjectGetter,
  182. }))
  183. vi.doMock(
  184. '../../../../app/src/Features/Authentication/SessionManager',
  185. () => ({
  186. default: ctx.SessionManager,
  187. })
  188. )
  189. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  190. default: ctx.Features,
  191. }))
  192. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  193. default: ctx.UserGetter,
  194. }))
  195. vi.doMock(
  196. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  197. () => ({
  198. default: ctx.SubscriptionViewModelBuilder,
  199. })
  200. )
  201. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  202. default: ctx.Modules,
  203. }))
  204. vi.doMock('../../../../app/src/Features/Survey/SurveyHandler', () => ({
  205. default: ctx.SurveyHandler,
  206. }))
  207. vi.doMock(
  208. '../../../../app/src/Features/User/UserPrimaryEmailCheckHandler',
  209. () => ({
  210. default: ctx.UserPrimaryEmailCheckHandler,
  211. })
  212. )
  213. vi.doMock(
  214. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  215. () => ({
  216. default: ctx.NotificationBuilder,
  217. })
  218. )
  219. vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
  220. default: ctx.GeoIpLookup,
  221. }))
  222. vi.doMock('../../../../app/src/Features/Tutorial/TutorialHandler', () => ({
  223. default: ctx.TutorialHandler,
  224. }))
  225. ctx.ProjectListController = (await import(MODULE_PATH)).default
  226. ctx.req = {
  227. query: {},
  228. params: {
  229. Project_id: ctx.project_id,
  230. },
  231. headers: {},
  232. session: {
  233. user: ctx.user,
  234. },
  235. body: {},
  236. i18n: {
  237. translate() {},
  238. },
  239. }
  240. ctx.res = {}
  241. })
  242. describe('projectListPage', function () {
  243. beforeEach(function (ctx) {
  244. ctx.projects = [
  245. { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
  246. {
  247. _id: 2,
  248. lastUpdated: 2,
  249. owner_ref: 'user-2',
  250. lastUpdatedBy: 'user-1',
  251. },
  252. ]
  253. ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
  254. ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
  255. ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
  256. ctx.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }]
  257. ctx.review = [{ _id: 8, lastUpdated: 4, owner_ref: 'user-6' }]
  258. ctx.allProjects = {
  259. owned: ctx.projects,
  260. readAndWrite: ctx.readAndWrite,
  261. readOnly: ctx.readOnly,
  262. tokenReadAndWrite: ctx.tokenReadAndWrite,
  263. tokenReadOnly: ctx.tokenReadOnly,
  264. review: ctx.review,
  265. }
  266. ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects)
  267. })
  268. it('should render the project/list-react page', function (ctx) {
  269. return new Promise(resolve => {
  270. ctx.res.render = (pageName, opts) => {
  271. pageName.should.equal('project/list-react')
  272. resolve()
  273. }
  274. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  275. })
  276. })
  277. it('should invoke the session maintenance', function (ctx) {
  278. return new Promise(resolve => {
  279. ctx.Features.hasFeature.withArgs('saas').returns(true)
  280. ctx.res.render = () => {
  281. ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
  282. ctx.req,
  283. ctx.user
  284. )
  285. resolve()
  286. }
  287. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  288. })
  289. })
  290. it('should send the tags', function (ctx) {
  291. return new Promise(resolve => {
  292. ctx.res.render = (pageName, opts) => {
  293. opts.tags.length.should.equal(ctx.tags.length)
  294. resolve()
  295. }
  296. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  297. })
  298. })
  299. it('should create trigger ip matcher notifications', function (ctx) {
  300. return new Promise(resolve => {
  301. ctx.settings.overleaf = true
  302. ctx.req.ip = '111.111.111.111'
  303. ctx.res.render = (pageName, opts) => {
  304. ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal(
  305. true
  306. )
  307. resolve()
  308. }
  309. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  310. })
  311. })
  312. it('should send the projects', function (ctx) {
  313. return new Promise(resolve => {
  314. ctx.res.render = (pageName, opts) => {
  315. opts.prefetchedProjectsBlob.projects.length.should.equal(
  316. ctx.projects.length +
  317. ctx.readAndWrite.length +
  318. ctx.readOnly.length +
  319. ctx.tokenReadAndWrite.length +
  320. ctx.tokenReadOnly.length +
  321. ctx.review.length
  322. )
  323. resolve()
  324. }
  325. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  326. })
  327. })
  328. it('should send the user', function (ctx) {
  329. return new Promise(resolve => {
  330. ctx.res.render = (pageName, opts) => {
  331. opts.user.should.deep.equal(ctx.user)
  332. resolve()
  333. }
  334. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  335. })
  336. })
  337. it('should inject the users', function (ctx) {
  338. return new Promise(resolve => {
  339. ctx.res.render = (pageName, opts) => {
  340. const projects = opts.prefetchedProjectsBlob.projects
  341. projects
  342. .filter(p => p.id === '1')[0]
  343. .owner.firstName.should.equal(
  344. ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref]
  345. .first_name
  346. )
  347. projects
  348. .filter(p => p.id === '2')[0]
  349. .owner.firstName.should.equal(
  350. ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref]
  351. .first_name
  352. )
  353. projects
  354. .filter(p => p.id === '2')[0]
  355. .lastUpdatedBy.firstName.should.equal(
  356. ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy]
  357. .first_name
  358. )
  359. resolve()
  360. }
  361. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  362. })
  363. })
  364. it("should send the user's best subscription when saas feature present", function (ctx) {
  365. return new Promise(resolve => {
  366. ctx.Features.hasFeature.withArgs('saas').returns(true)
  367. ctx.res.render = (pageName, opts) => {
  368. expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
  369. resolve()
  370. }
  371. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  372. })
  373. })
  374. it('should not return a best subscription without saas feature', function (ctx) {
  375. return new Promise(resolve => {
  376. ctx.Features.hasFeature.withArgs('saas').returns(false)
  377. ctx.res.render = (pageName, opts) => {
  378. expect(opts.usersBestSubscription).to.be.undefined
  379. resolve()
  380. }
  381. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  382. })
  383. })
  384. it('should show INR Banner for Indian users with free account', function (ctx) {
  385. return new Promise(resolve => {
  386. // usersBestSubscription is only available when saas feature is present
  387. ctx.Features.hasFeature.withArgs('saas').returns(true)
  388. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  389. {
  390. bestSubscription: {
  391. type: 'free',
  392. },
  393. }
  394. )
  395. ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
  396. countryCode: 'IN',
  397. })
  398. ctx.res.render = (pageName, opts) => {
  399. expect(opts.showInrGeoBanner).to.be.true
  400. resolve()
  401. }
  402. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  403. })
  404. })
  405. it('should not show INR Banner for Indian users with premium account', function (ctx) {
  406. return new Promise(resolve => {
  407. // usersBestSubscription is only available when saas feature is present
  408. ctx.Features.hasFeature.withArgs('saas').returns(true)
  409. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  410. {
  411. bestSubscription: {
  412. type: 'individual',
  413. },
  414. }
  415. )
  416. ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
  417. countryCode: 'IN',
  418. })
  419. ctx.res.render = (pageName, opts) => {
  420. expect(opts.showInrGeoBanner).to.be.false
  421. resolve()
  422. }
  423. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  424. })
  425. })
  426. describe('With Institution SSO feature', function () {
  427. beforeEach(function (ctx) {
  428. return new Promise(resolve => {
  429. ctx.institutionEmail = 'test@overleaf.com'
  430. ctx.institutionName = 'Overleaf'
  431. ctx.Features.hasFeature.withArgs('saml').returns(true)
  432. ctx.Features.hasFeature.withArgs('affiliations').returns(true)
  433. ctx.Features.hasFeature.withArgs('saas').returns(true)
  434. resolve()
  435. })
  436. })
  437. it('should show institution SSO available notification for confirmed domains', function (ctx) {
  438. ctx.UserGetter.promises.getUserFullEmails.resolves([
  439. {
  440. email: 'test@overleaf.com',
  441. affiliation: {
  442. institution: {
  443. id: 1,
  444. confirmed: true,
  445. name: 'Overleaf',
  446. ssoBeta: false,
  447. ssoEnabled: true,
  448. },
  449. },
  450. },
  451. ])
  452. ctx.res.render = (pageName, opts) => {
  453. expect(opts.notificationsInstitution).to.deep.include({
  454. email: ctx.institutionEmail,
  455. institutionId: 1,
  456. institutionName: ctx.institutionName,
  457. templateKey: 'notification_institution_sso_available',
  458. })
  459. }
  460. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  461. })
  462. it('should show a linked notification', function (ctx) {
  463. ctx.req.session.saml = {
  464. institutionEmail: ctx.institutionEmail,
  465. linked: {
  466. hasEntitlement: false,
  467. universityName: ctx.institutionName,
  468. },
  469. }
  470. ctx.res.render = (pageName, opts) => {
  471. expect(opts.notificationsInstitution).to.deep.include({
  472. email: ctx.institutionEmail,
  473. institutionName: ctx.institutionName,
  474. templateKey: 'notification_institution_sso_linked',
  475. })
  476. }
  477. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  478. })
  479. it('should show a linked another email notification', function (ctx) {
  480. // when they request to link an email but the institution returns
  481. // a different email
  482. ctx.res.render = (pageName, opts) => {
  483. expect(opts.notificationsInstitution).to.deep.include({
  484. institutionEmail: ctx.institutionEmail,
  485. requestedEmail: 'requested@overleaf.com',
  486. templateKey: 'notification_institution_sso_non_canonical',
  487. })
  488. }
  489. ctx.req.session.saml = {
  490. emailNonCanonical: ctx.institutionEmail,
  491. institutionEmail: ctx.institutionEmail,
  492. requestedEmail: 'requested@overleaf.com',
  493. linked: {
  494. hasEntitlement: false,
  495. universityName: ctx.institutionName,
  496. },
  497. }
  498. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  499. })
  500. it('should show a notification when intent was to register via SSO but account existed', function (ctx) {
  501. ctx.res.render = (pageName, opts) => {
  502. expect(opts.notificationsInstitution).to.deep.include({
  503. email: ctx.institutionEmail,
  504. templateKey: 'notification_institution_sso_already_registered',
  505. })
  506. }
  507. ctx.req.session.saml = {
  508. institutionEmail: ctx.institutionEmail,
  509. linked: {
  510. hasEntitlement: false,
  511. universityName: 'Overleaf',
  512. },
  513. registerIntercept: {
  514. id: 1,
  515. name: 'Example University',
  516. },
  517. }
  518. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  519. })
  520. it('should not show a register notification if the flow was abandoned', function (ctx) {
  521. // could initially start to register with an SSO email and then
  522. // abandon flow and login with an existing non-institution SSO email
  523. ctx.res.render = (pageName, opts) => {
  524. expect(opts.notificationsInstitution).to.deep.not.include({
  525. email: 'test@overleaf.com',
  526. templateKey: 'notification_institution_sso_already_registered',
  527. })
  528. }
  529. ctx.req.session.saml = {
  530. registerIntercept: {
  531. id: 1,
  532. name: 'Example University',
  533. },
  534. }
  535. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  536. })
  537. it('should show error notification', function (ctx) {
  538. ctx.res.render = (pageName, opts) => {
  539. expect(opts.notificationsInstitution.length).to.equal(1)
  540. expect(opts.notificationsInstitution[0].templateKey).to.equal(
  541. 'notification_institution_sso_error'
  542. )
  543. expect(opts.notificationsInstitution[0].error).to.be.instanceof(
  544. Errors.SAMLAlreadyLinkedError
  545. )
  546. }
  547. ctx.req.session.saml = {
  548. institutionEmail: ctx.institutionEmail,
  549. error: new Errors.SAMLAlreadyLinkedError(),
  550. }
  551. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  552. })
  553. describe('for an unconfirmed domain for an SSO institution', function () {
  554. beforeEach(function (ctx) {
  555. return new Promise(resolve => {
  556. ctx.UserGetter.promises.getUserFullEmails.resolves([
  557. {
  558. email: 'test@overleaf-uncofirmed.com',
  559. affiliation: {
  560. institution: {
  561. id: 1,
  562. confirmed: false,
  563. name: 'Overleaf',
  564. ssoBeta: false,
  565. ssoEnabled: true,
  566. },
  567. },
  568. },
  569. ])
  570. resolve()
  571. })
  572. })
  573. it('should not show institution SSO available notification', function (ctx) {
  574. ctx.res.render = (pageName, opts) => {
  575. expect(opts.notificationsInstitution.length).to.equal(0)
  576. }
  577. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  578. })
  579. })
  580. describe('when linking/logging in initiated on institution side', function () {
  581. it('should not show a linked another email notification', function (ctx) {
  582. // this is only used when initated on Overleaf,
  583. // because we keep track of the requested email they tried to link
  584. ctx.res.render = (pageName, opts) => {
  585. expect(opts.notificationsInstitution).to.not.deep.include({
  586. institutionEmail: ctx.institutionEmail,
  587. requestedEmail: undefined,
  588. templateKey: 'notification_institution_sso_non_canonical',
  589. })
  590. }
  591. ctx.req.session.saml = {
  592. emailNonCanonical: ctx.institutionEmail,
  593. institutionEmail: ctx.institutionEmail,
  594. linked: {
  595. hasEntitlement: false,
  596. universityName: ctx.institutionName,
  597. },
  598. }
  599. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  600. })
  601. })
  602. describe('Institution with SSO beta testable', function () {
  603. beforeEach(function (ctx) {
  604. return new Promise(resolve => {
  605. ctx.UserGetter.promises.getUserFullEmails.resolves([
  606. {
  607. email: 'beta@beta.com',
  608. affiliation: {
  609. institution: {
  610. id: 2,
  611. confirmed: true,
  612. name: 'Beta University',
  613. ssoBeta: true,
  614. ssoEnabled: false,
  615. },
  616. },
  617. },
  618. ])
  619. resolve()
  620. })
  621. })
  622. it('should show institution SSO available notification when on a beta testing session', function (ctx) {
  623. ctx.req.session.samlBeta = true
  624. ctx.res.render = (pageName, opts) => {
  625. expect(opts.notificationsInstitution).to.deep.include({
  626. email: 'beta@beta.com',
  627. institutionId: 2,
  628. institutionName: 'Beta University',
  629. templateKey: 'notification_institution_sso_available',
  630. })
  631. }
  632. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  633. })
  634. it('should not show institution SSO available notification when not on a beta testing session', function (ctx) {
  635. ctx.req.session.samlBeta = false
  636. ctx.res.render = (pageName, opts) => {
  637. expect(opts.notificationsInstitution).to.deep.not.include({
  638. email: 'test@overleaf.com',
  639. institutionId: 1,
  640. institutionName: 'Overleaf',
  641. templateKey: 'notification_institution_sso_available',
  642. })
  643. }
  644. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  645. })
  646. })
  647. })
  648. describe('Without Institution SSO feature', function () {
  649. beforeEach(function (ctx) {
  650. return new Promise(resolve => {
  651. ctx.Features.hasFeature.withArgs('saml').returns(false)
  652. resolve()
  653. })
  654. })
  655. it('should not show institution sso available notification', function (ctx) {
  656. ctx.res.render = (pageName, opts) => {
  657. expect(opts.notificationsInstitution).to.deep.not.include({
  658. email: 'test@overleaf.com',
  659. institutionId: 1,
  660. institutionName: 'Overleaf',
  661. templateKey: 'notification_institution_sso_available',
  662. })
  663. }
  664. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  665. })
  666. })
  667. describe('enterprise banner', function () {
  668. beforeEach(function (ctx) {
  669. ctx.Features.hasFeature.withArgs('saas').returns(true)
  670. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  671. { memberGroupSubscriptions: [] }
  672. )
  673. ctx.UserGetter.promises.getUserFullEmails.resolves([
  674. {
  675. email: 'test@test-domain.com',
  676. },
  677. ])
  678. })
  679. describe('normal enterprise banner', function () {
  680. it('shows banner', function (ctx) {
  681. ctx.res.render = (pageName, opts) => {
  682. expect(opts.showGroupsAndEnterpriseBanner).to.be.true
  683. }
  684. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  685. })
  686. it('does not show banner if user is part of any affiliation', function (ctx) {
  687. ctx.UserGetter.promises.getUserFullEmails.resolves([
  688. {
  689. email: 'test@overleaf.com',
  690. affiliation: {
  691. licence: 'pro_plus',
  692. institution: {
  693. id: 1,
  694. confirmed: true,
  695. name: 'Overleaf',
  696. ssoBeta: false,
  697. ssoEnabled: true,
  698. },
  699. },
  700. },
  701. ])
  702. ctx.res.render = (pageName, opts) => {
  703. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  704. }
  705. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  706. })
  707. it('does not show banner if user is part of any group subscription', function (ctx) {
  708. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  709. { memberGroupSubscriptions: [{}] }
  710. )
  711. ctx.res.render = (pageName, opts) => {
  712. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  713. }
  714. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  715. })
  716. it('have a banner variant of "FOMO" or "on-premise"', function (ctx) {
  717. ctx.res.render = (pageName, opts) => {
  718. expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([
  719. 'FOMO',
  720. 'on-premise',
  721. ])
  722. }
  723. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  724. })
  725. })
  726. describe('US government enterprise banner', function () {
  727. it('does not show enterprise banner if US government enterprise banner is shown', function (ctx) {
  728. const emails = [
  729. {
  730. email: 'test@test.mil',
  731. confirmedAt: new Date('2024-01-01'),
  732. },
  733. ]
  734. ctx.UserGetter.promises.getUserFullEmails.resolves(emails)
  735. ctx.Modules.promises.hooks.fire
  736. .withArgs('getUSGovBanner', emails, false, [])
  737. .resolves([
  738. {
  739. showUSGovBanner: true,
  740. usGovBannerVariant: 'variant',
  741. },
  742. ])
  743. ctx.res.render = (pageName, opts) => {
  744. expect(opts.showGroupsAndEnterpriseBanner).to.be.false
  745. expect(opts.showUSGovBanner).to.be.true
  746. }
  747. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  748. })
  749. })
  750. })
  751. })
  752. describe('projectListReactPage with duplicate projects', function () {
  753. beforeEach(function (ctx) {
  754. ctx.projects = [
  755. { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
  756. { _id: 2, lastUpdated: 2, owner_ref: 'user-2' },
  757. ]
  758. ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
  759. ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
  760. ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
  761. ctx.tokenReadOnly = [
  762. { _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite
  763. { _id: 7, lastUpdated: 4, owner_ref: 'user-5' },
  764. ]
  765. ctx.review = [{ _id: 8, lastUpdated: 5, owner_ref: 'user-6' }]
  766. ctx.allProjects = {
  767. owned: ctx.projects,
  768. readAndWrite: ctx.readAndWrite,
  769. readOnly: ctx.readOnly,
  770. tokenReadAndWrite: ctx.tokenReadAndWrite,
  771. tokenReadOnly: ctx.tokenReadOnly,
  772. review: ctx.review,
  773. }
  774. ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects)
  775. })
  776. it('should render the project/list-react page', function (ctx) {
  777. return new Promise(resolve => {
  778. ctx.res.render = (pageName, opts) => {
  779. pageName.should.equal('project/list-react')
  780. resolve()
  781. }
  782. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  783. })
  784. })
  785. it('should omit one of the projects', function (ctx) {
  786. return new Promise(resolve => {
  787. ctx.res.render = (pageName, opts) => {
  788. opts.prefetchedProjectsBlob.projects.length.should.equal(
  789. ctx.projects.length +
  790. ctx.readAndWrite.length +
  791. ctx.readOnly.length +
  792. ctx.tokenReadAndWrite.length +
  793. ctx.tokenReadOnly.length +
  794. ctx.review.length -
  795. 1
  796. )
  797. resolve()
  798. }
  799. ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
  800. })
  801. })
  802. })
  803. })