notifications.test.tsx 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  1. import { expect } from 'chai'
  2. import sinon, { SinonStub } from 'sinon'
  3. import {
  4. fireEvent,
  5. render,
  6. screen,
  7. waitForElementToBeRemoved,
  8. within,
  9. } from '@testing-library/react'
  10. import fetchMock from 'fetch-mock'
  11. import { merge, cloneDeep } from 'lodash'
  12. import {
  13. professionalUserData,
  14. unconfirmedUserData,
  15. untrustedUserData,
  16. unconfirmedCommonsUserData,
  17. confirmedUserData,
  18. } from '../../settings/fixtures/test-user-email-data'
  19. import {
  20. notificationDropboxDuplicateProjectNames,
  21. notificationGroupInviteDefault,
  22. notificationIPMatchedAffiliation,
  23. notificationProjectInvite,
  24. notificationTPDSFileLimit,
  25. notificationsInstitution,
  26. } from '../fixtures/notifications-data'
  27. import Common from '../../../../../frontend/js/features/project-list/components/notifications/groups/common'
  28. import Institution from '../../../../../frontend/js/features/project-list/components/notifications/groups/institution'
  29. import ConfirmEmail, {
  30. getEmailDeletionDate,
  31. } from '../../../../../frontend/js/features/project-list/components/notifications/groups/confirm-email'
  32. import ReconfirmationInfo from '../../../../../frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info'
  33. import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
  34. import { SplitTestProvider } from '@/shared/context/split-test-context'
  35. import {
  36. Notification,
  37. Institution as InstitutionType,
  38. } from '../../../../../types/project/dashboard/notification'
  39. import { DeepPartial } from '../../../../../types/utils'
  40. import { Project } from '../../../../../types/project/dashboard/api'
  41. import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
  42. import GroupSsoSetupSuccess from '../../../../../frontend/js/features/project-list/components/notifications/groups/group-sso-setup-success'
  43. import localStorage from '@/infrastructure/local-storage'
  44. import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
  45. import {
  46. commonsSubscription,
  47. freeSubscription,
  48. groupSubscription,
  49. individualSubscription,
  50. } from '../fixtures/user-subscriptions'
  51. import getMeta from '@/utils/meta'
  52. import * as bootstrapUtils from '@/features/utils/bootstrap-5'
  53. const renderWithinProjectListProvider = (Component: React.ComponentType) => {
  54. render(<Component />, {
  55. wrapper: ({ children }) => (
  56. <ProjectListProvider>
  57. <SplitTestProvider>
  58. <ul className="list-unstyled">{children}</ul>
  59. </SplitTestProvider>
  60. </ProjectListProvider>
  61. ),
  62. })
  63. }
  64. describe('<UserNotifications />', function () {
  65. const exposedSettings = {
  66. samlInitPath: '/fakeSaml/',
  67. appName: 'Overleaf',
  68. }
  69. let isBootstrap5Stub: SinonStub
  70. before(function () {
  71. isBootstrap5Stub = sinon.stub(bootstrapUtils, 'isBootstrap5').returns(true)
  72. })
  73. after(function () {
  74. isBootstrap5Stub.restore()
  75. })
  76. beforeEach(function () {
  77. fetchMock.reset()
  78. // at least one project is required to show some notifications
  79. const projects = [{}] as Project[]
  80. fetchMock.post(/\/api\/project/, {
  81. status: 200,
  82. body: {
  83. projects,
  84. totalSize: projects.length,
  85. },
  86. })
  87. })
  88. afterEach(function () {
  89. fetchMock.reset()
  90. })
  91. describe('<Common>', function () {
  92. beforeEach(function () {
  93. window.metaAttributesCache.set('ol-user', {})
  94. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  95. })
  96. afterEach(function () {
  97. fetchMock.reset()
  98. })
  99. it('accepts project invite', async function () {
  100. const reconfiguredNotification: DeepPartial<Notification> = {
  101. _id: 1,
  102. templateKey: 'notification_project_invite',
  103. }
  104. window.metaAttributesCache.set('ol-notifications', [
  105. merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
  106. ])
  107. renderWithinProjectListProvider(Common)
  108. await fetchMock.flush(true)
  109. const deleteMock = fetchMock.delete(
  110. `/notifications/${reconfiguredNotification._id}`,
  111. 200
  112. )
  113. const acceptMock = fetchMock.post(
  114. `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
  115. 200
  116. )
  117. screen.getByRole('alert')
  118. screen.getByText(/would like you to join/i)
  119. const joinBtn = screen.getByRole('button', {
  120. name: /join project/i,
  121. }) as HTMLButtonElement
  122. expect(joinBtn.disabled).to.be.false
  123. fireEvent.click(joinBtn)
  124. expect(joinBtn.disabled).to.be.true
  125. await waitForElementToBeRemoved(() =>
  126. screen.getByRole('button', { name: /joining/i })
  127. )
  128. expect(acceptMock.called()).to.be.true
  129. screen.getByText(/joined/i)
  130. expect(screen.queryByRole('button', { name: /join project/i })).to.be.null
  131. const openProject = screen.getByRole('button', { name: /open project/i })
  132. expect(openProject.getAttribute('href')).to.equal(
  133. `/project/${notificationProjectInvite.messageOpts.projectId}`
  134. )
  135. const closeBtn = screen.getByRole('button', { name: /close/i })
  136. fireEvent.click(closeBtn)
  137. expect(deleteMock.called()).to.be.true
  138. expect(screen.queryByRole('alert')).to.be.null
  139. })
  140. it('fails to accept project invite', async function () {
  141. const reconfiguredNotification: DeepPartial<Notification> = {
  142. _id: 1,
  143. templateKey: 'notification_project_invite',
  144. }
  145. window.metaAttributesCache.set('ol-notifications', [
  146. merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
  147. ])
  148. renderWithinProjectListProvider(Common)
  149. await fetchMock.flush(true)
  150. fetchMock.post(
  151. `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
  152. 500
  153. )
  154. screen.getByRole('alert')
  155. screen.getByText(/would like you to join/i)
  156. const joinBtn = screen.getByRole('button', {
  157. name: /join project/i,
  158. }) as HTMLButtonElement
  159. fireEvent.click(joinBtn)
  160. await waitForElementToBeRemoved(() =>
  161. screen.getByRole('button', { name: /joining/i })
  162. )
  163. expect(fetchMock.called()).to.be.true
  164. screen.getByRole('button', { name: /join project/i })
  165. expect(screen.queryByRole('button', { name: /open project/i })).to.be.null
  166. })
  167. it('shows WFH2020', async function () {
  168. const reconfiguredNotification: DeepPartial<Notification> = {
  169. _id: 1,
  170. templateKey: 'wfh_2020_upgrade_offer',
  171. }
  172. window.metaAttributesCache.set('ol-notifications', [
  173. merge(reconfiguredNotification),
  174. ])
  175. renderWithinProjectListProvider(Common)
  176. await fetchMock.flush(true)
  177. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  178. screen.getByRole('alert')
  179. screen.getByText(/your free WFH2020 upgrade came to an end on/i)
  180. const viewLink = screen.getByRole('button', { name: /view/i })
  181. expect(viewLink.getAttribute('href')).to.equal(
  182. 'https://www.overleaf.com/events/wfh2020'
  183. )
  184. const closeBtn = screen.getByRole('button', { name: /close/i })
  185. fireEvent.click(closeBtn)
  186. expect(fetchMock.called()).to.be.true
  187. expect(screen.queryByRole('alert')).to.be.null
  188. })
  189. it('shows IP matched affiliation with SSO enabled', async function () {
  190. const reconfiguredNotification: DeepPartial<Notification> = {
  191. _id: 1,
  192. templateKey: 'notification_ip_matched_affiliation',
  193. messageOpts: { ssoEnabled: true },
  194. }
  195. window.metaAttributesCache.set('ol-notifications', [
  196. merge(
  197. cloneDeep(notificationIPMatchedAffiliation),
  198. reconfiguredNotification
  199. ),
  200. ])
  201. renderWithinProjectListProvider(Common)
  202. await fetchMock.flush(true)
  203. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  204. screen.getByRole('alert')
  205. screen.getByText(/it looks like you’re at/i)
  206. screen.getByText(/you can now log in through your institution/i)
  207. screen.getByText(
  208. /link an institutional email address to your account to get started/i
  209. )
  210. const findOutMore = screen.getByRole('link', { name: /find out more/i })
  211. expect(findOutMore.getAttribute('href')).to.equal(
  212. 'https://www.overleaf.com/learn/how-to/Institutional_Login'
  213. )
  214. const linkAccount = screen.getByRole('button', { name: /link account/i })
  215. expect(linkAccount.getAttribute('href')).to.equal(
  216. `${exposedSettings.samlInitPath}?university_id=${notificationIPMatchedAffiliation.messageOpts.institutionId}&auto=/project`
  217. )
  218. const closeBtn = screen.getByRole('button', { name: /close/i })
  219. fireEvent.click(closeBtn)
  220. expect(fetchMock.called()).to.be.true
  221. expect(screen.queryByRole('alert')).to.be.null
  222. })
  223. it('shows IP matched affiliation with SSO disabled', async function () {
  224. const reconfiguredNotification: DeepPartial<Notification> = {
  225. _id: 1,
  226. templateKey: 'notification_ip_matched_affiliation',
  227. messageOpts: { ssoEnabled: false },
  228. }
  229. window.metaAttributesCache.set('ol-notifications', [
  230. merge(
  231. cloneDeep(notificationIPMatchedAffiliation),
  232. reconfiguredNotification
  233. ),
  234. ])
  235. renderWithinProjectListProvider(Common)
  236. await fetchMock.flush(true)
  237. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  238. screen.getByRole('alert')
  239. screen.getByText(/it looks like you’re at/i)
  240. screen.getByText(/did you know that/i)
  241. screen.getByText(
  242. /add an institutional email address to claim your features/i
  243. )
  244. const addAffiliation = screen.getByRole('button', {
  245. name: /add affiliation/i,
  246. })
  247. expect(addAffiliation.getAttribute('href')).to.equal(`/user/settings`)
  248. const closeBtn = screen.getByRole('button', { name: /close/i })
  249. fireEvent.click(closeBtn)
  250. expect(fetchMock.called()).to.be.true
  251. expect(screen.queryByRole('alert')).to.be.null
  252. })
  253. it('shows tpds file limit', async function () {
  254. const reconfiguredNotification: DeepPartial<Notification> = {
  255. templateKey: 'notification_tpds_file_limit',
  256. }
  257. window.metaAttributesCache.set('ol-notifications', [
  258. merge(cloneDeep(notificationTPDSFileLimit), reconfiguredNotification),
  259. ])
  260. renderWithinProjectListProvider(Common)
  261. await fetchMock.flush(true)
  262. screen.getByRole('alert')
  263. screen.getByText(/file limit/i)
  264. screen.getByText(/You can't add more files to the project or sync it/i)
  265. const accountSettings = screen.getByRole('button', {
  266. name: /Open project/i,
  267. })
  268. expect(accountSettings.getAttribute('href')).to.equal('/project/123')
  269. const closeBtn = screen.getByRole('button', { name: /close/i })
  270. fireEvent.click(closeBtn)
  271. expect(screen.queryByRole('alert')).to.be.null
  272. })
  273. it('shows dropbox duplicate project names warning', async function () {
  274. const reconfiguredNotification: DeepPartial<Notification> = {
  275. _id: 1,
  276. templateKey: 'notification_dropbox_duplicate_project_names',
  277. }
  278. window.metaAttributesCache.set('ol-notifications', [
  279. merge(
  280. cloneDeep(notificationDropboxDuplicateProjectNames),
  281. reconfiguredNotification
  282. ),
  283. ])
  284. renderWithinProjectListProvider(Common)
  285. await fetchMock.flush(true)
  286. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  287. screen.getByRole('alert')
  288. screen.getByText(/you have more than one project called/i)
  289. screen.getByText(/make your project names unique/i)
  290. const learnMore = screen.getByRole('link', { name: /learn more/i })
  291. expect(learnMore.getAttribute('href')).to.equal(
  292. '/learn/how-to/Dropbox_Synchronization#Troubleshooting'
  293. )
  294. const closeBtn = screen.getByRole('button', { name: /close/i })
  295. fireEvent.click(closeBtn)
  296. expect(fetchMock.called()).to.be.true
  297. expect(screen.queryByRole('alert')).to.be.null
  298. })
  299. it('shows dropbox unlinked tue to lapsed reconfirmation', async function () {
  300. const reconfiguredNotification: DeepPartial<Notification> = {
  301. _id: 1,
  302. templateKey:
  303. 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
  304. }
  305. window.metaAttributesCache.set('ol-notifications', [
  306. merge(
  307. cloneDeep(notificationDropboxDuplicateProjectNames),
  308. reconfiguredNotification
  309. ),
  310. ])
  311. renderWithinProjectListProvider(Common)
  312. await fetchMock.flush(true)
  313. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  314. screen.getByRole('alert')
  315. screen.getByText(/your Dropbox account has been unlinked/i)
  316. screen.getByText(
  317. /confirm you are still at the institution and on their license, or upgrade your account in order to relink your dropbox account/i
  318. )
  319. const learnMore = screen.getByRole('link', { name: /learn more/i })
  320. expect(learnMore.getAttribute('href')).to.equal(
  321. '/learn/how-to/Institutional_Email_Reconfirmation'
  322. )
  323. const closeBtn = screen.getByRole('button', { name: /close/i })
  324. fireEvent.click(closeBtn)
  325. expect(fetchMock.called()).to.be.true
  326. expect(screen.queryByRole('alert')).to.be.null
  327. })
  328. it('shows non specific notification', async function () {
  329. const reconfiguredNotification: DeepPartial<Notification> = {
  330. _id: 1,
  331. html: 'unspecific message',
  332. }
  333. window.metaAttributesCache.set('ol-notifications', [
  334. reconfiguredNotification,
  335. ])
  336. renderWithinProjectListProvider(Common)
  337. await fetchMock.flush(true)
  338. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  339. screen.getByRole('alert')
  340. screen.getByText(reconfiguredNotification.html!)
  341. const closeBtn = screen.getByRole('button', { name: /close/i })
  342. fireEvent.click(closeBtn)
  343. expect(fetchMock.called()).to.be.true
  344. expect(screen.queryByRole('alert')).to.be.null
  345. })
  346. describe('<GroupInvitation />', function () {
  347. describe('without existing personal subscription', function () {
  348. it('shows group invitation notification for user without personal subscription', async function () {
  349. const notificationGroupInvite: DeepPartial<Notification> = {
  350. _id: 1,
  351. templateKey: 'notification_group_invitation',
  352. }
  353. window.metaAttributesCache.set('ol-notifications', [
  354. merge(
  355. cloneDeep(notificationGroupInviteDefault),
  356. notificationGroupInvite
  357. ),
  358. ])
  359. renderWithinProjectListProvider(Common)
  360. await fetchMock.flush(true)
  361. fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
  362. screen.getByRole('alert')
  363. screen.getByText('inviter@overleaf.com')
  364. screen.getByText(
  365. /has invited you to join a group subscription on Overleaf/
  366. )
  367. screen.getByRole('button', { name: 'Join now' })
  368. screen.getByRole('button', { name: /close/i })
  369. })
  370. describe('with existing personal subscription', function () {
  371. it('shows group invitation notification for user with personal subscription', async function () {
  372. const notificationGroupInvite: DeepPartial<Notification> = {
  373. _id: 1,
  374. templateKey: 'notification_group_invitation',
  375. }
  376. window.metaAttributesCache.set('ol-notifications', [
  377. merge(
  378. cloneDeep(notificationGroupInviteDefault),
  379. notificationGroupInvite
  380. ),
  381. ])
  382. window.metaAttributesCache.set(
  383. 'ol-hasIndividualRecurlySubscription',
  384. true
  385. )
  386. renderWithinProjectListProvider(Common)
  387. await fetchMock.flush(true)
  388. fetchMock.delete(
  389. `/notifications/${notificationGroupInvite._id}`,
  390. 200
  391. )
  392. screen.getByRole('alert')
  393. screen.getByText(
  394. /inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it/
  395. )
  396. screen.getByRole('button', { name: 'Not now' })
  397. screen.getByRole('button', { name: 'Cancel my subscription' })
  398. })
  399. })
  400. })
  401. })
  402. })
  403. describe('<Institution>', function () {
  404. beforeEach(function () {
  405. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  406. fetchMock.reset()
  407. })
  408. afterEach(function () {
  409. fetchMock.reset()
  410. })
  411. it('shows sso available', function () {
  412. const institution: DeepPartial<InstitutionType> = {
  413. templateKey: 'notification_institution_sso_available',
  414. }
  415. window.metaAttributesCache.set('ol-notificationsInstitution', [
  416. { ...notificationsInstitution, ...institution },
  417. ])
  418. render(<Institution />)
  419. screen.getByRole('alert')
  420. screen.getByText(/you can now link/i)
  421. screen.getByText(/doing this will allow you to log in/i)
  422. const learnMore = screen.getByRole('link', { name: /learn more/i })
  423. expect(learnMore.getAttribute('href')).to.equal(
  424. '/learn/how-to/Institutional_Login'
  425. )
  426. const action = screen.getByRole('button', { name: /link account/i })
  427. expect(action.getAttribute('href')).to.equal(
  428. `${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}`
  429. )
  430. })
  431. it('shows sso linked', function () {
  432. const institution: DeepPartial<InstitutionType> = {
  433. _id: 1,
  434. templateKey: 'notification_institution_sso_linked',
  435. }
  436. window.metaAttributesCache.set('ol-notificationsInstitution', [
  437. { ...notificationsInstitution, ...institution },
  438. ])
  439. render(<Institution />)
  440. fetchMock.delete(`/notifications/${institution._id}`, 200)
  441. screen.getByRole('alert')
  442. screen.getByText(/has been linked to your/i)
  443. const closeBtn = screen.getByRole('button', { name: /close/i })
  444. fireEvent.click(closeBtn)
  445. expect(fetchMock.called()).to.be.true
  446. expect(screen.queryByRole('alert')).to.be.null
  447. })
  448. it('shows sso non canonical', function () {
  449. const institution: DeepPartial<InstitutionType> = {
  450. _id: 1,
  451. templateKey: 'notification_institution_sso_non_canonical',
  452. }
  453. window.metaAttributesCache.set('ol-notificationsInstitution', [
  454. { ...notificationsInstitution, ...institution },
  455. ])
  456. render(<Institution />)
  457. fetchMock.delete(`/notifications/${institution._id}`, 200)
  458. screen.getByRole('alert')
  459. screen.getByText(/you’ve tried to log in with/i)
  460. screen.getByText(
  461. /in order to match your institutional metadata, your account is associated with/i
  462. )
  463. const closeBtn = screen.getByRole('button', { name: /close/i })
  464. fireEvent.click(closeBtn)
  465. expect(fetchMock.called()).to.be.true
  466. expect(screen.queryByRole('alert')).to.be.null
  467. })
  468. it('shows sso already registered', function () {
  469. const institution: DeepPartial<InstitutionType> = {
  470. _id: 1,
  471. templateKey: 'notification_institution_sso_already_registered',
  472. }
  473. window.metaAttributesCache.set('ol-notificationsInstitution', [
  474. { ...notificationsInstitution, ...institution },
  475. ])
  476. render(<Institution />)
  477. fetchMock.delete(`/notifications/${institution._id}`, 200)
  478. screen.getByRole('alert')
  479. screen.getByText(/which is already registered with/i)
  480. const action = screen.getByRole('button', { name: /find out more/i })
  481. expect(action.getAttribute('href')).to.equal(
  482. '/learn/how-to/Institutional_Login'
  483. )
  484. const closeBtn = screen.getByRole('button', { name: /close/i })
  485. fireEvent.click(closeBtn)
  486. expect(fetchMock.called()).to.be.true
  487. expect(screen.queryByRole('alert')).to.be.null
  488. })
  489. it('shows sso error', function () {
  490. const institution: DeepPartial<InstitutionType> = {
  491. templateKey: 'notification_institution_sso_error',
  492. error: {
  493. message: 'fake message',
  494. tryAgain: true,
  495. },
  496. }
  497. window.metaAttributesCache.set('ol-notificationsInstitution', [
  498. { ...notificationsInstitution, ...institution },
  499. ])
  500. render(<Institution />)
  501. screen.getByRole('alert')
  502. screen.getByText(/something went wrong/i)
  503. screen.getByText(institution.error!.message!)
  504. screen.getByText(/please try again/i)
  505. const closeBtn = screen.getByRole('button', { name: /close/i })
  506. fireEvent.click(closeBtn)
  507. expect(screen.queryByRole('alert')).to.be.null
  508. })
  509. })
  510. describe('getEmailDeletionDate', function () {
  511. beforeEach(async function () {
  512. window.metaAttributesCache.set('ol-userEmails', [
  513. confirmedUserData,
  514. untrustedUserData,
  515. ])
  516. this.clock = sinon.useFakeTimers(new Date('2025-07-01').getTime())
  517. })
  518. afterEach(function () {
  519. this.clock.restore()
  520. })
  521. it('returns deletion date for unconfirmed email within notification window', function () {
  522. window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
  523. const signUpDate = '2022-01-01' // Before cutoff '2025-03-03'
  524. const emailDeletionDate = getEmailDeletionDate(
  525. unconfirmedUserData,
  526. signUpDate
  527. )
  528. expect(emailDeletionDate).to.equal(
  529. new Date('2025-09-03').toLocaleDateString()
  530. )
  531. })
  532. it('returns false for primary email', function () {
  533. const primaryUserData = { ...unconfirmedUserData, default: true }
  534. const signUpDate = '2022-01-01'
  535. const emailDeletionDate = getEmailDeletionDate(
  536. primaryUserData,
  537. signUpDate
  538. )
  539. expect(emailDeletionDate).to.be.false
  540. })
  541. it('returns false for already confirmed email', function () {
  542. window.metaAttributesCache.set('ol-userEmails', [confirmedUserData])
  543. const signUpDate = '2022-01-01'
  544. const emailDeletionDate = getEmailDeletionDate(
  545. confirmedUserData,
  546. signUpDate
  547. )
  548. expect(emailDeletionDate).to.be.false
  549. })
  550. })
  551. describe('<ConfirmEmail/>', function () {
  552. beforeEach(async function () {
  553. Object.assign(getMeta('ol-ExposedSettings'), {
  554. emailConfirmationDisabled: false,
  555. })
  556. window.metaAttributesCache.set('ol-userEmails', [
  557. confirmedUserData,
  558. untrustedUserData,
  559. ])
  560. window.metaAttributesCache.set(
  561. 'ol-usersBestSubscription',
  562. freeSubscription
  563. )
  564. window.metaAttributesCache.set('ol-user', {
  565. signUpDate: new Date('2024-01-01').toISOString(),
  566. })
  567. this.clock = sinon.useFakeTimers(new Date('2025-07-01').getTime())
  568. })
  569. afterEach(function () {
  570. fetchMock.reset()
  571. this.clock.restore()
  572. })
  573. function testUnconfirmedNotification(
  574. userEmails: any[],
  575. isPrimary: boolean
  576. ) {
  577. it(`sends unconfirmed notification email successfully when email is ${isPrimary ? 'primary' : 'secondary'}`, async function () {
  578. window.metaAttributesCache.set('ol-userEmails', userEmails)
  579. renderWithinProjectListProvider(ConfirmEmail)
  580. await fetchMock.flush(true)
  581. fetchMock.post('/user/emails/resend_confirmation', 200)
  582. const email = userEmails[0].email
  583. const notificationBody = screen.getByTestId('pro-notification-body')
  584. if (isPrimary) {
  585. expect(notificationBody.textContent).to.contain(
  586. `Please confirm your primary email address ${email} by clicking on the link in the confirmation email.`
  587. )
  588. } else {
  589. expect(notificationBody.textContent).to.contain(
  590. `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email.`
  591. )
  592. }
  593. const resendButton = screen.getByRole('button', { name: /resend/i })
  594. fireEvent.click(resendButton)
  595. await waitForElementToBeRemoved(() =>
  596. screen.getByRole('button', { name: /resend/i })
  597. )
  598. expect(fetchMock.called()).to.be.true
  599. expect(screen.queryByRole('alert')).to.be.null
  600. })
  601. }
  602. testUnconfirmedNotification(
  603. [{ email: 'baz@overleaf.com', default: true }],
  604. true
  605. )
  606. testUnconfirmedNotification(
  607. [{ email: 'baz@overleaf.com', default: false }],
  608. false
  609. )
  610. it('sends untrusted notification email successfully', async function () {
  611. window.metaAttributesCache.set('ol-userEmails', [untrustedUserData])
  612. renderWithinProjectListProvider(ConfirmEmail)
  613. await fetchMock.flush(true)
  614. fetchMock.post('/user/emails/resend_confirmation', 200)
  615. const email = untrustedUserData.email
  616. const notificationBody = screen.getByTestId(
  617. 'not-trusted-notification-body'
  618. )
  619. expect(notificationBody.textContent).to.contain(
  620. `To enhance the security of your Overleaf account, please reconfirm your secondary email address ${email}.`
  621. )
  622. const resendButton = screen.getByRole('button', { name: /resend/i })
  623. fireEvent.click(resendButton)
  624. await waitForElementToBeRemoved(() =>
  625. screen.getByRole('button', { name: /resend/i })
  626. )
  627. expect(fetchMock.called()).to.be.true
  628. expect(screen.queryByRole('alert')).to.be.null
  629. })
  630. it('fails to send', async function () {
  631. window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
  632. renderWithinProjectListProvider(ConfirmEmail)
  633. await fetchMock.flush(true)
  634. fetchMock.post('/user/emails/resend_confirmation', 500)
  635. const resendButtons = screen.getAllByRole('button', { name: /resend/i })
  636. const resendButton = resendButtons[0]
  637. fireEvent.click(resendButton)
  638. const notificationBody = screen.getByTestId('pro-notification-body')
  639. await waitForElementToBeRemoved(() =>
  640. within(notificationBody).getByTestId(
  641. 'loading-resending-confirmation-email'
  642. )
  643. )
  644. expect(fetchMock.called()).to.be.true
  645. screen.getByText(/something went wrong/i)
  646. })
  647. for (const subscription of [freeSubscription, individualSubscription]) {
  648. it(`shows commons notification for commons account when user is on ${subscription.type} plan`, async function () {
  649. window.metaAttributesCache.set('ol-userEmails', [
  650. unconfirmedCommonsUserData,
  651. ])
  652. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  653. renderWithinProjectListProvider(ConfirmEmail)
  654. await fetchMock.flush(true)
  655. const alert = screen.getByRole('alert')
  656. const email = unconfirmedCommonsUserData.email
  657. const notificationBody = within(alert).getByTestId('notification-body')
  658. expect(notificationBody.textContent).to.contain(
  659. 'You are one step away from accessing Overleaf Professional features'
  660. )
  661. expect(notificationBody.textContent).to.contain(
  662. `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional`
  663. )
  664. })
  665. }
  666. for (const subscription of [groupSubscription, commonsSubscription]) {
  667. it(`shows default notification for commons account when user is on ${subscription.type} plan`, async function () {
  668. window.metaAttributesCache.set('ol-userEmails', [
  669. unconfirmedCommonsUserData,
  670. ])
  671. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  672. renderWithinProjectListProvider(ConfirmEmail)
  673. await fetchMock.flush(true)
  674. const alert = screen.getByRole('alert')
  675. const email = unconfirmedCommonsUserData.email
  676. const notificationBody = within(alert).getByTestId(
  677. 'pro-notification-body'
  678. )
  679. const isPrimary = unconfirmedCommonsUserData.default
  680. if (isPrimary) {
  681. expect(notificationBody.textContent).to.contain(
  682. `Please confirm your primary email address ${email} by clicking on the link in the confirmation email`
  683. )
  684. } else {
  685. expect(notificationBody.textContent).to.contain(
  686. `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email`
  687. )
  688. }
  689. })
  690. }
  691. })
  692. describe('<Affiliation/>', function () {
  693. let assignStub: sinon.SinonStub
  694. beforeEach(function () {
  695. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  696. assignStub = sinon.stub()
  697. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  698. assign: assignStub,
  699. replace: sinon.stub(),
  700. reload: sinon.stub(),
  701. setHash: sinon.stub(),
  702. })
  703. fetchMock.reset()
  704. })
  705. afterEach(function () {
  706. this.locationStub.restore()
  707. fetchMock.reset()
  708. })
  709. it('shows reconfirm message with SSO disabled', async function () {
  710. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  711. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  712. ])
  713. render(<ReconfirmationInfo />)
  714. screen.getByRole('alert')
  715. screen.getByText(
  716. /take a moment to confirm your institutional email address/i
  717. )
  718. const removeLink = screen.getByRole('link', { name: /remove it/i })
  719. expect(removeLink.getAttribute('href')).to.equal(
  720. `/user/settings?remove=${professionalUserData.email}`
  721. )
  722. const learnMore = screen.getByRole('link', { name: /learn more/i })
  723. expect(learnMore.getAttribute('href')).to.equal(
  724. '/learn/how-to/Institutional_Email_Reconfirmation'
  725. )
  726. const sendReconfirmationMock = fetchMock.post(
  727. '/user/emails/send-reconfirmation',
  728. 200
  729. )
  730. fireEvent.click(
  731. screen.getByRole('button', { name: /confirm affiliation/i })
  732. )
  733. await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
  734. screen.getByText(/check your email inbox to confirm/i)
  735. expect(screen.queryByRole('button', { name: /confirm affiliation/i })).to
  736. .be.null
  737. expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
  738. expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
  739. expect(sendReconfirmationMock.called()).to.be.true
  740. fireEvent.click(
  741. screen.getByRole('button', { name: /resend confirmation email/i })
  742. )
  743. await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
  744. expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
  745. })
  746. it('shows reconfirm message with SSO enabled', async function () {
  747. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  748. merge(cloneDeep(professionalUserData), {
  749. affiliation: { institution: { ssoEnabled: true } },
  750. }),
  751. ])
  752. render(<ReconfirmationInfo />)
  753. fireEvent.click(
  754. screen.getByRole('button', { name: /confirm affiliation/i })
  755. )
  756. sinon.assert.calledOnce(assignStub)
  757. sinon.assert.calledWithMatch(
  758. assignStub,
  759. `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
  760. )
  761. })
  762. it('shows success reconfirmation message', function () {
  763. window.metaAttributesCache.set('ol-userEmails', [
  764. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  765. ])
  766. window.metaAttributesCache.set('ol-reconfirmedViaSAML', 'Saml Provider')
  767. const { rerender } = render(<ReconfirmationInfo />)
  768. screen.getByRole('alert')
  769. screen.getByText(professionalUserData.affiliation!.institution!.name!)
  770. screen.getByText(/affiliation is confirmed/i)
  771. window.metaAttributesCache.set('ol-reconfirmedViaSAML', '')
  772. rerender(<ReconfirmationInfo />)
  773. expect(screen.queryByRole('alert')).to.be.null
  774. })
  775. })
  776. describe('<GroupsAndEnterpriseBanner />', function () {
  777. beforeEach(function () {
  778. localStorage.clear()
  779. fetchMock.reset()
  780. // at least one project is required to show some notifications
  781. const projects = [{}] as Project[]
  782. fetchMock.post(/\/api\/project/, {
  783. status: 200,
  784. body: {
  785. projects,
  786. totalSize: projects.length,
  787. },
  788. })
  789. window.metaAttributesCache.set(
  790. 'ol-groupsAndEnterpriseBannerVariant',
  791. 'on-premise'
  792. )
  793. })
  794. afterEach(function () {
  795. fetchMock.reset()
  796. })
  797. it('does not show the banner for users that are in group or are affiliated', async function () {
  798. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
  799. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  800. await fetchMock.flush(true)
  801. expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
  802. })
  803. it('shows the banner for users that have dismissed the previous banners', async function () {
  804. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  805. localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
  806. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  807. await fetchMock.flush(true)
  808. expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
  809. .null
  810. })
  811. it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
  812. const dismissed = new Date()
  813. dismissed.setDate(dismissed.getDate() - 31) // 31 days
  814. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  815. localStorage.setItem(
  816. 'has_dismissed_groups_and_enterprise_banner',
  817. dismissed
  818. )
  819. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  820. await fetchMock.flush(true)
  821. expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
  822. .null
  823. })
  824. it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () {
  825. const dismissed = new Date()
  826. dismissed.setDate(dismissed.getDate() - 29) // 29 days
  827. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  828. localStorage.setItem(
  829. 'has_dismissed_groups_and_enterprise_banner',
  830. dismissed
  831. )
  832. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  833. await fetchMock.flush(true)
  834. expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
  835. })
  836. describe('users that are not in group and are not affiliated', function () {
  837. beforeEach(function () {
  838. localStorage.clear()
  839. fetchMock.reset()
  840. // at least one project is required to show some notifications
  841. const projects = [{}] as Project[]
  842. fetchMock.post(/\/api\/project/, {
  843. status: 200,
  844. body: {
  845. projects,
  846. totalSize: projects.length,
  847. },
  848. })
  849. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  850. })
  851. afterEach(function () {
  852. fetchMock.reset()
  853. })
  854. after(function () {
  855. localStorage.clear()
  856. })
  857. it('will show the correct text for the `on-premise` variant', async function () {
  858. window.metaAttributesCache.set(
  859. 'ol-groupsAndEnterpriseBannerVariant',
  860. 'on-premise'
  861. )
  862. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  863. await fetchMock.flush(true)
  864. screen.getByText(
  865. 'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.'
  866. )
  867. const link = screen.getByRole('button', { name: 'Contact Sales' })
  868. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`)
  869. })
  870. it('will show the correct text for the `FOMO` variant', async function () {
  871. window.metaAttributesCache.set(
  872. 'ol-groupsAndEnterpriseBannerVariant',
  873. 'FOMO'
  874. )
  875. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  876. await fetchMock.flush(true)
  877. screen.getByText(
  878. 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'
  879. )
  880. const link = screen.getByRole('button', { name: 'Contact Sales' })
  881. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`)
  882. })
  883. })
  884. })
  885. describe('GroupSsoSetupSuccess', function () {
  886. it('shows group SSO linked notification', function () {
  887. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', true)
  888. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  889. screen.getByText('Success! Single sign-on is all set up for you.')
  890. })
  891. it('does not show group SSO linked notification', function () {
  892. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', false)
  893. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  894. expect(
  895. screen.queryByText('Success! Single sign-on is all set up for you.')
  896. ).to.be.null
  897. })
  898. })
  899. })