SAMLIdentityManagerTests.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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', function () {
  325. const auditLog = {
  326. intiatorId: '6005c75b12cbcaf771f4a105',
  327. ip: '0:0:0:0',
  328. }
  329. 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.ip,
  345. {
  346. institutionEmail: this.user.email,
  347. providerId: '1',
  348. providerName: 'Overleaf University',
  349. }
  350. )
  351. }
  352. )
  353. })
  354. it('should send an email notification', function () {
  355. this.SAMLIdentityManager.linkAccounts(
  356. this.user._id,
  357. 'externalUserId',
  358. this.user.email,
  359. '1',
  360. 'Overleaf University',
  361. undefined,
  362. {
  363. intiatorId: '6005c75b12cbcaf771f4a105',
  364. ipAddress: '0:0:0:0',
  365. },
  366. () => {
  367. expect(this.User.updateOne).to.have.been.called
  368. expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
  369. const emailArgs = this.EmailHandler.sendEmail.lastCall.args
  370. expect(emailArgs[0]).to.equal('securityAlert')
  371. expect(emailArgs[1].to).to.equal(this.user.email)
  372. expect(emailArgs[1].actionDescribed).to.contain('was linked')
  373. expect(emailArgs[1].message[0]).to.contain('Linked')
  374. expect(emailArgs[1].message[0]).to.contain(this.user.email)
  375. }
  376. )
  377. })
  378. })
  379. })
  380. describe('unlinkAccounts', function () {
  381. it('should update the audit log', async function () {
  382. await this.SAMLIdentityManager.unlinkAccounts(
  383. this.user._id,
  384. linkedEmail,
  385. this.user.email,
  386. '1',
  387. 'Overleaf University',
  388. this.auditLog
  389. )
  390. expect(
  391. this.UserAuditLogHandler.promises.addEntry
  392. ).to.have.been.calledOnce.and.calledWithMatch(
  393. this.user._id,
  394. 'unlink-institution-sso',
  395. this.auditLog.initiatorId,
  396. this.auditLog.ipAddress,
  397. {
  398. institutionEmail: linkedEmail,
  399. providerId: '1',
  400. providerName: 'Overleaf University',
  401. }
  402. )
  403. })
  404. it('should remove the identifier', async function () {
  405. await this.SAMLIdentityManager.unlinkAccounts(
  406. this.user._id,
  407. linkedEmail,
  408. this.user.email,
  409. '1',
  410. 'Overleaf University',
  411. this.auditLog
  412. )
  413. const query = {
  414. _id: this.user._id,
  415. }
  416. const update = {
  417. $pull: {
  418. samlIdentifiers: {
  419. providerId: '1',
  420. },
  421. },
  422. }
  423. expect(this.User.updateOne).to.have.been.calledOnce.and.calledWithMatch(
  424. query,
  425. update
  426. )
  427. })
  428. it('should send an email notification', async function () {
  429. await this.SAMLIdentityManager.unlinkAccounts(
  430. this.user._id,
  431. linkedEmail,
  432. this.user.email,
  433. '1',
  434. 'Overleaf University',
  435. this.auditLog
  436. )
  437. expect(this.User.updateOne).to.have.been.called
  438. expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
  439. const emailArgs = this.EmailHandler.sendEmail.lastCall.args
  440. expect(emailArgs[0]).to.equal('securityAlert')
  441. expect(emailArgs[1].to).to.equal(this.user.email)
  442. expect(emailArgs[1].actionDescribed).to.contain('was unlinked')
  443. expect(emailArgs[1].message[0]).to.contain('No longer linked')
  444. expect(emailArgs[1].message[0]).to.contain(linkedEmail)
  445. })
  446. describe('errors', function () {
  447. it('should pass back errors via UserAuditLogHandler', async function () {
  448. let error
  449. const anError = new Error('oops')
  450. this.UserAuditLogHandler.promises.addEntry.rejects(anError)
  451. try {
  452. await this.SAMLIdentityManager.unlinkAccounts(
  453. this.user._id,
  454. linkedEmail,
  455. this.user.email,
  456. '1',
  457. 'Overleaf University',
  458. this.auditLog
  459. )
  460. } catch (e) {
  461. error = e
  462. } finally {
  463. expect(error).to.exist
  464. expect(error).to.equal(anError)
  465. expect(this.EmailHandler.sendEmail).to.not.have.been.called
  466. expect(this.User.updateOne).to.not.have.been.called
  467. }
  468. })
  469. })
  470. })
  471. describe('entitlementAttributeMatches', function () {
  472. it('should return true when entitlement matches on string', function () {
  473. this.SAMLIdentityManager.entitlementAttributeMatches(
  474. 'foo bar',
  475. 'bar'
  476. ).should.equal(true)
  477. })
  478. it('should return false when entitlement does not match on string', function () {
  479. this.SAMLIdentityManager.entitlementAttributeMatches(
  480. 'foo bar',
  481. 'bam'
  482. ).should.equal(false)
  483. })
  484. it('should return false on an invalid matcher', function () {
  485. this.SAMLIdentityManager.entitlementAttributeMatches(
  486. 'foo bar',
  487. '('
  488. ).should.equal(false)
  489. })
  490. it('should log error on an invalid matcher', function () {
  491. this.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
  492. this.logger.error.firstCall.args[0].err.message.should.equal(
  493. 'Invalid regular expression: /(/: Unterminated group'
  494. )
  495. })
  496. it('should return true when entitlement matches on array', function () {
  497. this.SAMLIdentityManager.entitlementAttributeMatches(
  498. ['foo', 'bar'],
  499. 'bar'
  500. ).should.equal(true)
  501. })
  502. it('should return false when entitlement does not match array', function () {
  503. this.SAMLIdentityManager.entitlementAttributeMatches(
  504. ['foo', 'bar'],
  505. 'bam'
  506. ).should.equal(false)
  507. })
  508. })
  509. describe('redundantSubscription', function () {
  510. const userId = '1bv'
  511. const providerId = 123
  512. const providerName = 'University Name'
  513. describe('with a personal subscription', function () {
  514. beforeEach(function () {
  515. this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
  516. {
  517. planCode: 'professional',
  518. }
  519. )
  520. })
  521. it('should create redundant personal subscription notification ', async function () {
  522. try {
  523. await this.SAMLIdentityManager.redundantSubscription(
  524. userId,
  525. providerId,
  526. providerName
  527. )
  528. } catch (error) {
  529. expect(error).to.not.exist
  530. }
  531. expect(this.NotificationsBuilder.promises.redundantPersonalSubscription)
  532. .to.have.been.calledOnce
  533. })
  534. })
  535. describe('without a personal subscription', function () {
  536. it('should create redundant personal subscription notification ', async function () {
  537. try {
  538. await this.SAMLIdentityManager.redundantSubscription(
  539. userId,
  540. providerId,
  541. providerName
  542. )
  543. } catch (error) {
  544. expect(error).to.not.exist
  545. }
  546. expect(this.NotificationsBuilder.promises.redundantPersonalSubscription)
  547. .to.not.have.been.called
  548. })
  549. })
  550. })
  551. })