InstitutionsAPI.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import { vi, expect } from 'vitest'
  2. import path from 'node:path'
  3. import sinon from 'sinon'
  4. import mongodb from 'mongodb-legacy'
  5. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  6. const modulePath = path.join(
  7. import.meta.dirname,
  8. '../../../../app/src/Features/Institutions/InstitutionsAPI'
  9. )
  10. const { ObjectId } = mongodb
  11. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  12. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  13. )
  14. describe('InstitutionsAPI', function () {
  15. beforeEach(async function (ctx) {
  16. ctx.settings = {
  17. apis: { v1: { url: 'v1.url', user: '', pass: '', timeout: 5000 } },
  18. }
  19. ctx.request = sinon.stub()
  20. ctx.fetchNothing = sinon.stub()
  21. ctx.ipMatcherNotification = {
  22. read: (ctx.markAsReadIpMatcher = sinon.stub().resolves()),
  23. }
  24. vi.doMock('@overleaf/settings', () => ({
  25. default: ctx.settings,
  26. }))
  27. vi.doMock('requestretry', () => ({
  28. default: ctx.request,
  29. }))
  30. vi.doMock('@overleaf/fetch-utils', () => ({
  31. fetchNothing: ctx.fetchNothing,
  32. fetchJson: (ctx.fetchJson = sinon.stub()),
  33. }))
  34. vi.doMock(
  35. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  36. () => ({
  37. default: {
  38. promises: {
  39. ipMatcherAffiliation: sinon
  40. .stub()
  41. .returns(ctx.ipMatcherNotification),
  42. },
  43. },
  44. })
  45. )
  46. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  47. default: (ctx.Modules = {
  48. promises: {
  49. hooks: {
  50. fire: sinon.stub(),
  51. },
  52. },
  53. }),
  54. }))
  55. ctx.InstitutionsAPI = (await import(modulePath)).default
  56. ctx.stubbedUser = {
  57. _id: '3131231',
  58. name: 'bob',
  59. email: 'hello@world.com',
  60. }
  61. ctx.newEmail = 'bob@bob.com'
  62. })
  63. describe('getInstitutionAffiliations', function () {
  64. it('get affiliations', async function (ctx) {
  65. ctx.institutionId = 123
  66. const responseBody = ['123abc', '456def']
  67. ctx.request.yields(null, { statusCode: 200 }, responseBody)
  68. const body =
  69. await ctx.InstitutionsAPI.promises.getInstitutionAffiliations(
  70. ctx.institutionId
  71. )
  72. ctx.request.calledOnce.should.equal(true)
  73. const requestOptions = ctx.request.lastCall.args[0]
  74. const expectedUrl = `v1.url/api/v2/institutions/${ctx.institutionId}/affiliations`
  75. requestOptions.url.should.equal(expectedUrl)
  76. requestOptions.method.should.equal('GET')
  77. requestOptions.maxAttempts.should.exist
  78. requestOptions.maxAttempts.should.not.equal(0)
  79. requestOptions.retryDelay.should.exist
  80. expect(requestOptions.body).not.to.exist
  81. body.should.equal(responseBody)
  82. })
  83. it('handle empty response', async function (ctx) {
  84. ctx.institutionId = 123
  85. ctx.settings.apis.v1.url = ''
  86. const body =
  87. await ctx.InstitutionsAPI.promises.getInstitutionAffiliations(
  88. ctx.institutionId
  89. )
  90. expect(body).to.be.a('Array')
  91. body.length.should.equal(0)
  92. })
  93. })
  94. describe('getLicencesForAnalytics', function () {
  95. const lag = 'daily'
  96. const queryDate = '2017-01-07:00:00.000Z'
  97. it('should send the request to v1', async function (ctx) {
  98. const v1Result = {
  99. lag: 'daily',
  100. date: queryDate,
  101. data: {
  102. user_counts: { total: [], new: [] },
  103. max_confirmation_months: [],
  104. },
  105. }
  106. ctx.request.callsArgWith(1, null, { statusCode: 201 }, v1Result)
  107. await ctx.InstitutionsAPI.promises.getLicencesForAnalytics(lag, queryDate)
  108. const requestOptions = ctx.request.lastCall.args[0]
  109. expect(requestOptions.body.query_date).to.equal(queryDate)
  110. expect(requestOptions.body.lag).to.equal(lag)
  111. requestOptions.method.should.equal('GET')
  112. })
  113. it('should handle errors', async function (ctx) {
  114. ctx.request.callsArgWith(1, null, { statusCode: 500 })
  115. let error
  116. try {
  117. await ctx.InstitutionsAPI.promises.getLicencesForAnalytics(
  118. lag,
  119. queryDate
  120. )
  121. } catch (err) {
  122. error = err
  123. }
  124. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  125. })
  126. })
  127. describe('getUserAffiliations', function () {
  128. it('get affiliations with commons', async function (ctx) {
  129. const responseBody = [
  130. {
  131. foo: 'bar',
  132. institution: {
  133. commonsAccount: true,
  134. },
  135. },
  136. ]
  137. ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
  138. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  139. ctx.stubbedUser._id
  140. )
  141. ctx.request.calledOnce.should.equal(true)
  142. const requestOptions = ctx.request.lastCall.args[0]
  143. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  144. requestOptions.url.should.equal(expectedUrl)
  145. requestOptions.method.should.equal('GET')
  146. requestOptions.maxAttempts.should.equal(3)
  147. ctx.Modules.promises.hooks.fire.should.have.been.called
  148. expect(requestOptions.body).not.to.exist
  149. expect(body).to.deep.equal(responseBody)
  150. })
  151. it('get affiliations with domain capture for groups', async function (ctx) {
  152. const responseBody = [
  153. {
  154. id: '123abc',
  155. foo: 'bar',
  156. institution: {
  157. commonsAccount: false,
  158. },
  159. },
  160. ]
  161. ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
  162. const groupResponse = {
  163. _id: new ObjectId(),
  164. managedUsersEnabled: false,
  165. domainCaptureEnabled: true,
  166. }
  167. ctx.Modules.promises.hooks.fire
  168. .withArgs(
  169. 'getGroupWithDomainCaptureByV1Id',
  170. responseBody[0].institution.id
  171. )
  172. .resolves([groupResponse])
  173. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  174. ctx.stubbedUser._id
  175. )
  176. ctx.request.calledOnce.should.equal(true)
  177. const requestOptions = ctx.request.lastCall.args[0]
  178. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  179. requestOptions.url.should.equal(expectedUrl)
  180. requestOptions.method.should.equal('GET')
  181. requestOptions.maxAttempts.should.equal(3)
  182. ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
  183. 'getGroupWithDomainCaptureByV1Id',
  184. responseBody[0].institution.id
  185. )
  186. expect(requestOptions.body).not.to.exist
  187. expect(body).to.deep.equal([
  188. {
  189. ...responseBody[0],
  190. group: {
  191. ...groupResponse,
  192. },
  193. },
  194. ])
  195. })
  196. it('handle error', async function (ctx) {
  197. const body = { errors: 'affiliation error message' }
  198. ctx.request.callsArgWith(1, null, { statusCode: 503 }, body)
  199. let error
  200. try {
  201. await ctx.InstitutionsAPI.promises.getUserAffiliations(
  202. ctx.stubbedUser._id
  203. )
  204. } catch (err) {
  205. error = err
  206. }
  207. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  208. })
  209. it('handle empty response', async function (ctx) {
  210. ctx.settings.apis.v1.url = ''
  211. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  212. ctx.stubbedUser._id
  213. )
  214. expect(body).to.be.a('Array')
  215. body.length.should.equal(0)
  216. })
  217. })
  218. describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
  219. it('get the list of users', async function (ctx) {
  220. ctx.fetchJson.resolves({ statusCode: 200 })
  221. await ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
  222. ctx.fetchJson.calledOnce.should.equal(true)
  223. const requestOptions = ctx.fetchJson.lastCall.args[1]
  224. const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
  225. ctx.fetchJson.lastCall.args[0].should.equal(expectedUrl)
  226. requestOptions.method.should.equal('GET')
  227. })
  228. it('handle error', async function (ctx) {
  229. ctx.fetchJson.throws({ info: { statusCode: 500 } })
  230. await expect(
  231. ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
  232. ).to.be.rejected
  233. })
  234. })
  235. describe('addAffiliation', function () {
  236. beforeEach(function (ctx) {
  237. ctx.fetchNothing.resolves({ status: 201 })
  238. })
  239. it('add affiliation', async function (ctx) {
  240. const affiliationOptions = {
  241. university: { id: 1 },
  242. department: 'Math',
  243. role: 'Prof',
  244. confirmedAt: new Date(),
  245. entitlement: true,
  246. }
  247. await ctx.InstitutionsAPI.promises.addAffiliation(
  248. ctx.stubbedUser._id,
  249. ctx.newEmail,
  250. affiliationOptions
  251. )
  252. ctx.fetchNothing.calledOnce.should.equal(true)
  253. const requestOptions = ctx.fetchNothing.lastCall.args[1]
  254. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  255. expect(ctx.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
  256. requestOptions.method.should.equal('POST')
  257. const { json } = requestOptions
  258. Object.keys(json).length.should.equal(7)
  259. expect(json).to.deep.equal(
  260. Object.assign(
  261. { email: ctx.newEmail, rejectIfBlocklisted: undefined },
  262. affiliationOptions
  263. )
  264. )
  265. ctx.markAsReadIpMatcher.calledOnce.should.equal(true)
  266. })
  267. it('handles 422 error', async function (ctx) {
  268. const messageFromApi = 'affiliation error message'
  269. const body = JSON.stringify({ errors: messageFromApi })
  270. ctx.fetchNothing.throws({ response: { status: 422 }, body })
  271. let error
  272. try {
  273. await ctx.InstitutionsAPI.promises.addAffiliation(
  274. ctx.stubbedUser._id,
  275. ctx.newEmail,
  276. {}
  277. )
  278. } catch (err) {
  279. error = err
  280. }
  281. expect(error).to.be.instanceOf(Errors.InvalidInstitutionalEmailError)
  282. expect(error).to.have.property('message', `422: ${messageFromApi}`)
  283. })
  284. it('handles 500 error', async function (ctx) {
  285. const body = { errors: 'affiliation error message' }
  286. ctx.fetchNothing.throws({ response: { status: 500 }, body })
  287. let error
  288. try {
  289. await ctx.InstitutionsAPI.promises.addAffiliation(
  290. ctx.stubbedUser._id,
  291. ctx.newEmail,
  292. {}
  293. )
  294. } catch (err) {
  295. error = err
  296. }
  297. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  298. expect(error.message).to.equal('error getting affiliations from v1')
  299. expect(error.info).to.eql({
  300. status: 500,
  301. body: { errors: 'affiliation error message' },
  302. })
  303. })
  304. it('uses default error message when no error body in response', async function (ctx) {
  305. ctx.fetchNothing.throws({ response: { status: 429 } })
  306. let error
  307. try {
  308. await ctx.InstitutionsAPI.promises.addAffiliation(
  309. ctx.stubbedUser._id,
  310. ctx.newEmail,
  311. {}
  312. )
  313. } catch (err) {
  314. error = err
  315. }
  316. expect(error).to.be.instanceOf(Error)
  317. expect(error).to.have.property(
  318. 'message',
  319. "Couldn't create affiliation: 429"
  320. )
  321. })
  322. it('does not try to mark IP matcher notifications as read if no university passed', async function (ctx) {
  323. const affiliationOptions = {
  324. confirmedAt: new Date(),
  325. }
  326. await ctx.InstitutionsAPI.promises.addAffiliation(
  327. ctx.stubbedUser._id,
  328. ctx.newEmail,
  329. affiliationOptions
  330. )
  331. expect(ctx.markAsReadIpMatcher.callCount).to.equal(0)
  332. })
  333. })
  334. describe('removeAffiliation', function () {
  335. beforeEach(function (ctx) {
  336. ctx.fetchNothing.throws({ response: { status: 404 } })
  337. })
  338. it('remove affiliation', async function (ctx) {
  339. await ctx.InstitutionsAPI.promises.removeAffiliation(
  340. ctx.stubbedUser._id,
  341. ctx.newEmail
  342. )
  343. ctx.fetchNothing.calledOnce.should.equal(true)
  344. const requestOptions = ctx.fetchNothing.lastCall.args[1]
  345. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/remove`
  346. ctx.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
  347. requestOptions.method.should.equal('POST')
  348. expect(requestOptions.json).to.deep.equal({ email: ctx.newEmail })
  349. })
  350. it('handle error', async function (ctx) {
  351. ctx.fetchNothing.throws({ response: { status: 500 } })
  352. let error
  353. try {
  354. await ctx.InstitutionsAPI.promises.removeAffiliation(
  355. ctx.stubbedUser._id,
  356. ctx.newEmail
  357. )
  358. } catch (err) {
  359. error = err
  360. }
  361. expect(error).to.exist
  362. expect(error).to.have.property('message')
  363. })
  364. })
  365. describe('deleteAffiliations', function () {
  366. it('delete affiliations', async function (ctx) {
  367. ctx.request.callsArgWith(1, null, { statusCode: 200 })
  368. await ctx.InstitutionsAPI.promises.deleteAffiliations(ctx.stubbedUser._id)
  369. ctx.request.calledOnce.should.equal(true)
  370. const requestOptions = ctx.request.lastCall.args[0]
  371. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  372. requestOptions.url.should.equal(expectedUrl)
  373. requestOptions.method.should.equal('DELETE')
  374. })
  375. it('handle error', async function (ctx) {
  376. const body = { errors: 'affiliation error message' }
  377. ctx.request.callsArgWith(1, null, { statusCode: 518 }, body)
  378. let error
  379. try {
  380. await ctx.InstitutionsAPI.promises.deleteAffiliations(
  381. ctx.stubbedUser._id
  382. )
  383. } catch (err) {
  384. error = err
  385. }
  386. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  387. })
  388. })
  389. describe('endorseAffiliation', function () {
  390. beforeEach(function (ctx) {
  391. ctx.request.callsArgWith(1, null, { statusCode: 204 })
  392. })
  393. it('endorse affiliation', async function (ctx) {
  394. await ctx.InstitutionsAPI.promises.endorseAffiliation(
  395. ctx.stubbedUser._id,
  396. ctx.newEmail,
  397. 'Student',
  398. 'Physics'
  399. )
  400. ctx.request.calledOnce.should.equal(true)
  401. const requestOptions = ctx.request.lastCall.args[0]
  402. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/endorse`
  403. requestOptions.url.should.equal(expectedUrl)
  404. requestOptions.method.should.equal('POST')
  405. const { body } = requestOptions
  406. Object.keys(body).length.should.equal(3)
  407. body.email.should.equal(ctx.newEmail)
  408. body.role.should.equal('Student')
  409. body.department.should.equal('Physics')
  410. })
  411. })
  412. describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
  413. const users = ['abc123', 'def456']
  414. it('sends the list of users', async function (ctx) {
  415. ctx.request.callsArgWith(1, null, { statusCode: 200 })
  416. await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
  417. users
  418. )
  419. ctx.request.calledOnce.should.equal(true)
  420. const requestOptions = ctx.request.lastCall.args[0]
  421. const expectedUrl =
  422. 'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
  423. requestOptions.url.should.equal(expectedUrl)
  424. requestOptions.method.should.equal('POST')
  425. expect(requestOptions.body).to.deep.equal({ users })
  426. })
  427. it('handle error', async function (ctx) {
  428. ctx.request.callsArgWith(1, null, { statusCode: 500 })
  429. let error
  430. try {
  431. await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
  432. users
  433. )
  434. } catch (err) {
  435. error = err
  436. }
  437. expect(error).to.exist
  438. })
  439. })
  440. })