OwnershipTransferHandlerTests.js 15 KB

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