OwnershipTransferHandler.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import mongodb from 'mongodb-legacy'
  6. const { ObjectId } = mongodb
  7. const MODULE_PATH =
  8. '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
  9. describe('OwnershipTransferHandler', function () {
  10. beforeEach(async function (ctx) {
  11. ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
  12. ctx.collaborator = {
  13. _id: new ObjectId(),
  14. email: 'collaborator@example.com',
  15. }
  16. ctx.readOnlyCollaborator = {
  17. _id: new ObjectId(),
  18. email: 'readonly@example.com',
  19. }
  20. ctx.reviewer = {
  21. _id: new ObjectId(),
  22. email: 'reviewer@example.com',
  23. }
  24. ctx.project = {
  25. _id: new ObjectId(),
  26. name: 'project',
  27. owner_ref: ctx.user._id,
  28. collaberator_refs: [ctx.collaborator._id],
  29. readOnly_refs: [ctx.readOnlyCollaborator._id],
  30. reviewer_refs: [ctx.reviewer._id],
  31. }
  32. ctx.ProjectGetter = {
  33. promises: {
  34. getProject: sinon.stub().resolves(ctx.project),
  35. },
  36. }
  37. ctx.ProjectModel = {
  38. find: sinon.stub().resolves([]),
  39. updateOne: sinon.stub().returns({
  40. exec: sinon.stub().resolves(),
  41. }),
  42. }
  43. ctx.UserGetter = {
  44. promises: {
  45. getUser: sinon.stub().resolves(),
  46. },
  47. }
  48. ctx.TpdsUpdateSender = {
  49. promises: {
  50. moveEntity: sinon.stub().resolves(),
  51. },
  52. }
  53. ctx.TpdsProjectFlusher = {
  54. promises: {
  55. flushProjectToTpds: sinon.stub().resolves(),
  56. },
  57. }
  58. ctx.CollaboratorsHandler = {
  59. promises: {
  60. removeUserFromProject: sinon.stub().resolves(),
  61. addUserIdToProject: sinon.stub().resolves(),
  62. },
  63. }
  64. ctx.EmailHandler = {
  65. promises: {
  66. sendEmail: sinon.stub().resolves(),
  67. },
  68. }
  69. ctx.ProjectAuditLogHandler = {
  70. promises: {
  71. addEntry: sinon.stub().resolves(),
  72. },
  73. }
  74. ctx.TagsHandler = {
  75. promises: {
  76. createTag: sinon.stub().resolves(),
  77. addProjectsToTag: sinon.stub().resolves(),
  78. },
  79. }
  80. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  81. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  82. )
  83. vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
  84. default: ctx.ProjectGetter,
  85. }))
  86. vi.doMock('../../../../app/src/models/Project.js', () => ({
  87. Project: ctx.ProjectModel,
  88. }))
  89. vi.doMock('../../../../app/src/Features/Tags/TagsHandler.js', () => ({
  90. default: ctx.TagsHandler,
  91. }))
  92. vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
  93. default: ctx.UserGetter,
  94. }))
  95. vi.doMock(
  96. '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.js',
  97. () => ({
  98. default: ctx.TpdsProjectFlusher,
  99. })
  100. )
  101. vi.doMock(
  102. '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
  103. () => ({
  104. default: ctx.ProjectAuditLogHandler,
  105. })
  106. )
  107. vi.doMock('../../../../app/src/Features/Email/EmailHandler.js', () => ({
  108. default: ctx.EmailHandler,
  109. }))
  110. vi.doMock(
  111. '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js',
  112. () => ({
  113. default: ctx.CollaboratorsHandler,
  114. })
  115. )
  116. vi.doMock(
  117. '../../../../app/src/Features/Analytics/AnalyticsManager',
  118. () => ({
  119. default: {
  120. recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
  121. sinon.stub()),
  122. },
  123. })
  124. )
  125. ctx.handler = (await import(MODULE_PATH)).default
  126. })
  127. describe('transferOwnership', function () {
  128. beforeEach(function (ctx) {
  129. ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
  130. ctx.UserGetter.promises.getUser
  131. .withArgs(ctx.collaborator._id)
  132. .resolves(ctx.collaborator)
  133. ctx.UserGetter.promises.getUser
  134. .withArgs(ctx.readOnlyCollaborator._id)
  135. .resolves(ctx.readOnlyCollaborator)
  136. ctx.UserGetter.promises.getUser
  137. .withArgs(ctx.reviewer._id)
  138. .resolves(ctx.reviewer)
  139. })
  140. it("should return a not found error if the project can't be found", async function (ctx) {
  141. ctx.ProjectGetter.promises.getProject.resolves(null)
  142. await expect(
  143. ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
  144. ).to.be.rejectedWith(Errors.ProjectNotFoundError)
  145. })
  146. it("should return a not found error if the user can't be found", async function (ctx) {
  147. ctx.UserGetter.promises.getUser
  148. .withArgs(ctx.collaborator._id)
  149. .resolves(null)
  150. await expect(
  151. ctx.handler.promises.transferOwnership(
  152. ctx.project._id,
  153. ctx.collaborator._id
  154. )
  155. ).to.be.rejectedWith(Errors.UserNotFoundError)
  156. })
  157. it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
  158. ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
  159. new Error('user-cannot-be-removed')
  160. )
  161. await expect(
  162. ctx.handler.promises.transferOwnership(
  163. ctx.project._id,
  164. ctx.collaborator._id
  165. )
  166. ).to.be.rejected
  167. })
  168. it('should transfer ownership of the project', async function (ctx) {
  169. await ctx.handler.promises.transferOwnership(
  170. ctx.project._id,
  171. ctx.collaborator._id
  172. )
  173. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  174. { _id: ctx.project._id },
  175. sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
  176. )
  177. })
  178. it('should transfer ownership of the project to a read-only collaborator', async function (ctx) {
  179. await ctx.handler.promises.transferOwnership(
  180. ctx.project._id,
  181. ctx.readOnlyCollaborator._id
  182. )
  183. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  184. { _id: ctx.project._id },
  185. sinon.match({ $set: { owner_ref: ctx.readOnlyCollaborator._id } })
  186. )
  187. })
  188. it('gives old owner read-only permissions if new owner was previously a viewer', async function (ctx) {
  189. await ctx.handler.promises.transferOwnership(
  190. ctx.project._id,
  191. ctx.readOnlyCollaborator._id
  192. )
  193. expect(
  194. ctx.CollaboratorsHandler.promises.addUserIdToProject
  195. ).to.have.been.calledWith(
  196. ctx.project._id,
  197. ctx.readOnlyCollaborator._id,
  198. ctx.user._id,
  199. PrivilegeLevels.READ_ONLY
  200. )
  201. })
  202. it('should do nothing if transferring back to the owner', async function (ctx) {
  203. await ctx.handler.promises.transferOwnership(
  204. ctx.project._id,
  205. ctx.user._id
  206. )
  207. expect(ctx.ProjectModel.updateOne).not.to.have.been.called
  208. })
  209. it("should remove the user from the project's collaborators", async function (ctx) {
  210. await ctx.handler.promises.transferOwnership(
  211. ctx.project._id,
  212. ctx.collaborator._id
  213. )
  214. expect(
  215. ctx.CollaboratorsHandler.promises.removeUserFromProject
  216. ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
  217. })
  218. it('should add the former project owner as a read/write collaborator', async function (ctx) {
  219. await ctx.handler.promises.transferOwnership(
  220. ctx.project._id,
  221. ctx.collaborator._id
  222. )
  223. expect(
  224. ctx.CollaboratorsHandler.promises.addUserIdToProject
  225. ).to.have.been.calledWith(
  226. ctx.project._id,
  227. ctx.collaborator._id,
  228. ctx.user._id,
  229. PrivilegeLevels.READ_AND_WRITE
  230. )
  231. })
  232. it('should transfer ownership of the project to a reviewer', async function (ctx) {
  233. await ctx.handler.promises.transferOwnership(
  234. ctx.project._id,
  235. ctx.reviewer._id
  236. )
  237. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  238. { _id: ctx.project._id },
  239. sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
  240. )
  241. })
  242. it('gives old owner reviewer permissions if new owner was previously a reviewer', async function (ctx) {
  243. await ctx.handler.promises.transferOwnership(
  244. ctx.project._id,
  245. ctx.reviewer._id
  246. )
  247. expect(
  248. ctx.CollaboratorsHandler.promises.addUserIdToProject
  249. ).to.have.been.calledWith(
  250. ctx.project._id,
  251. ctx.reviewer._id,
  252. ctx.user._id,
  253. PrivilegeLevels.REVIEW
  254. )
  255. })
  256. it('should flush the project to tpds', async function (ctx) {
  257. await ctx.handler.promises.transferOwnership(
  258. ctx.project._id,
  259. ctx.collaborator._id
  260. )
  261. expect(
  262. ctx.TpdsProjectFlusher.promises.flushProjectToTpds
  263. ).to.have.been.calledWith(ctx.project._id)
  264. })
  265. it('should send an email notification', async function (ctx) {
  266. await ctx.handler.promises.transferOwnership(
  267. ctx.project._id,
  268. ctx.collaborator._id
  269. )
  270. expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  271. 'ownershipTransferConfirmationPreviousOwner',
  272. {
  273. to: ctx.user.email,
  274. project: ctx.project,
  275. newOwner: ctx.collaborator,
  276. }
  277. )
  278. expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  279. 'ownershipTransferConfirmationNewOwner',
  280. {
  281. to: ctx.collaborator.email,
  282. project: ctx.project,
  283. previousOwner: ctx.user,
  284. }
  285. )
  286. })
  287. it('should not send an email notification with the skipEmails option', async function (ctx) {
  288. await ctx.handler.promises.transferOwnership(
  289. ctx.project._id,
  290. ctx.collaborator._id,
  291. { skipEmails: true }
  292. )
  293. expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
  294. })
  295. it('should track the change in BigQuery', async function (ctx) {
  296. const sessionUserId = new ObjectId()
  297. await ctx.handler.promises.transferOwnership(
  298. ctx.project._id,
  299. ctx.collaborator._id,
  300. { sessionUserId }
  301. )
  302. expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
  303. ctx.user._id,
  304. 'project-ownership-transfer',
  305. {
  306. projectId: ctx.project._id,
  307. newOwnerId: ctx.collaborator._id,
  308. }
  309. )
  310. })
  311. it('should write an entry in the audit log', async function (ctx) {
  312. const sessionUserId = new ObjectId()
  313. const ipAddress = '1.2.3.4'
  314. await ctx.handler.promises.transferOwnership(
  315. ctx.project._id,
  316. ctx.collaborator._id,
  317. { sessionUserId, ipAddress }
  318. )
  319. expect(
  320. ctx.ProjectAuditLogHandler.promises.addEntry
  321. ).to.have.been.calledWith(
  322. ctx.project._id,
  323. 'transfer-ownership',
  324. sessionUserId,
  325. ipAddress,
  326. {
  327. previousOwnerId: ctx.user._id,
  328. newOwnerId: ctx.collaborator._id,
  329. }
  330. )
  331. })
  332. it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
  333. ctx.project.collaberator_refs = []
  334. ctx.project.readOnly_refs = []
  335. await expect(
  336. ctx.handler.promises.transferOwnership(
  337. ctx.project._id,
  338. ctx.collaborator._id
  339. )
  340. ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
  341. })
  342. })
  343. describe('transferAllProjectsToUser', function () {
  344. const fromUserEmail = 'user.one@example.com'
  345. const ipAddress = '1.2.3.4'
  346. let fromUserId, toUserId
  347. beforeEach(function () {
  348. fromUserId = new ObjectId().toString()
  349. toUserId = new ObjectId().toString()
  350. })
  351. describe('with missing user', function () {
  352. it('should throw an error', async function (ctx) {
  353. ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
  354. ctx.UserGetter.promises.getUser
  355. .withArgs(toUserId)
  356. .resolves({ _id: new ObjectId(toUserId) })
  357. await expect(
  358. ctx.handler.promises.transferAllProjectsToUser({
  359. toUserId,
  360. fromUserId,
  361. ipAddress,
  362. })
  363. ).to.be.rejectedWith(/missing source user/)
  364. ctx.UserGetter.promises.getUser
  365. .withArgs(fromUserId)
  366. .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
  367. ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
  368. await expect(
  369. ctx.handler.promises.transferAllProjectsToUser({
  370. fromUserId,
  371. toUserId,
  372. ipAddress,
  373. })
  374. ).to.be.rejectedWith(/missing destination user/)
  375. })
  376. })
  377. describe('with the same id', function () {
  378. it('should throw an error', async function (ctx) {
  379. ctx.UserGetter.promises.getUser
  380. .withArgs(fromUserId)
  381. .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
  382. await expect(
  383. ctx.handler.promises.transferAllProjectsToUser({
  384. fromUserId,
  385. toUserId: fromUserId,
  386. ipAddress,
  387. })
  388. ).to.be.rejectedWith(/rejecting transfer between identical users/)
  389. })
  390. })
  391. describe('happy path', function () {
  392. let tag, fromUserEmail, projects
  393. beforeEach(function (ctx) {
  394. tag = {
  395. _id: new ObjectId(),
  396. name: 'some-tag-name',
  397. }
  398. projects = [
  399. { _id: 'project-1' },
  400. { _id: 'project-2' },
  401. { _id: 'project-3' },
  402. ]
  403. ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
  404. _id: new ObjectId(fromUserId),
  405. email: fromUserEmail,
  406. })
  407. ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
  408. _id: new ObjectId(toUserId),
  409. })
  410. ctx.ProjectModel.find.resolves(projects)
  411. ctx.TagsHandler.promises.createTag.resolves({
  412. _id: tag._id,
  413. name: 'some-tag-name',
  414. })
  415. ctx.TagsHandler.promises.addProjectsToTag.resolves()
  416. })
  417. it('creates a tag', async function (ctx) {
  418. await ctx.handler.promises.transferAllProjectsToUser({
  419. fromUserId,
  420. toUserId,
  421. ipAddress,
  422. })
  423. expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
  424. toUserId,
  425. `transferred-from-${fromUserEmail}`,
  426. '#434AF0',
  427. { truncate: true }
  428. )
  429. })
  430. it('returns a projectCount, and tag name', async function (ctx) {
  431. const result = await ctx.handler.promises.transferAllProjectsToUser({
  432. fromUserId,
  433. toUserId,
  434. ipAddress,
  435. })
  436. expect(result.projectCount).to.equal(projects.length)
  437. expect(result.newTagName).to.equal('some-tag-name')
  438. })
  439. it('gets the user records', async function (ctx) {
  440. await ctx.handler.promises.transferAllProjectsToUser({
  441. fromUserId,
  442. toUserId,
  443. ipAddress,
  444. })
  445. expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
  446. fromUserId
  447. )
  448. expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
  449. toUserId
  450. )
  451. })
  452. it('gets the list of affected projects', async function (ctx) {
  453. await ctx.handler.promises.transferAllProjectsToUser({
  454. fromUserId,
  455. toUserId,
  456. ipAddress,
  457. })
  458. expect(ctx.ProjectModel.find).to.have.been.calledWith({
  459. owner_ref: fromUserId,
  460. })
  461. })
  462. it('transfers all of the projects', async function (ctx) {
  463. await ctx.handler.promises.transferAllProjectsToUser({
  464. fromUserId,
  465. toUserId,
  466. ipAddress,
  467. })
  468. expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
  469. expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
  470. for (const project of projects) {
  471. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  472. { _id: project._id },
  473. sinon.match({ $set: { owner_ref: toUserId } })
  474. )
  475. }
  476. expect(
  477. ctx.TagsHandler.promises.addProjectsToTag
  478. ).to.have.been.calledWith(
  479. toUserId,
  480. tag._id,
  481. projects.map(p => p._id)
  482. )
  483. })
  484. })
  485. })
  486. })