ThirdPartyIdentityManagerTests.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const SandboxedModule = require('sandboxed-module')
  4. const OError = require('@overleaf/o-error')
  5. const modulePath =
  6. '../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
  7. describe('ThirdPartyIdentityManager', function () {
  8. beforeEach(function () {
  9. this.userId = 'a1b2c3'
  10. this.user = {
  11. _id: this.userId,
  12. email: 'example@overleaf.com',
  13. }
  14. this.externalUserId = 'id789'
  15. this.externalData = {}
  16. this.auditLog = { initiatorId: this.userId, ipAddress: '0:0:0:0' }
  17. this.ThirdPartyIdentityManager = SandboxedModule.require(modulePath, {
  18. requires: {
  19. '../../../../app/src/Features/User/UserAuditLogHandler':
  20. (this.UserAuditLogHandler = {
  21. promises: {
  22. addEntry: sinon.stub().resolves(),
  23. },
  24. }),
  25. '../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler =
  26. {
  27. promises: {
  28. sendEmail: sinon.stub().resolves(),
  29. },
  30. }),
  31. '../../../../app/src/models/User': {
  32. User: (this.User = {
  33. findOneAndUpdate: sinon
  34. .stub()
  35. .returns({ exec: sinon.stub().resolves(this.user) }),
  36. findOne: sinon.stub().returns({
  37. exec: sinon.stub().resolves(undefined),
  38. }),
  39. }),
  40. },
  41. '@overleaf/settings': {
  42. oauthProviders: {
  43. google: {
  44. name: 'Google',
  45. },
  46. orcid: {
  47. name: 'ORCID',
  48. },
  49. },
  50. },
  51. },
  52. })
  53. })
  54. describe('getUser', function () {
  55. it('should throw an error when missing providerId or externalUserId', async function () {
  56. await expect(
  57. this.ThirdPartyIdentityManager.promises.getUser(undefined, undefined)
  58. ).to.be.rejectedWith(OError, `invalid SSO arguments`)
  59. })
  60. describe('when user linked', function () {
  61. beforeEach(function () {
  62. this.User.findOne.returns({
  63. exec: sinon.stub().resolves(this.user),
  64. })
  65. })
  66. it('should return the user', async function () {
  67. this.User.findOne.returns({
  68. exec: sinon.stub().resolves(this.user),
  69. })
  70. const user = await this.ThirdPartyIdentityManager.promises.getUser(
  71. 'google',
  72. 'an-id-linked'
  73. )
  74. expect(user).to.deep.equal(this.user)
  75. })
  76. })
  77. it('should return ThirdPartyUserNotFoundError when no user linked', function (done) {
  78. this.ThirdPartyIdentityManager.getUser(
  79. 'google',
  80. 'an-id-not-linked',
  81. (error, user) => {
  82. expect(error).to.exist
  83. expect(error.name).to.equal('ThirdPartyUserNotFoundError')
  84. done()
  85. }
  86. )
  87. })
  88. })
  89. describe('link', function () {
  90. it('should send email alert', async function () {
  91. await this.ThirdPartyIdentityManager.promises.link(
  92. this.userId,
  93. 'google',
  94. this.externalUserId,
  95. this.externalData,
  96. this.auditLog
  97. )
  98. const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
  99. expect(emailCall.args[0]).to.equal('securityAlert')
  100. expect(emailCall.args[1].actionDescribed).to.contain(
  101. 'a Google account was linked'
  102. )
  103. })
  104. it('should update user audit log', async function () {
  105. await this.ThirdPartyIdentityManager.promises.link(
  106. this.userId,
  107. 'google',
  108. this.externalUserId,
  109. this.externalData,
  110. this.auditLog
  111. )
  112. expect(
  113. this.UserAuditLogHandler.promises.addEntry
  114. ).to.have.been.calledOnceWith(
  115. this.userId,
  116. 'link-sso',
  117. this.auditLog.initiatorId,
  118. this.auditLog.ipAddress,
  119. {
  120. providerId: 'google',
  121. }
  122. )
  123. })
  124. describe('errors', function () {
  125. const anError = new Error('oops')
  126. it('should not unlink if the UserAuditLogHandler throws an error', async function () {
  127. this.UserAuditLogHandler.promises.addEntry.throws(anError)
  128. await expect(
  129. this.ThirdPartyIdentityManager.promises.link(
  130. this.userId,
  131. 'google',
  132. this.externalUserId,
  133. this.externalData,
  134. this.auditLog
  135. )
  136. ).to.be.rejectedWith(anError)
  137. expect(this.User.findOneAndUpdate).to.not.have.been.called
  138. })
  139. describe('EmailHandler', function () {
  140. beforeEach(function () {
  141. this.EmailHandler.promises.sendEmail.rejects(anError)
  142. })
  143. it('should log but not return the error', async function () {
  144. await expect(
  145. this.ThirdPartyIdentityManager.promises.link(
  146. this.userId,
  147. 'google',
  148. this.externalUserId,
  149. this.externalData,
  150. this.auditLog
  151. )
  152. ).to.be.fulfilled
  153. expect(this.logger.error.lastCall).to.be.calledWithExactly(
  154. {
  155. err: anError,
  156. userId: this.userId,
  157. },
  158. 'could not send security alert email when new account linked'
  159. )
  160. })
  161. })
  162. })
  163. })
  164. describe('unlink', function () {
  165. it('should send email alert', async function () {
  166. await this.ThirdPartyIdentityManager.promises.unlink(
  167. this.userId,
  168. 'orcid',
  169. this.auditLog
  170. )
  171. const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
  172. expect(emailCall.args[0]).to.equal('securityAlert')
  173. expect(emailCall.args[1].actionDescribed).to.contain(
  174. 'an ORCID account was unlinked from'
  175. )
  176. })
  177. it('should update user audit log', async function () {
  178. await this.ThirdPartyIdentityManager.promises.unlink(
  179. this.userId,
  180. 'orcid',
  181. this.auditLog
  182. )
  183. expect(
  184. this.UserAuditLogHandler.promises.addEntry
  185. ).to.have.been.calledOnceWith(
  186. this.userId,
  187. 'unlink-sso',
  188. this.auditLog.initiatorId,
  189. this.auditLog.ipAddress,
  190. {
  191. providerId: 'orcid',
  192. }
  193. )
  194. })
  195. describe('errors', function () {
  196. const anError = new Error('oops')
  197. it('should not unlink if the UserAuditLogHandler throws an error', async function () {
  198. this.UserAuditLogHandler.promises.addEntry.throws(anError)
  199. await expect(
  200. this.ThirdPartyIdentityManager.promises.unlink(
  201. this.userId,
  202. 'orcid',
  203. this.auditLog
  204. )
  205. ).to.be.rejectedWith(anError)
  206. expect(this.User.findOneAndUpdate).to.not.have.been.called
  207. })
  208. describe('EmailHandler', function () {
  209. beforeEach(function () {
  210. this.EmailHandler.promises.sendEmail.rejects(anError)
  211. })
  212. it('should log but not return the error', async function () {
  213. await expect(
  214. this.ThirdPartyIdentityManager.promises.unlink(
  215. this.userId,
  216. 'google',
  217. this.auditLog
  218. )
  219. ).to.be.fulfilled
  220. expect(this.logger.error.lastCall).to.be.calledWithExactly(
  221. {
  222. err: anError,
  223. userId: this.userId,
  224. },
  225. 'could not send security alert email when account no longer linked'
  226. )
  227. })
  228. })
  229. })
  230. })
  231. })