SAMLIdentityManagerTests.js 18 KB

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