SAMLIdentityManagerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. const { ObjectId } = require('mongodb')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const SandboxedModule = require('sandboxed-module')
  5. const Errors = require('../../../../app/src/Features/Errors/Errors')
  6. const modulePath = '../../../../app/src/Features/User/SAMLIdentityManager.js'
  7. describe('SAMLIdentityManager', function () {
  8. const linkedEmail = 'another@example.com'
  9. beforeEach(function () {
  10. this.userId = '6005c75b12cbcaf771f4a105'
  11. this.user = {
  12. _id: this.userId,
  13. email: 'not-linked@overleaf.com',
  14. emails: [{ email: 'not-linked@overleaf.com' }],
  15. samlIdentifiers: [],
  16. }
  17. this.auditLog = {
  18. initiatorId: this.userId,
  19. ipAddress: '0:0:0:0',
  20. }
  21. this.userAlreadyLinked = {
  22. _id: '6005c7a012cbcaf771f4a106',
  23. email: 'linked@overleaf.com',
  24. emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
  25. samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
  26. }
  27. this.userEmailExists = {
  28. _id: '6005c7a012cbcaf771f4a107',
  29. email: 'exists@overleaf.com',
  30. emails: [{ email: 'exists@overleaf.com' }],
  31. samlIdentifiers: [],
  32. }
  33. this.institution = {
  34. name: 'Overleaf University',
  35. }
  36. this.InstitutionsAPI = {
  37. promises: {
  38. addEntitlement: sinon.stub().resolves(),
  39. removeEntitlement: sinon.stub().resolves(),
  40. },
  41. }
  42. this.SAMLIdentityManager = SandboxedModule.require(modulePath, {
  43. requires: {
  44. '../Email/EmailHandler': (this.EmailHandler = {
  45. sendEmail: sinon.stub().yields(),
  46. }),
  47. '../Notifications/NotificationsBuilder': (this.NotificationsBuilder = {
  48. promises: {
  49. redundantPersonalSubscription: sinon
  50. .stub()
  51. .returns({ create: sinon.stub().resolves() }),
  52. },
  53. }),
  54. '../Subscription/SubscriptionLocator': (this.SubscriptionLocator = {
  55. promises: {
  56. getUserIndividualSubscription: sinon.stub().resolves(),
  57. },
  58. }),
  59. '../../models/User': {
  60. User: (this.User = {
  61. findOneAndUpdate: sinon.stub().returns({
  62. exec: sinon.stub().resolves(this.user),
  63. }),
  64. findOne: sinon.stub().returns({
  65. exec: sinon.stub().resolves(),
  66. }),
  67. updateOne: sinon.stub().returns({
  68. exec: sinon.stub().resolves(),
  69. }),
  70. }),
  71. },
  72. '../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
  73. promises: {
  74. addEntry: sinon.stub().resolves(),
  75. },
  76. }),
  77. '../User/UserGetter': (this.UserGetter = {
  78. getUser: sinon.stub(),
  79. promises: {
  80. getUser: sinon.stub().resolves(this.user),
  81. getUserByAnyEmail: sinon.stub().resolves(),
  82. getUserFullEmails: sinon.stub().resolves(),
  83. },
  84. }),
  85. '../User/UserUpdater': (this.UserUpdater = {
  86. addEmailAddress: sinon.stub(),
  87. promises: {
  88. addEmailAddress: sinon.stub().resolves(),
  89. confirmEmail: sinon.stub().resolves(),
  90. updateUser: sinon.stub().resolves(),
  91. },
  92. }),
  93. '../Institutions/InstitutionsAPI': this.InstitutionsAPI,
  94. },
  95. })
  96. })
  97. describe('getUser', function () {
  98. it('should throw an error if missing provider ID and/or external user ID', async function () {
  99. let error
  100. try {
  101. await this.SAMLIdentityManager.getUser(null, null)
  102. } catch (e) {
  103. error = e
  104. } finally {
  105. expect(error).to.exist
  106. }
  107. })
  108. })
  109. describe('linkAccounts', function () {
  110. describe('errors', function () {
  111. beforeEach(function () {
  112. // first call is to get userWithProvider; should be falsy
  113. this.UserGetter.promises.getUser.onFirstCall().resolves()
  114. this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
  115. })
  116. it('should throw an error if missing data', async function () {
  117. let error
  118. try {
  119. await this.SAMLIdentityManager.linkAccounts(
  120. null,
  121. null,
  122. null,
  123. null,
  124. null
  125. )
  126. } catch (e) {
  127. error = e
  128. } finally {
  129. expect(error).to.exist
  130. }
  131. })
  132. describe('when email is already associated with another Overleaf account', function () {
  133. beforeEach(function () {
  134. this.UserGetter.promises.getUserByAnyEmail.resolves(
  135. this.userEmailExists
  136. )
  137. })
  138. it('should throw an EmailExistsError error', async function () {
  139. let error
  140. try {
  141. await this.SAMLIdentityManager.linkAccounts(
  142. '6005c75b12cbcaf771f4a105',
  143. 'not-linked-id',
  144. 'exists@overleaf.com',
  145. 'provider-id',
  146. 'provider-name',
  147. true,
  148. {
  149. intiatorId: '6005c75b12cbcaf771f4a105',
  150. ip: '0:0:0:0',
  151. }
  152. )
  153. } catch (e) {
  154. error = e
  155. } finally {
  156. expect(error).to.be.instanceof(Errors.EmailExistsError)
  157. expect(this.User.findOneAndUpdate).to.not.have.been.called
  158. }
  159. })
  160. })
  161. describe('when email is not affiliated', function () {
  162. beforeEach(function () {
  163. this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
  164. this.UserGetter.promises.getUserFullEmails.resolves([
  165. {
  166. email: 'not-affiliated@overleaf.com',
  167. },
  168. ])
  169. })
  170. it('should throw SAMLEmailNotAffiliatedError', async function () {
  171. let error
  172. try {
  173. await this.SAMLIdentityManager.linkAccounts(
  174. '6005c75b12cbcaf771f4a105',
  175. 'not-linked-id',
  176. 'not-affiliated@overleaf.com',
  177. 'provider-id',
  178. 'provider-name',
  179. true,
  180. {
  181. intiatorId: 'user-id-1',
  182. ip: '0:0:0:0',
  183. }
  184. )
  185. } catch (e) {
  186. error = e
  187. } finally {
  188. expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
  189. expect(this.User.findOneAndUpdate).to.not.have.been.called
  190. }
  191. })
  192. })
  193. describe('when email is affiliated with another institution', function () {
  194. beforeEach(function () {
  195. this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
  196. this.UserGetter.promises.getUserFullEmails.resolves([
  197. {
  198. email: 'affiliated@overleaf.com',
  199. affiliation: { institution: { id: '987' } },
  200. },
  201. ])
  202. })
  203. it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function () {
  204. let error
  205. try {
  206. await this.SAMLIdentityManager.linkAccounts(
  207. '6005c75b12cbcaf771f4a105',
  208. 'not-linked-id',
  209. 'affiliated@overleaf.com',
  210. 'provider-id',
  211. 'provider-name',
  212. true,
  213. {
  214. intiatorId: 'user-id-1',
  215. ip: '0:0:0:0',
  216. }
  217. )
  218. } catch (e) {
  219. error = e
  220. } finally {
  221. expect(error).to.be.instanceof(
  222. Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
  223. )
  224. expect(this.User.findOneAndUpdate).to.not.have.been.called
  225. }
  226. })
  227. })
  228. describe('when institution identifier is already associated with another Overleaf account', function () {
  229. beforeEach(function () {
  230. this.UserGetter.promises.getUserByAnyEmail.resolves(
  231. this.userAlreadyLinked
  232. )
  233. })
  234. it('should throw an SAMLIdentityExistsError error', async function () {
  235. let error
  236. try {
  237. await this.SAMLIdentityManager.linkAccounts(
  238. '6005c75b12cbcaf771f4a105',
  239. 'already-linked-id',
  240. 'linked@overleaf.com',
  241. 'provider-id',
  242. 'provider-name',
  243. true,
  244. {
  245. intiatorId: '6005c75b12cbcaf771f4a105',
  246. ip: '0:0:0:0',
  247. }
  248. )
  249. } catch (e) {
  250. error = e
  251. } finally {
  252. expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
  253. expect(this.User.findOneAndUpdate).to.not.have.been.called
  254. }
  255. })
  256. })
  257. describe('when institution provider is already associated with the user', function () {
  258. beforeEach(function () {
  259. // first call is to get userWithProvider; resolves with any user
  260. this.UserGetter.promises.getUser.onFirstCall().resolves(this.user)
  261. })
  262. it('should throw an SAMLAlreadyLinkedError error', async function () {
  263. let error
  264. try {
  265. await this.SAMLIdentityManager.linkAccounts(
  266. '6005c75b12cbcaf771f4a105',
  267. 'already-linked-id',
  268. 'linked@overleaf.com',
  269. 123456,
  270. 'provider-name',
  271. true,
  272. {
  273. intiatorId: '6005c75b12cbcaf771f4a105',
  274. ip: '0:0:0:0',
  275. }
  276. )
  277. } catch (e) {
  278. error = e
  279. } finally {
  280. expect(
  281. this.UserGetter.promises.getUser
  282. ).to.have.been.calledWithMatch({
  283. _id: ObjectId('6005c75b12cbcaf771f4a105'),
  284. 'samlIdentifiers.providerId': '123456',
  285. })
  286. expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError)
  287. expect(this.User.findOneAndUpdate).to.not.have.been.called
  288. }
  289. })
  290. })
  291. it('should pass back errors via UserAuditLogHandler', async function () {
  292. let error
  293. const anError = new Error('oops')
  294. this.UserAuditLogHandler.promises.addEntry.rejects(anError)
  295. try {
  296. await this.SAMLIdentityManager.linkAccounts(
  297. this.user._id,
  298. 'externalUserId',
  299. this.user.email,
  300. '1',
  301. 'Overleaf University',
  302. undefined,
  303. {
  304. intiatorId: '6005c75b12cbcaf771f4a105',
  305. ipAddress: '0:0:0:0',
  306. }
  307. )
  308. } catch (e) {
  309. error = e
  310. } finally {
  311. expect(error).to.exist
  312. expect(error).to.equal(anError)
  313. expect(this.EmailHandler.sendEmail).to.not.have.been.called
  314. expect(this.User.updateOne).to.not.have.been.called
  315. }
  316. })
  317. })
  318. describe('success', function () {
  319. beforeEach(function () {
  320. // first call is to get userWithProvider; should be falsy
  321. this.UserGetter.promises.getUser.onFirstCall().resolves()
  322. this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
  323. })
  324. it('should update the user audit log', async function () {
  325. const auditLog = {
  326. initiatorId: '6005c75b12cbcaf771f4a105',
  327. ipAddress: '0:0:0:0',
  328. }
  329. await this.SAMLIdentityManager.linkAccounts(
  330. this.user._id,
  331. 'externalUserId',
  332. this.user.email,
  333. '1',
  334. 'Overleaf University',
  335. undefined,
  336. auditLog
  337. )
  338. expect(
  339. this.UserAuditLogHandler.promises.addEntry
  340. ).to.have.been.calledWith(
  341. this.user._id,
  342. 'link-institution-sso',
  343. auditLog.initiatorId,
  344. auditLog.ipAddress,
  345. {
  346. institutionEmail: this.user.email,
  347. providerId: '1',
  348. providerName: 'Overleaf University',
  349. }
  350. )
  351. })
  352. it('should send an email notification', async function () {
  353. await this.SAMLIdentityManager.linkAccounts(
  354. this.user._id,
  355. 'externalUserId',
  356. this.user.email,
  357. '1',
  358. 'Overleaf University',
  359. undefined,
  360. {
  361. intiatorId: '6005c75b12cbcaf771f4a105',
  362. ipAddress: '0:0:0:0',
  363. }
  364. )
  365. expect(this.User.findOneAndUpdate).to.have.been.called
  366. expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
  367. const emailArgs = this.EmailHandler.sendEmail.lastCall.args
  368. expect(emailArgs[0]).to.equal('securityAlert')
  369. expect(emailArgs[1].to).to.equal(this.user.email)
  370. expect(emailArgs[1].actionDescribed).to.contain('was linked')
  371. expect(emailArgs[1].message[0]).to.contain('Linked')
  372. expect(emailArgs[1].message[0]).to.contain(this.user.email)
  373. })
  374. })
  375. })
  376. describe('unlinkAccounts', function () {
  377. it('should update the audit log', async function () {
  378. await this.SAMLIdentityManager.unlinkAccounts(
  379. this.user._id,
  380. linkedEmail,
  381. this.user.email,
  382. '1',
  383. 'Overleaf University',
  384. this.auditLog
  385. )
  386. expect(
  387. this.UserAuditLogHandler.promises.addEntry
  388. ).to.have.been.calledOnce.and.calledWithMatch(
  389. this.user._id,
  390. 'unlink-institution-sso',
  391. this.auditLog.initiatorId,
  392. this.auditLog.ipAddress,
  393. {
  394. institutionEmail: linkedEmail,
  395. providerId: '1',
  396. providerName: 'Overleaf University',
  397. }
  398. )
  399. })
  400. it('should remove the identifier', async function () {
  401. await this.SAMLIdentityManager.unlinkAccounts(
  402. this.user._id,
  403. linkedEmail,
  404. this.user.email,
  405. '1',
  406. 'Overleaf University',
  407. this.auditLog
  408. )
  409. const query = {
  410. _id: this.user._id,
  411. }
  412. const update = {
  413. $pull: {
  414. samlIdentifiers: {
  415. providerId: '1',
  416. },
  417. },
  418. }
  419. expect(this.User.updateOne).to.have.been.calledOnce.and.calledWithMatch(
  420. query,
  421. update
  422. )
  423. })
  424. it('should send an email notification', async function () {
  425. await this.SAMLIdentityManager.unlinkAccounts(
  426. this.user._id,
  427. linkedEmail,
  428. this.user.email,
  429. '1',
  430. 'Overleaf University',
  431. this.auditLog
  432. )
  433. expect(this.User.updateOne).to.have.been.called
  434. expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
  435. const emailArgs = this.EmailHandler.sendEmail.lastCall.args
  436. expect(emailArgs[0]).to.equal('securityAlert')
  437. expect(emailArgs[1].to).to.equal(this.user.email)
  438. expect(emailArgs[1].actionDescribed).to.contain('was unlinked')
  439. expect(emailArgs[1].message[0]).to.contain('No longer linked')
  440. expect(emailArgs[1].message[0]).to.contain(linkedEmail)
  441. })
  442. describe('errors', function () {
  443. it('should pass back errors via UserAuditLogHandler', async function () {
  444. let error
  445. const anError = new Error('oops')
  446. this.UserAuditLogHandler.promises.addEntry.rejects(anError)
  447. try {
  448. await this.SAMLIdentityManager.unlinkAccounts(
  449. this.user._id,
  450. linkedEmail,
  451. this.user.email,
  452. '1',
  453. 'Overleaf University',
  454. this.auditLog
  455. )
  456. } catch (e) {
  457. error = e
  458. } finally {
  459. expect(error).to.exist
  460. expect(error).to.equal(anError)
  461. expect(this.EmailHandler.sendEmail).to.not.have.been.called
  462. expect(this.User.updateOne).to.not.have.been.called
  463. }
  464. })
  465. })
  466. })
  467. describe('entitlementAttributeMatches', function () {
  468. it('should return true when entitlement matches on string', function () {
  469. this.SAMLIdentityManager.entitlementAttributeMatches(
  470. 'foo bar',
  471. 'bar'
  472. ).should.equal(true)
  473. })
  474. it('should return false when entitlement does not match on string', function () {
  475. this.SAMLIdentityManager.entitlementAttributeMatches(
  476. 'foo bar',
  477. 'bam'
  478. ).should.equal(false)
  479. })
  480. it('should return false on an invalid matcher', function () {
  481. this.SAMLIdentityManager.entitlementAttributeMatches(
  482. 'foo bar',
  483. '('
  484. ).should.equal(false)
  485. })
  486. it('should log error on an invalid matcher', function () {
  487. this.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
  488. this.logger.error.firstCall.args[0].err.message.should.equal(
  489. 'Invalid regular expression: /(/: Unterminated group'
  490. )
  491. })
  492. it('should return true when entitlement matches on array', function () {
  493. this.SAMLIdentityManager.entitlementAttributeMatches(
  494. ['foo', 'bar'],
  495. 'bar'
  496. ).should.equal(true)
  497. })
  498. it('should return false when entitlement does not match array', function () {
  499. this.SAMLIdentityManager.entitlementAttributeMatches(
  500. ['foo', 'bar'],
  501. 'bam'
  502. ).should.equal(false)
  503. })
  504. })
  505. describe('redundantSubscription', function () {
  506. const userId = '1bv'
  507. const providerId = 123
  508. const providerName = 'University Name'
  509. describe('with a personal subscription', function () {
  510. beforeEach(function () {
  511. this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
  512. {
  513. planCode: 'professional',
  514. }
  515. )
  516. })
  517. it('should create redundant personal subscription notification ', async function () {
  518. try {
  519. await this.SAMLIdentityManager.redundantSubscription(
  520. userId,
  521. providerId,
  522. providerName
  523. )
  524. } catch (error) {
  525. expect(error).to.not.exist
  526. }
  527. expect(this.NotificationsBuilder.promises.redundantPersonalSubscription)
  528. .to.have.been.calledOnce
  529. })
  530. })
  531. describe('with a group subscription', function () {
  532. beforeEach(function () {
  533. this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
  534. {
  535. planCode: 'professional',
  536. groupPlan: true,
  537. }
  538. )
  539. })
  540. it('should create redundant personal subscription notification ', async function () {
  541. try {
  542. await this.SAMLIdentityManager.redundantSubscription(
  543. userId,
  544. providerId,
  545. providerName
  546. )
  547. } catch (error) {
  548. expect(error).to.not.exist
  549. }
  550. expect(this.NotificationsBuilder.promises.redundantPersonalSubscription)
  551. .to.not.have.been.called
  552. })
  553. })
  554. describe('without a personal subscription', function () {
  555. it('should not create redundant personal subscription notification ', async function () {
  556. try {
  557. await this.SAMLIdentityManager.redundantSubscription(
  558. userId,
  559. providerId,
  560. providerName
  561. )
  562. } catch (error) {
  563. expect(error).to.not.exist
  564. }
  565. expect(this.NotificationsBuilder.promises.redundantPersonalSubscription)
  566. .to.not.have.been.called
  567. })
  568. })
  569. })
  570. })