SAMLIdentityManagerTests.js 14 KB

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