InstitutionsAPI.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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. confirmed: true,
  135. },
  136. },
  137. ]
  138. ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
  139. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  140. ctx.stubbedUser._id
  141. )
  142. ctx.request.calledOnce.should.equal(true)
  143. const requestOptions = ctx.request.lastCall.args[0]
  144. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  145. requestOptions.url.should.equal(expectedUrl)
  146. requestOptions.method.should.equal('GET')
  147. requestOptions.maxAttempts.should.equal(3)
  148. ctx.Modules.promises.hooks.fire.should.have.been.called
  149. expect(requestOptions.body).not.to.exist
  150. expect(body).to.deep.equal(responseBody)
  151. })
  152. it('get affiliations with domain capture for groups', async function (ctx) {
  153. const responseBody = [
  154. {
  155. id: '123abc',
  156. foo: 'bar',
  157. institution: {
  158. commonsAccount: false,
  159. confirmed: true,
  160. },
  161. },
  162. ]
  163. ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
  164. const groupResponse = {
  165. _id: new ObjectId(),
  166. managedUsersEnabled: false,
  167. domainCaptureEnabled: true,
  168. }
  169. ctx.Modules.promises.hooks.fire
  170. .withArgs(
  171. 'getGroupWithDomainCaptureByV1Id',
  172. responseBody[0].institution.id
  173. )
  174. .resolves([groupResponse])
  175. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  176. ctx.stubbedUser._id
  177. )
  178. ctx.request.calledOnce.should.equal(true)
  179. const requestOptions = ctx.request.lastCall.args[0]
  180. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  181. requestOptions.url.should.equal(expectedUrl)
  182. requestOptions.method.should.equal('GET')
  183. requestOptions.maxAttempts.should.equal(3)
  184. ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
  185. 'getGroupWithDomainCaptureByV1Id',
  186. responseBody[0].institution.id
  187. )
  188. expect(requestOptions.body).not.to.exist
  189. expect(body).to.deep.equal([
  190. {
  191. ...responseBody[0],
  192. group: {
  193. ...groupResponse,
  194. },
  195. },
  196. ])
  197. })
  198. it('does not query for group with domain capture if domain is not confirmed', async function (ctx) {
  199. const responseBody = [
  200. {
  201. id: '123abc',
  202. foo: 'bar',
  203. institution: {
  204. commonsAccount: false,
  205. confirmed: false,
  206. },
  207. },
  208. ]
  209. ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
  210. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  211. ctx.stubbedUser._id
  212. )
  213. ctx.request.calledOnce.should.equal(true)
  214. const requestOptions = ctx.request.lastCall.args[0]
  215. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  216. requestOptions.url.should.equal(expectedUrl)
  217. requestOptions.method.should.equal('GET')
  218. requestOptions.maxAttempts.should.equal(3)
  219. ctx.Modules.promises.hooks.fire.should.not.have.been.called
  220. expect(requestOptions.body).not.to.exist
  221. expect(body).to.deep.equal([
  222. {
  223. ...responseBody[0],
  224. },
  225. ])
  226. })
  227. it('handle error', async function (ctx) {
  228. const body = { errors: 'affiliation error message' }
  229. ctx.request.callsArgWith(1, null, { statusCode: 503 }, body)
  230. let error
  231. try {
  232. await ctx.InstitutionsAPI.promises.getUserAffiliations(
  233. ctx.stubbedUser._id
  234. )
  235. } catch (err) {
  236. error = err
  237. }
  238. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  239. })
  240. it('handle empty response', async function (ctx) {
  241. ctx.settings.apis.v1.url = ''
  242. const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
  243. ctx.stubbedUser._id
  244. )
  245. expect(body).to.be.a('Array')
  246. body.length.should.equal(0)
  247. })
  248. })
  249. describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
  250. it('get the list of users', async function (ctx) {
  251. ctx.fetchJson.resolves({ statusCode: 200 })
  252. await ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
  253. ctx.fetchJson.calledOnce.should.equal(true)
  254. const requestOptions = ctx.fetchJson.lastCall.args[1]
  255. const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
  256. ctx.fetchJson.lastCall.args[0].should.equal(expectedUrl)
  257. requestOptions.method.should.equal('GET')
  258. })
  259. it('handle error', async function (ctx) {
  260. ctx.fetchJson.throws({ info: { statusCode: 500 } })
  261. await expect(
  262. ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
  263. ).to.be.rejected
  264. })
  265. })
  266. describe('addAffiliation', function () {
  267. beforeEach(function (ctx) {
  268. ctx.fetchNothing.resolves({ status: 201 })
  269. })
  270. it('add affiliation', async function (ctx) {
  271. const affiliationOptions = {
  272. university: { id: 1 },
  273. department: 'Math',
  274. role: 'Prof',
  275. confirmedAt: new Date(),
  276. entitlement: true,
  277. }
  278. await ctx.InstitutionsAPI.promises.addAffiliation(
  279. ctx.stubbedUser._id,
  280. ctx.newEmail,
  281. affiliationOptions
  282. )
  283. ctx.fetchNothing.calledOnce.should.equal(true)
  284. const requestOptions = ctx.fetchNothing.lastCall.args[1]
  285. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
  286. expect(ctx.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
  287. requestOptions.method.should.equal('POST')
  288. const { json } = requestOptions
  289. Object.keys(json).length.should.equal(7)
  290. expect(json).to.deep.equal(
  291. Object.assign(
  292. { email: ctx.newEmail, rejectIfBlocklisted: undefined },
  293. affiliationOptions
  294. )
  295. )
  296. ctx.markAsReadIpMatcher.calledOnce.should.equal(true)
  297. })
  298. it('handles 422 error', async function (ctx) {
  299. const messageFromApi = 'affiliation error message'
  300. const body = JSON.stringify({ errors: messageFromApi })
  301. ctx.fetchNothing.throws({ response: { status: 422 }, body })
  302. let error
  303. try {
  304. await ctx.InstitutionsAPI.promises.addAffiliation(
  305. ctx.stubbedUser._id,
  306. ctx.newEmail,
  307. {}
  308. )
  309. } catch (err) {
  310. error = err
  311. }
  312. expect(error).to.be.instanceOf(Errors.InvalidInstitutionalEmailError)
  313. expect(error).to.have.property('message', `422: ${messageFromApi}`)
  314. })
  315. it('handles 500 error', async function (ctx) {
  316. const body = { errors: 'affiliation error message' }
  317. ctx.fetchNothing.throws({ response: { status: 500 }, body })
  318. let error
  319. try {
  320. await ctx.InstitutionsAPI.promises.addAffiliation(
  321. ctx.stubbedUser._id,
  322. ctx.newEmail,
  323. {}
  324. )
  325. } catch (err) {
  326. error = err
  327. }
  328. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  329. expect(error.message).to.equal('error getting affiliations from v1')
  330. expect(error.info).to.eql({
  331. status: 500,
  332. body: { errors: 'affiliation error message' },
  333. })
  334. })
  335. it('uses default error message when no error body in response', async function (ctx) {
  336. ctx.fetchNothing.throws({ response: { status: 429 } })
  337. let error
  338. try {
  339. await ctx.InstitutionsAPI.promises.addAffiliation(
  340. ctx.stubbedUser._id,
  341. ctx.newEmail,
  342. {}
  343. )
  344. } catch (err) {
  345. error = err
  346. }
  347. expect(error).to.be.instanceOf(Error)
  348. expect(error).to.have.property(
  349. 'message',
  350. "Couldn't create affiliation: 429"
  351. )
  352. })
  353. it('does not try to mark IP matcher notifications as read if no university passed', async function (ctx) {
  354. const affiliationOptions = {
  355. confirmedAt: new Date(),
  356. }
  357. await ctx.InstitutionsAPI.promises.addAffiliation(
  358. ctx.stubbedUser._id,
  359. ctx.newEmail,
  360. affiliationOptions
  361. )
  362. expect(ctx.markAsReadIpMatcher.callCount).to.equal(0)
  363. })
  364. })
  365. describe('removeAffiliation', function () {
  366. beforeEach(function (ctx) {
  367. ctx.fetchNothing.throws({ response: { status: 404 } })
  368. })
  369. it('remove affiliation', async function (ctx) {
  370. await ctx.InstitutionsAPI.promises.removeAffiliation(
  371. ctx.stubbedUser._id,
  372. ctx.newEmail
  373. )
  374. ctx.fetchNothing.calledOnce.should.equal(true)
  375. const requestOptions = ctx.fetchNothing.lastCall.args[1]
  376. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/remove`
  377. ctx.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
  378. requestOptions.method.should.equal('POST')
  379. expect(requestOptions.json).to.deep.equal({ email: ctx.newEmail })
  380. })
  381. it('handle error', async function (ctx) {
  382. ctx.fetchNothing.throws({ response: { status: 500 } })
  383. let error
  384. try {
  385. await ctx.InstitutionsAPI.promises.removeAffiliation(
  386. ctx.stubbedUser._id,
  387. ctx.newEmail
  388. )
  389. } catch (err) {
  390. error = err
  391. }
  392. expect(error).to.exist
  393. expect(error).to.have.property('message')
  394. })
  395. })
  396. describe('deleteAffiliations', function () {
  397. it('delete affiliations', async function (ctx) {
  398. ctx.request.callsArgWith(1, null, { statusCode: 200 })
  399. await ctx.InstitutionsAPI.promises.deleteAffiliations(ctx.stubbedUser._id)
  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`
  403. requestOptions.url.should.equal(expectedUrl)
  404. requestOptions.method.should.equal('DELETE')
  405. })
  406. it('handle error', async function (ctx) {
  407. const body = { errors: 'affiliation error message' }
  408. ctx.request.callsArgWith(1, null, { statusCode: 518 }, body)
  409. let error
  410. try {
  411. await ctx.InstitutionsAPI.promises.deleteAffiliations(
  412. ctx.stubbedUser._id
  413. )
  414. } catch (err) {
  415. error = err
  416. }
  417. expect(error).to.be.instanceOf(Errors.V1ConnectionError)
  418. })
  419. })
  420. describe('endorseAffiliation', function () {
  421. beforeEach(function (ctx) {
  422. ctx.request.callsArgWith(1, null, { statusCode: 204 })
  423. })
  424. it('endorse affiliation', async function (ctx) {
  425. await ctx.InstitutionsAPI.promises.endorseAffiliation(
  426. ctx.stubbedUser._id,
  427. ctx.newEmail,
  428. 'Student',
  429. 'Physics'
  430. )
  431. ctx.request.calledOnce.should.equal(true)
  432. const requestOptions = ctx.request.lastCall.args[0]
  433. const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/endorse`
  434. requestOptions.url.should.equal(expectedUrl)
  435. requestOptions.method.should.equal('POST')
  436. const { body } = requestOptions
  437. Object.keys(body).length.should.equal(3)
  438. body.email.should.equal(ctx.newEmail)
  439. body.role.should.equal('Student')
  440. body.department.should.equal('Physics')
  441. })
  442. })
  443. describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
  444. const users = ['abc123', 'def456']
  445. it('sends the list of users', async function (ctx) {
  446. ctx.request.callsArgWith(1, null, { statusCode: 200 })
  447. await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
  448. users
  449. )
  450. ctx.request.calledOnce.should.equal(true)
  451. const requestOptions = ctx.request.lastCall.args[0]
  452. const expectedUrl =
  453. 'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
  454. requestOptions.url.should.equal(expectedUrl)
  455. requestOptions.method.should.equal('POST')
  456. expect(requestOptions.body).to.deep.equal({ users })
  457. })
  458. it('handle error', async function (ctx) {
  459. ctx.request.callsArgWith(1, null, { statusCode: 500 })
  460. let error
  461. try {
  462. await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
  463. users
  464. )
  465. } catch (err) {
  466. error = err
  467. }
  468. expect(error).to.exist
  469. })
  470. })
  471. })