SAMLIdentityManager.test.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  1. import { vi, expect } from 'vitest'
  2. import mongodb from 'mongodb-legacy'
  3. import sinon from 'sinon'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. const { ObjectId } = mongodb
  6. const modulePath = '../../../../app/src/Features/User/SAMLIdentityManager.mjs'
  7. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  8. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  9. )
  10. describe('SAMLIdentityManager', function () {
  11. const linkedEmail = 'another@example.com'
  12. beforeEach(async function (ctx) {
  13. ctx.userId = '6005c75b12cbcaf771f4a105'
  14. ctx.user = {
  15. _id: ctx.userId,
  16. email: 'not-linked@overleaf.com',
  17. emails: [{ email: 'not-linked@overleaf.com' }],
  18. samlIdentifiers: [],
  19. }
  20. ctx.auditLog = {
  21. initiatorId: ctx.userId,
  22. ipAddress: '0:0:0:0',
  23. }
  24. ctx.userAlreadyLinked = {
  25. _id: '6005c7a012cbcaf771f4a106',
  26. email: 'linked@overleaf.com',
  27. emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
  28. samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
  29. }
  30. ctx.userEmailExists = {
  31. _id: '6005c7a012cbcaf771f4a107',
  32. email: 'exists@overleaf.com',
  33. emails: [{ email: 'exists@overleaf.com' }],
  34. samlIdentifiers: [],
  35. }
  36. ctx.institution = {
  37. name: 'Overleaf University',
  38. }
  39. ctx.InstitutionsAPI = {
  40. promises: {
  41. addEntitlement: sinon.stub().resolves(),
  42. removeEntitlement: sinon.stub().resolves(),
  43. },
  44. }
  45. ctx.logger = {
  46. error: sinon.stub(),
  47. }
  48. vi.doMock('@overleaf/logger', () => ({
  49. default: ctx.logger,
  50. }))
  51. vi.doMock('mongodb-legacy', () => ({
  52. default: { ObjectId },
  53. }))
  54. vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
  55. default: (ctx.EmailHandler = {
  56. sendEmail: sinon.stub().yields(),
  57. }),
  58. }))
  59. vi.doMock(
  60. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  61. () => ({
  62. default: (ctx.NotificationsBuilder = {
  63. promises: {
  64. redundantPersonalSubscription: sinon
  65. .stub()
  66. .returns({ create: sinon.stub().resolves() }),
  67. },
  68. }),
  69. })
  70. )
  71. vi.doMock(
  72. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  73. () => ({
  74. default: (ctx.SubscriptionLocator = {
  75. promises: {
  76. getUserIndividualSubscription: sinon.stub().resolves(),
  77. },
  78. }),
  79. })
  80. )
  81. vi.doMock('../../../../app/src/models/User', () => ({
  82. User: (ctx.User = {
  83. findOneAndUpdate: sinon.stub().returns({
  84. exec: sinon.stub().resolves(ctx.user),
  85. }),
  86. findOne: sinon.stub().returns({
  87. exec: sinon.stub().resolves(),
  88. }),
  89. updateOne: sinon.stub().returns({
  90. exec: sinon.stub().resolves(),
  91. }),
  92. }),
  93. }))
  94. vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
  95. default: (ctx.UserAuditLogHandler = {
  96. promises: {
  97. addEntry: sinon.stub().resolves(),
  98. },
  99. }),
  100. }))
  101. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  102. default: (ctx.UserGetter = {
  103. getUser: sinon.stub(),
  104. promises: {
  105. getUser: sinon.stub().resolves(ctx.user),
  106. getUserByAnyEmail: sinon.stub().resolves(),
  107. getUserFullEmails: sinon.stub().resolves(),
  108. },
  109. }),
  110. }))
  111. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  112. default: (ctx.UserUpdater = {
  113. addEmailAddress: sinon.stub(),
  114. promises: {
  115. addEmailAddress: sinon.stub().resolves(),
  116. confirmEmail: sinon.stub().resolves(),
  117. updateUser: sinon.stub().resolves(),
  118. },
  119. }),
  120. }))
  121. vi.doMock(
  122. '../../../../app/src/Features/Institutions/InstitutionsAPI',
  123. () => ({
  124. default: ctx.InstitutionsAPI,
  125. })
  126. )
  127. ctx.SAMLIdentityManager = (await import(modulePath)).default
  128. })
  129. describe('getUser', function () {
  130. it('should throw an error if missing all of: provider ID, external user ID, attribute', async function (ctx) {
  131. let error
  132. try {
  133. await ctx.SAMLIdentityManager.getUser(undefined, undefined, undefined)
  134. } catch (e) {
  135. error = e
  136. } finally {
  137. expect(error).to.exist
  138. expect(error.message).to.equal('invalid arguments')
  139. expect(error.info).to.deep.equal({
  140. providerId: undefined,
  141. externalUserId: undefined,
  142. userIdAttribute: undefined,
  143. })
  144. }
  145. })
  146. it('should throw an error if missing provider ID', async function (ctx) {
  147. let error
  148. try {
  149. await ctx.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
  150. } catch (e) {
  151. error = e
  152. } finally {
  153. expect(error).to.exist
  154. expect(error.message).to.equal('invalid arguments')
  155. expect(error.info).to.deep.equal({
  156. providerId: undefined,
  157. externalUserId: 'id123',
  158. userIdAttribute: 'someAttr',
  159. })
  160. }
  161. })
  162. it('should throw an error if missing external user ID', async function (ctx) {
  163. let error
  164. try {
  165. await ctx.SAMLIdentityManager.getUser('123', null, 'someAttr')
  166. } catch (e) {
  167. error = e
  168. } finally {
  169. expect(error).to.exist
  170. expect(error.message).to.equal('invalid arguments')
  171. expect(error.info).to.deep.equal({
  172. providerId: '123',
  173. externalUserId: null,
  174. userIdAttribute: 'someAttr',
  175. })
  176. }
  177. })
  178. it('should throw an error if missing attribute', async function (ctx) {
  179. let error
  180. try {
  181. await ctx.SAMLIdentityManager.getUser('123', 'id123', undefined)
  182. } catch (e) {
  183. error = e
  184. } finally {
  185. expect(error).to.exist
  186. expect(error.message).to.equal('invalid arguments')
  187. expect(error.info).to.deep.equal({
  188. providerId: '123',
  189. externalUserId: 'id123',
  190. userIdAttribute: undefined,
  191. })
  192. }
  193. })
  194. })
  195. describe('linkAccounts', function () {
  196. describe('errors', function () {
  197. beforeEach(function (ctx) {
  198. // first call is to get userWithProvider; should be falsy
  199. ctx.UserGetter.promises.getUser.onFirstCall().resolves()
  200. ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
  201. })
  202. it('should throw an error if missing all data', async function (ctx) {
  203. let error
  204. try {
  205. await ctx.SAMLIdentityManager.linkAccounts(null, null, null)
  206. } catch (e) {
  207. error = e
  208. } finally {
  209. expect(error).to.exist
  210. }
  211. })
  212. describe('linking data', function () {
  213. const requiredData = {
  214. externalUserId: 'someUniqueId',
  215. institutionEmail: 'user@example.com',
  216. providerId: '123',
  217. userIdAttribute: 'attribute',
  218. }
  219. for (const [data] of Object.entries(requiredData)) {
  220. const testData = { ...requiredData }
  221. delete testData[data]
  222. let error
  223. it(`should throw an error when missing ${data}`, async function (ctx) {
  224. try {
  225. await ctx.SAMLIdentityManager.linkAccounts('123', testData, {})
  226. } catch (e) {
  227. error = e
  228. } finally {
  229. expect(error).to.exist
  230. expect(error.message).to.contain(
  231. 'missing data when linking institution SSO'
  232. )
  233. }
  234. })
  235. }
  236. })
  237. describe('when email is already associated with another Overleaf account', function () {
  238. beforeEach(function (ctx) {
  239. ctx.UserGetter.promises.getUserByAnyEmail.resolves(
  240. ctx.userEmailExists
  241. )
  242. })
  243. it('should throw an EmailExistsError error', async function (ctx) {
  244. let error
  245. try {
  246. await ctx.SAMLIdentityManager.linkAccounts(
  247. '6005c75b12cbcaf771f4a105',
  248. {
  249. externalUserId: 'not-linked-id',
  250. institutionEmail: 'exists@overleaf.com',
  251. universityId: 'provider-id',
  252. universityName: 'provider-name',
  253. hasEntitlement: true,
  254. userIdAttribute: 'someAttribute',
  255. },
  256. {
  257. intiatorId: '6005c75b12cbcaf771f4a105',
  258. ip: '0:0:0:0',
  259. }
  260. )
  261. } catch (e) {
  262. error = e
  263. } finally {
  264. expect(error).to.be.instanceof(Errors.EmailExistsError)
  265. expect(ctx.User.findOneAndUpdate).to.not.have.been.called
  266. }
  267. })
  268. })
  269. describe('when email is not affiliated', function () {
  270. beforeEach(function (ctx) {
  271. ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
  272. ctx.UserGetter.promises.getUserFullEmails.resolves([
  273. {
  274. email: 'not-affiliated@overleaf.com',
  275. },
  276. ])
  277. })
  278. it('should throw SAMLEmailNotAffiliatedError', async function (ctx) {
  279. let error
  280. try {
  281. await ctx.SAMLIdentityManager.linkAccounts(
  282. '6005c75b12cbcaf771f4a105',
  283. {
  284. externalUserId: 'not-linked-id',
  285. institutionEmail: 'not-affiliated@overleaf.com',
  286. universityId: 'provider-id',
  287. universityName: 'provider-name',
  288. hasEntitlement: true,
  289. userIdAttribute: 'someAttribute',
  290. },
  291. {
  292. intiatorId: 'user-id-1',
  293. ip: '0:0:0:0',
  294. }
  295. )
  296. } catch (e) {
  297. error = e
  298. } finally {
  299. expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
  300. expect(ctx.User.findOneAndUpdate).to.not.have.been.called
  301. }
  302. })
  303. })
  304. describe('when email is affiliated with another institution', function () {
  305. beforeEach(function (ctx) {
  306. ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
  307. ctx.UserGetter.promises.getUserFullEmails.resolves([
  308. {
  309. email: 'affiliated@overleaf.com',
  310. affiliation: { institution: { id: '987' } },
  311. },
  312. ])
  313. })
  314. it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function (ctx) {
  315. let error
  316. try {
  317. await ctx.SAMLIdentityManager.linkAccounts(
  318. '6005c75b12cbcaf771f4a105',
  319. {
  320. externalUserId: 'not-linked-id',
  321. institutionEmail: 'affiliated@overleaf.com',
  322. universityId: 'provider-id',
  323. universityName: 'provider-name',
  324. hasEntitlement: true,
  325. userIdAttribute: 'someAttribute',
  326. },
  327. {
  328. intiatorId: 'user-id-1',
  329. ip: '0:0:0:0',
  330. }
  331. )
  332. } catch (e) {
  333. error = e
  334. } finally {
  335. expect(error).to.be.instanceof(
  336. Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
  337. )
  338. expect(ctx.User.findOneAndUpdate).to.not.have.been.called
  339. }
  340. })
  341. })
  342. describe('when institution identifier is already associated with another Overleaf account', function () {
  343. beforeEach(function (ctx) {
  344. ctx.UserGetter.promises.getUserByAnyEmail.resolves(
  345. ctx.userAlreadyLinked
  346. )
  347. })
  348. it('should throw an SAMLIdentityExistsError error', async function (ctx) {
  349. let error
  350. try {
  351. await ctx.SAMLIdentityManager.linkAccounts(
  352. '6005c75b12cbcaf771f4a105',
  353. {
  354. externalUserId: 'already-linked-id',
  355. institutionEmail: 'linked@overleaf.com',
  356. universityId: 'provider-id',
  357. universityName: 'provider-name',
  358. hasEntitlement: true,
  359. userIdAttribute: 'someAttribute',
  360. },
  361. {
  362. intiatorId: '6005c75b12cbcaf771f4a105',
  363. ip: '0:0:0:0',
  364. }
  365. )
  366. } catch (e) {
  367. error = e
  368. } finally {
  369. expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
  370. expect(ctx.User.findOneAndUpdate).to.not.have.been.called
  371. }
  372. })
  373. })
  374. describe('when institution provider is already associated with the user', function () {
  375. beforeEach(function (ctx) {
  376. // first call is to get userWithProvider; resolves with any user
  377. ctx.UserGetter.promises.getUser.onFirstCall().resolves(ctx.user)
  378. })
  379. it('should throw an SAMLAlreadyLinkedError error', async function (ctx) {
  380. let error
  381. try {
  382. await ctx.SAMLIdentityManager.linkAccounts(
  383. '6005c75b12cbcaf771f4a105',
  384. {
  385. externalUserId: 'already-linked-id',
  386. institutionEmail: 'linked@overleaf.com',
  387. universityId: 123456,
  388. universityName: 'provider-name',
  389. hasEntitlement: true,
  390. userIdAttribute: 'someAttribute',
  391. },
  392. {
  393. intiatorId: '6005c75b12cbcaf771f4a105',
  394. ip: '0:0:0:0',
  395. }
  396. )
  397. } catch (e) {
  398. error = e
  399. } finally {
  400. expect(
  401. ctx.UserGetter.promises.getUser
  402. ).to.have.been.calledWithMatch({
  403. _id: new ObjectId('6005c75b12cbcaf771f4a105'),
  404. 'samlIdentifiers.providerId': '123456',
  405. })
  406. expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError)
  407. expect(ctx.User.findOneAndUpdate).to.not.have.been.called
  408. }
  409. })
  410. })
  411. it('should pass back errors via UserAuditLogHandler', async function (ctx) {
  412. let error
  413. const anError = new Error('oops')
  414. ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
  415. try {
  416. await ctx.SAMLIdentityManager.linkAccounts(
  417. ctx.user._id,
  418. {
  419. externalUserId: 'externalUserId',
  420. institutionEmail: ctx.user.email,
  421. universityId: '1',
  422. universityName: 'Overleaf University',
  423. hasEntitlement: false,
  424. userIdAttribute: 'someAttribute',
  425. },
  426. {
  427. intiatorId: '6005c75b12cbcaf771f4a105',
  428. ipAddress: '0:0:0:0',
  429. }
  430. )
  431. } catch (e) {
  432. error = e
  433. } finally {
  434. expect(error).to.exist
  435. expect(error).to.equal(anError)
  436. expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
  437. expect(ctx.User.updateOne).to.not.have.been.called
  438. }
  439. })
  440. })
  441. describe('success', function () {
  442. beforeEach(function (ctx) {
  443. // first call is to get userWithProvider; should be falsy
  444. ctx.UserGetter.promises.getUser.onFirstCall().resolves()
  445. ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
  446. })
  447. it('should update the user audit log', async function (ctx) {
  448. const auditLog = {
  449. initiatorId: '6005c75b12cbcaf771f4a105',
  450. ipAddress: '0:0:0:0',
  451. }
  452. await ctx.SAMLIdentityManager.linkAccounts(
  453. ctx.user._id,
  454. {
  455. externalUserId: 'externalUserId',
  456. institutionEmail: ctx.user.email,
  457. universityId: '1',
  458. universityName: 'Overleaf University',
  459. hasEntitlement: false,
  460. userIdAttribute: 'uniqueId',
  461. },
  462. auditLog
  463. )
  464. expect(
  465. ctx.UserAuditLogHandler.promises.addEntry
  466. ).to.have.been.calledWith(
  467. ctx.user._id,
  468. 'link-institution-sso',
  469. auditLog.initiatorId,
  470. auditLog.ipAddress,
  471. {
  472. institutionEmail: ctx.user.email,
  473. providerId: '1',
  474. providerName: 'Overleaf University',
  475. userIdAttribute: 'uniqueId',
  476. externalUserId: 'externalUserId',
  477. }
  478. )
  479. })
  480. it('should send an email notification', async function (ctx) {
  481. await ctx.SAMLIdentityManager.linkAccounts(
  482. ctx.user._id,
  483. {
  484. externalUserId: 'externalUserId',
  485. institutionEmail: ctx.user.email,
  486. universityId: '1',
  487. universityName: 'Overleaf University',
  488. hasEntitlement: false,
  489. userIdAttribute: 'someAttribute',
  490. },
  491. {
  492. intiatorId: '6005c75b12cbcaf771f4a105',
  493. ipAddress: '0:0:0:0',
  494. }
  495. )
  496. expect(ctx.User.findOneAndUpdate).to.have.been.called
  497. expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
  498. const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
  499. expect(emailArgs[0]).to.equal('securityAlert')
  500. expect(emailArgs[1].to).to.equal(ctx.user.email)
  501. expect(emailArgs[1].actionDescribed).to.contain('was linked')
  502. expect(emailArgs[1].message[0]).to.contain('Linked')
  503. expect(emailArgs[1].message[0]).to.contain(ctx.user.email)
  504. })
  505. })
  506. })
  507. describe('unlinkAccounts', function () {
  508. it('should update the audit log', async function (ctx) {
  509. await ctx.SAMLIdentityManager.unlinkAccounts(
  510. ctx.user._id,
  511. linkedEmail,
  512. ctx.user.email,
  513. '1',
  514. 'Overleaf University',
  515. ctx.auditLog
  516. )
  517. expect(
  518. ctx.UserAuditLogHandler.promises.addEntry
  519. ).to.have.been.calledOnce.and.calledWithMatch(
  520. ctx.user._id,
  521. 'unlink-institution-sso',
  522. ctx.auditLog.initiatorId,
  523. ctx.auditLog.ipAddress,
  524. {
  525. institutionEmail: linkedEmail,
  526. providerId: '1',
  527. providerName: 'Overleaf University',
  528. }
  529. )
  530. })
  531. it('should remove the identifier with a single update', async function (ctx) {
  532. await ctx.SAMLIdentityManager.unlinkAccounts(
  533. ctx.user._id,
  534. linkedEmail,
  535. ctx.user.email,
  536. '1',
  537. 'Overleaf University',
  538. ctx.auditLog
  539. )
  540. expect(ctx.User.updateOne).to.have.been.calledOnce
  541. expect(ctx.User.updateOne.getCall(0)).to.have.been.calledWithMatch(
  542. { _id: ctx.user._id },
  543. {
  544. $pull: {
  545. samlIdentifiers: { providerId: '1' },
  546. },
  547. $unset: {
  548. 'emails.$[email].samlProviderId': 1,
  549. },
  550. },
  551. {
  552. arrayFilters: [{ 'email.samlProviderId': '1' }],
  553. }
  554. )
  555. })
  556. it('should send an email notification', async function (ctx) {
  557. await ctx.SAMLIdentityManager.unlinkAccounts(
  558. ctx.user._id,
  559. linkedEmail,
  560. ctx.user.email,
  561. '1',
  562. 'Overleaf University',
  563. ctx.auditLog
  564. )
  565. expect(ctx.User.updateOne).to.have.been.called
  566. expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
  567. const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
  568. expect(emailArgs[0]).to.equal('securityAlert')
  569. expect(emailArgs[1].to).to.equal(ctx.user.email)
  570. expect(emailArgs[1].actionDescribed).to.contain('was unlinked')
  571. expect(emailArgs[1].message[0]).to.contain('No longer linked')
  572. expect(emailArgs[1].message[0]).to.contain(linkedEmail)
  573. })
  574. describe('errors', function () {
  575. it('should pass back errors via UserAuditLogHandler', async function (ctx) {
  576. let error
  577. const anError = new Error('oops')
  578. ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
  579. try {
  580. await ctx.SAMLIdentityManager.unlinkAccounts(
  581. ctx.user._id,
  582. linkedEmail,
  583. ctx.user.email,
  584. '1',
  585. 'Overleaf University',
  586. ctx.auditLog
  587. )
  588. } catch (e) {
  589. error = e
  590. } finally {
  591. expect(error).to.exist
  592. expect(error).to.equal(anError)
  593. expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
  594. expect(ctx.User.updateOne).to.not.have.been.called
  595. }
  596. })
  597. })
  598. })
  599. describe('entitlementAttributeMatches', function () {
  600. it('should return true when entitlement matches on string', function (ctx) {
  601. ctx.SAMLIdentityManager.entitlementAttributeMatches(
  602. 'foo bar',
  603. 'bar'
  604. ).should.equal(true)
  605. })
  606. it('should return false when entitlement does not match on string', function (ctx) {
  607. ctx.SAMLIdentityManager.entitlementAttributeMatches(
  608. 'foo bar',
  609. 'bam'
  610. ).should.equal(false)
  611. })
  612. it('should return false on an invalid matcher', function (ctx) {
  613. ctx.SAMLIdentityManager.entitlementAttributeMatches(
  614. 'foo bar',
  615. '('
  616. ).should.equal(false)
  617. })
  618. it('should log error on an invalid matcher', function (ctx) {
  619. ctx.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
  620. ctx.logger.error.firstCall.args[0].err.message.should.equal(
  621. 'Invalid regular expression: /(/: Unterminated group'
  622. )
  623. })
  624. it('should return true when entitlement matches on array', function (ctx) {
  625. ctx.SAMLIdentityManager.entitlementAttributeMatches(
  626. ['foo', 'bar'],
  627. 'bar'
  628. ).should.equal(true)
  629. })
  630. it('should return false when entitlement does not match array', function (ctx) {
  631. ctx.SAMLIdentityManager.entitlementAttributeMatches(
  632. ['foo', 'bar'],
  633. 'bam'
  634. ).should.equal(false)
  635. })
  636. })
  637. describe('migrateIdentifier', function () {
  638. const userId = '5efb8b6e9b647b0027e4c0b0'
  639. const externalUserId = '987zyx'
  640. const providerId = 123
  641. const hasEntitlement = false
  642. const institutionEmail = 'someone@email.com'
  643. const providerName = 'Example University'
  644. const auditLog = {
  645. initiatorId: userId,
  646. ipAddress: '0.0.0.0',
  647. migration: {
  648. from: 'uniqueId',
  649. to: 'newUniqueId',
  650. },
  651. }
  652. const userIdAttribute = 'newUniqueId'
  653. it('should remove the old identifier and add the new identifier', async function (ctx) {
  654. ctx.UserGetter.promises.getUser.resolves()
  655. ctx.UserGetter.promises.getUserByAnyEmail
  656. .withArgs(institutionEmail)
  657. .resolves({ _id: userId, emails: [{ email: institutionEmail }] })
  658. ctx.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
  659. {
  660. email: institutionEmail,
  661. affiliation: { institution: { id: providerId } },
  662. },
  663. ])
  664. await ctx.SAMLIdentityManager.migrateIdentifier(
  665. userId,
  666. externalUserId,
  667. providerId,
  668. hasEntitlement,
  669. institutionEmail,
  670. providerName,
  671. auditLog,
  672. userIdAttribute
  673. )
  674. expect(ctx.User.updateOne).to.have.been.calledOnce
  675. const query = {
  676. _id: userId,
  677. 'samlIdentifiers.providerId': providerId.toString(),
  678. }
  679. const update = {
  680. $set: {
  681. 'samlIdentifiers.$.externalUserId': externalUserId,
  682. 'samlIdentifiers.$.userIdAttribute': userIdAttribute,
  683. },
  684. }
  685. expect(ctx.User.updateOne.lastCall.args).to.deep.equal([query, update])
  686. })
  687. })
  688. describe('unlinkNotMigrated', function () {
  689. const userId = '5efb8b6e9b647b0027e4c0b0'
  690. const providerId = '123'
  691. const institutionEmail = 'someone@email.com'
  692. const providerName = 'Example University'
  693. const auditLog = {
  694. ipAddress: 'N/A',
  695. }
  696. it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function (ctx) {
  697. ctx.User.findOneAndUpdate = sinon.stub().returns({
  698. exec: sinon.stub().resolves({
  699. _id: userId,
  700. emails: [{ email: institutionEmail, samlProviderId: providerId }],
  701. }),
  702. })
  703. await ctx.SAMLIdentityManager.unlinkNotMigrated(
  704. userId,
  705. providerId,
  706. providerName,
  707. auditLog
  708. )
  709. expect(ctx.User.findOneAndUpdate).to.have.been.calledOnce
  710. const query = {
  711. _id: userId,
  712. 'emails.samlProviderId': providerId,
  713. }
  714. const update = {
  715. $pull: {
  716. samlIdentifiers: {
  717. providerId,
  718. },
  719. },
  720. $unset: {
  721. 'emails.$.samlProviderId': 1,
  722. },
  723. }
  724. expect(ctx.User.findOneAndUpdate.lastCall.args).to.deep.equal([
  725. query,
  726. update,
  727. ])
  728. expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
  729. expect(
  730. ctx.UserAuditLogHandler.promises.addEntry.lastCall.args
  731. ).to.deep.equal([
  732. userId,
  733. 'unlink-institution-sso-not-migrated',
  734. undefined,
  735. 'N/A',
  736. { providerId, providerName },
  737. ])
  738. expect(ctx.InstitutionsAPI.promises.removeEntitlement).to.have.been
  739. .calledOnce
  740. expect(
  741. ctx.InstitutionsAPI.promises.removeEntitlement.lastCall.args
  742. ).to.deep.equal([userId, institutionEmail])
  743. })
  744. })
  745. })