project_updates.test.js 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  1. const BPromise = require('bluebird')
  2. const { expect } = require('chai')
  3. const fs = BPromise.promisifyAll(require('node:fs'))
  4. const HTTPStatus = require('http-status')
  5. const fetch = require('node-fetch')
  6. const cleanup = require('../storage/support/cleanup')
  7. const fixtures = require('../storage/support/fixtures')
  8. const testFiles = require('../storage/support/test_files')
  9. const expectResponse = require('./support/expect_response')
  10. const testServer = require('./support/test_server')
  11. const core = require('overleaf-editor-core')
  12. const testProjects = require('./support/test_projects')
  13. const Change = core.Change
  14. const ChunkResponse = core.ChunkResponse
  15. const File = core.File
  16. const Operation = core.Operation
  17. const Origin = core.Origin
  18. const Snapshot = core.Snapshot
  19. const TextOperation = core.TextOperation
  20. const V2DocVersions = core.V2DocVersions
  21. const knex = require('../../../../storage').knex
  22. const blobHash = require('../../../../storage/lib/blob_hash')
  23. describe('history import', function () {
  24. beforeEach(cleanup.everything)
  25. beforeEach(fixtures.create)
  26. function changeToRaw(change) {
  27. return change.toRaw()
  28. }
  29. function makeChange(operation) {
  30. return new Change([operation], new Date(), [])
  31. }
  32. let basicAuthClient
  33. let pseudoJwtBasicAuthClient
  34. let clientForProject
  35. before(async function () {
  36. basicAuthClient = testServer.basicAuthClient
  37. pseudoJwtBasicAuthClient = testServer.pseudoJwtBasicAuthClient
  38. clientForProject = await testServer.createClientForProject('1')
  39. })
  40. it('creates blobs and then imports a snapshot and history', function () {
  41. // We need to be able to set the projectId to match an existing doc ID.
  42. const testProjectId = '1'
  43. const testFilePathname = 'main.tex'
  44. const testAuthors = [123, null]
  45. const testTextOperation0 = TextOperation.fromJSON({ textOperation: ['a'] })
  46. const testTextOperation1 = TextOperation.fromJSON({
  47. textOperation: [1, 'b'],
  48. })
  49. let testSnapshot
  50. return fetch(
  51. testServer.url(
  52. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  53. ),
  54. {
  55. method: 'PUT',
  56. body: fs.createReadStream(testFiles.path('empty.tex')),
  57. headers: {
  58. Authorization: testServer.basicAuthHeader,
  59. },
  60. }
  61. )
  62. .then(response => {
  63. expect(response.ok).to.be.true
  64. })
  65. .then(() => {
  66. // Import project
  67. testSnapshot = new Snapshot()
  68. testSnapshot.addFile(
  69. testFilePathname,
  70. File.fromHash(File.EMPTY_FILE_HASH)
  71. )
  72. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  73. project_id: testProjectId,
  74. snapshot: testSnapshot.toRaw(),
  75. })
  76. })
  77. .then(response => {
  78. // Check project is valid
  79. expect(response.obj.projectId).to.equal(testProjectId)
  80. })
  81. .then(() => {
  82. // Try importing the project again
  83. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  84. project_id: testProjectId,
  85. snapshot: testSnapshot.toRaw(),
  86. })
  87. })
  88. .then(() => {
  89. // Check that importing a duplicate fails
  90. expect.fail()
  91. })
  92. .catch(expectResponse.conflict)
  93. .then(() => {
  94. // Get project history
  95. return clientForProject.apis.Project.getLatestHistory({
  96. project_id: testProjectId,
  97. })
  98. })
  99. .then(response => {
  100. // Check that the imported history is valid
  101. const chunk = ChunkResponse.fromRaw(response.obj).getChunk()
  102. const snapshot = chunk.getSnapshot()
  103. expect(snapshot.countFiles()).to.equal(1)
  104. const file = snapshot.getFile(testFilePathname)
  105. expect(file.getHash()).to.eql(File.EMPTY_FILE_HASH)
  106. expect(chunk.getChanges().length).to.equal(0)
  107. expect(chunk.getEndVersion()).to.equal(0)
  108. })
  109. .then(() => {
  110. // Import changes with an end version
  111. const changes = [
  112. makeChange(Operation.editFile(testFilePathname, testTextOperation0)),
  113. makeChange(Operation.editFile(testFilePathname, testTextOperation1)),
  114. ]
  115. changes[0].setAuthors(testAuthors)
  116. return basicAuthClient.apis.ProjectImport.importChanges1({
  117. project_id: testProjectId,
  118. changes: changes.map(changeToRaw),
  119. end_version: 0,
  120. return_snapshot: 'hashed',
  121. })
  122. })
  123. .then(response => {
  124. expect(response.status).to.equal(HTTPStatus.CREATED)
  125. const snapshot = Snapshot.fromRaw(response.obj)
  126. expect(snapshot.countFiles()).to.equal(1)
  127. expect(snapshot.getFile('main.tex').getHash()).to.equal(
  128. testFiles.STRING_AB_HASH
  129. )
  130. })
  131. .then(() => {
  132. // Get project history
  133. return clientForProject.apis.Project.getLatestHistory({
  134. project_id: testProjectId,
  135. })
  136. })
  137. .then(response => {
  138. // Check that the history is valid
  139. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  140. const chunk = chunkResponse.getChunk()
  141. const snapshot = chunk.getSnapshot()
  142. expect(snapshot.countFiles()).to.equal(1)
  143. const file = snapshot.getFile(testFilePathname)
  144. expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH)
  145. expect(chunk.getChanges().length).to.equal(2)
  146. const changeWithAuthors = chunk.getChanges()[0]
  147. expect(changeWithAuthors.getAuthors().length).to.equal(2)
  148. expect(changeWithAuthors.getAuthors()).to.deep.equal(testAuthors)
  149. expect(chunk.getStartVersion()).to.equal(0)
  150. expect(chunk.getEndVersion()).to.equal(2)
  151. })
  152. .then(() => {
  153. return clientForProject.apis.Project.getLatestHistory({
  154. project_id: testProjectId,
  155. })
  156. })
  157. .then(response => {
  158. // it should retrieve the same chunk
  159. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  160. const chunk = chunkResponse.getChunk()
  161. expect(chunk.getChanges().length).to.equal(2)
  162. expect(chunk.getStartVersion()).to.equal(0)
  163. expect(chunk.getEndVersion()).to.equal(2)
  164. })
  165. .then(() => {
  166. // Get project's latest content
  167. return clientForProject.apis.Project.getLatestContent({
  168. project_id: testProjectId,
  169. })
  170. })
  171. .then(response => {
  172. // Check that the content is valid
  173. const snapshot = Snapshot.fromRaw(response.obj)
  174. expect(snapshot.countFiles()).to.equal(1)
  175. const file = snapshot.getFile(testFilePathname)
  176. expect(file.getContent()).to.equal('ab')
  177. })
  178. })
  179. it('rejects invalid changes in history', function () {
  180. const testProjectId = '1'
  181. const testFilePathname = 'main.tex'
  182. const testTextOperation = TextOperation.fromJSON({
  183. textOperation: ['a', 10],
  184. })
  185. let testSnapshot
  186. return fetch(
  187. testServer.url(
  188. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  189. ),
  190. {
  191. method: 'PUT',
  192. body: fs.createReadStream(testFiles.path('empty.tex')),
  193. headers: {
  194. Authorization: testServer.basicAuthHeader,
  195. },
  196. }
  197. )
  198. .then(response => {
  199. expect(response.ok).to.be.true
  200. })
  201. .then(() => {
  202. // Import project
  203. testSnapshot = new Snapshot()
  204. testSnapshot.addFile(
  205. testFilePathname,
  206. File.fromHash(File.EMPTY_FILE_HASH)
  207. )
  208. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  209. project_id: testProjectId,
  210. snapshot: testSnapshot.toRaw(),
  211. })
  212. })
  213. .then(response => {
  214. // Check project is valid
  215. expect(response.obj.projectId).to.equal(testProjectId)
  216. })
  217. .then(() => {
  218. // Import invalid changes
  219. const changes = [
  220. makeChange(Operation.editFile(testFilePathname, testTextOperation)),
  221. ]
  222. return basicAuthClient.apis.ProjectImport.importChanges1({
  223. project_id: testProjectId,
  224. end_version: 0,
  225. return_snapshot: 'hashed',
  226. changes: changes.map(changeToRaw),
  227. })
  228. })
  229. .then(() => {
  230. // Check that this fails
  231. expect.fail()
  232. })
  233. .catch(expectResponse.unprocessableEntity)
  234. .then(() => {
  235. // Get the latest content
  236. return clientForProject.apis.Project.getLatestContent({
  237. project_id: testProjectId,
  238. })
  239. })
  240. .then(response => {
  241. // Check that no changes have been stored
  242. const snapshot = Snapshot.fromRaw(response.obj)
  243. expect(snapshot.countFiles()).to.equal(1)
  244. const file = snapshot.getFile(testFilePathname)
  245. expect(file.getContent()).to.equal('')
  246. })
  247. .then(() => {
  248. // Send a change with the wrong end version that is not conflicting
  249. // with the latest snapshot
  250. const changes = [makeChange(Operation.removeFile(testFilePathname))]
  251. return basicAuthClient.apis.ProjectImport.importChanges1({
  252. project_id: testProjectId,
  253. end_version: 10000,
  254. changes,
  255. })
  256. })
  257. .then(() => {
  258. // Check that this fails
  259. expect.fail()
  260. })
  261. .catch(expectResponse.unprocessableEntity)
  262. .then(() => {
  263. // Get the latest project history
  264. return clientForProject.apis.Project.getLatestHistory({
  265. project_id: testProjectId,
  266. })
  267. })
  268. .then(response => {
  269. // Check that no changes have been stored
  270. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  271. const changes = chunkResponse.getChunk().getChanges()
  272. expect(changes).to.have.length(0)
  273. })
  274. })
  275. it('creates and edits a file using changes', function () {
  276. const testProjectId = '1'
  277. const mainFilePathname = 'main.tex'
  278. const testFilePathname = 'test.tex'
  279. const testTextOperation = TextOperation.fromJSON({ textOperation: ['a'] })
  280. const inexistentAuthors = [1234, 5678]
  281. const projectVersion = '12345.0'
  282. const v2DocVersions = new V2DocVersions({
  283. 'random-doc-id': { pathname: 'doc-path.tex', v: 123 },
  284. })
  285. const testLabelOrigin = Origin.fromRaw({
  286. kind: 'saved ver',
  287. })
  288. const testRestoreOrigin = Origin.fromRaw({
  289. kind: 'restore',
  290. timestamp: '2016-01-01T00:00:00',
  291. version: 1,
  292. })
  293. let testSnapshot
  294. return fetch(
  295. testServer.url(
  296. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  297. ),
  298. {
  299. method: 'PUT',
  300. body: fs.createReadStream(testFiles.path('empty.tex')),
  301. headers: {
  302. Authorization: testServer.basicAuthHeader,
  303. },
  304. }
  305. )
  306. .then(response => {
  307. expect(response.ok).to.be.true
  308. })
  309. .then(() => {
  310. // Import a project
  311. testSnapshot = new Snapshot()
  312. testSnapshot.addFile(
  313. mainFilePathname,
  314. File.fromHash(File.EMPTY_FILE_HASH)
  315. )
  316. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  317. project_id: testProjectId,
  318. snapshot: testSnapshot.toRaw(),
  319. })
  320. })
  321. .then(response => {
  322. // Check that the project is valid
  323. expect(response.obj.projectId).to.equal(testProjectId)
  324. })
  325. .then(() => {
  326. // Import changes
  327. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  328. const changes = [
  329. makeChange(Operation.addFile(testFilePathname, testFile)),
  330. makeChange(Operation.editFile(testFilePathname, testTextOperation)),
  331. ]
  332. changes[0].setProjectVersion(projectVersion)
  333. changes[1].setAuthors(inexistentAuthors)
  334. changes[1].setV2DocVersions(v2DocVersions)
  335. return basicAuthClient.apis.ProjectImport.importChanges1({
  336. project_id: testProjectId,
  337. end_version: 0,
  338. return_snapshot: 'hashed',
  339. changes: changes.map(changeToRaw),
  340. })
  341. })
  342. .then(response => {
  343. expect(response.status).to.equal(HTTPStatus.CREATED)
  344. const snapshot = Snapshot.fromRaw(response.obj)
  345. expect(snapshot.countFiles()).to.equal(2)
  346. expect(snapshot.getFile('main.tex').getHash()).to.equal(
  347. File.EMPTY_FILE_HASH
  348. )
  349. expect(snapshot.getFile('test.tex').getHash()).to.equal(
  350. testFiles.STRING_A_HASH
  351. )
  352. })
  353. .then(() => {
  354. // Get the project history
  355. return clientForProject.apis.Project.getLatestHistory({
  356. project_id: testProjectId,
  357. })
  358. })
  359. .then(response => {
  360. // it should not fail when the some of the authors do not exist anymore
  361. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  362. const changes = chunkResponse.getChunk().getChanges()
  363. expect(changes.length).to.equal(2)
  364. const changeWithAuthor = changes[1]
  365. expect(changeWithAuthor.getAuthors()).to.deep.equal(inexistentAuthors)
  366. })
  367. .then(() => {
  368. // it should retrieve the latest snapshot when the changes set is empty
  369. return basicAuthClient.apis.ProjectImport.importChanges1({
  370. project_id: testProjectId,
  371. end_version: 0,
  372. return_snapshot: 'hashed',
  373. changes: [],
  374. })
  375. })
  376. .then(response => {
  377. // Check latest snapshot
  378. const snapshot = Snapshot.fromRaw(response.obj)
  379. expect(snapshot.countFiles()).to.equal(2)
  380. expect(snapshot.getFile('main.tex').getHash()).to.equal(
  381. File.EMPTY_FILE_HASH
  382. )
  383. expect(snapshot.getFile('test.tex').getHash()).to.equal(
  384. testFiles.STRING_A_HASH
  385. )
  386. expect(snapshot.getProjectVersion()).to.equal(projectVersion)
  387. expect(snapshot.getV2DocVersions()).to.deep.equal(v2DocVersions)
  388. })
  389. .then(() => {
  390. // Import changes with origin
  391. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  392. const changes = [
  393. makeChange(Operation.removeFile(testFilePathname)),
  394. makeChange(Operation.addFile(testFilePathname, testFile)),
  395. ]
  396. changes[0].setOrigin(testLabelOrigin)
  397. changes[1].setOrigin(testRestoreOrigin)
  398. return basicAuthClient.apis.ProjectImport.importChanges1({
  399. project_id: testProjectId,
  400. end_version: 0,
  401. changes: changes.map(changeToRaw),
  402. })
  403. })
  404. .then(() => {
  405. // Get the latest history
  406. return clientForProject.apis.Project.getLatestHistory({
  407. project_id: testProjectId,
  408. })
  409. })
  410. .then(response => {
  411. // Check that the origin is stored
  412. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  413. const changes = chunkResponse.getChunk().getChanges()
  414. expect(changes).to.have.length(4)
  415. expect(changes[2].getOrigin()).to.deep.equal(testLabelOrigin)
  416. expect(changes[3].getOrigin()).to.deep.equal(testRestoreOrigin)
  417. })
  418. .then(() => {
  419. // Import invalid changes
  420. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  421. const changes = [makeChange(Operation.addFile('../../a.tex', testFile))]
  422. return basicAuthClient.apis.ProjectImport.importChanges1({
  423. project_id: testProjectId,
  424. end_version: 0,
  425. changes: changes.map(changeToRaw),
  426. })
  427. })
  428. .then(() => {
  429. // Check that this fails and returns a 422
  430. expect.fail()
  431. })
  432. .catch(expectResponse.unprocessableEntity)
  433. })
  434. it('imports changes with git-bridge origin', function () {
  435. const testProjectId = '1'
  436. const testFilePathname = 'git.tex'
  437. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  438. const testGitOrigin = Origin.fromRaw({
  439. kind: 'git-bridge',
  440. })
  441. let testSnapshot
  442. return fetch(
  443. testServer.url(
  444. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  445. ),
  446. {
  447. method: 'PUT',
  448. body: fs.createReadStream(testFiles.path('empty.tex')),
  449. headers: {
  450. Authorization: testServer.basicAuthHeader,
  451. },
  452. }
  453. )
  454. .then(response => {
  455. expect(response.ok).to.be.true
  456. })
  457. .then(() => {
  458. testSnapshot = new Snapshot()
  459. testSnapshot.addFile(testFilePathname, testFile)
  460. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  461. project_id: testProjectId,
  462. snapshot: testSnapshot.toRaw(),
  463. })
  464. })
  465. .then(response => {
  466. expect(response.obj.projectId).to.equal(testProjectId)
  467. })
  468. .then(() => {
  469. const changes = [
  470. makeChange(Operation.addFile(testFilePathname, testFile)),
  471. ]
  472. changes[0].setOrigin(testGitOrigin)
  473. return basicAuthClient.apis.ProjectImport.importChanges1({
  474. project_id: testProjectId,
  475. end_version: 0,
  476. changes: changes.map(changeToRaw),
  477. })
  478. })
  479. .then(response => {
  480. expect(response.status).to.equal(HTTPStatus.CREATED)
  481. })
  482. .then(() => {
  483. return clientForProject.apis.Project.getLatestHistory({
  484. project_id: testProjectId,
  485. })
  486. })
  487. .then(response => {
  488. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  489. const changes = chunkResponse.getChunk().getChanges()
  490. expect(changes.length).to.be.at.least(1)
  491. const lastChange = changes[changes.length - 1]
  492. expect(lastChange.getOrigin()).to.deep.equal(testGitOrigin)
  493. })
  494. })
  495. it('preserves rangesHash when importing a file', async function () {
  496. const testProjectId = '1'
  497. const testFilePathname = 'main.tex'
  498. // hello.txt contains "Olá mundo\n" (10 UTF-8 chars).
  499. const rangesContent = JSON.stringify({
  500. comments: [
  501. {
  502. id: 'comment-1',
  503. ranges: [{ pos: 0, length: 3 }],
  504. resolved: false,
  505. },
  506. ],
  507. trackedChanges: [
  508. {
  509. range: { pos: 4, length: 5 },
  510. tracking: {
  511. type: 'insert',
  512. userId: 'user-1',
  513. ts: '2024-01-01T00:00:00.000Z',
  514. },
  515. },
  516. ],
  517. })
  518. const testRangesHash = blobHash.fromString(rangesContent)
  519. const testFile = File.fromHash(testFiles.HELLO_TXT_HASH, testRangesHash)
  520. const [contentResponse, rangesResponse] = await Promise.all([
  521. fetch(
  522. testServer.url(
  523. `/api/projects/${testProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  524. ),
  525. {
  526. method: 'PUT',
  527. body: fs.createReadStream(testFiles.path('hello.txt')),
  528. headers: { Authorization: testServer.basicAuthHeader },
  529. }
  530. ),
  531. fetch(
  532. testServer.url(
  533. `/api/projects/${testProjectId}/blobs/${testRangesHash}`
  534. ),
  535. {
  536. method: 'PUT',
  537. body: rangesContent,
  538. headers: { Authorization: testServer.basicAuthHeader },
  539. }
  540. ),
  541. ])
  542. expect(contentResponse.ok).to.be.true
  543. expect(rangesResponse.ok).to.be.true
  544. const testSnapshot = new Snapshot()
  545. testSnapshot.addFile(testFilePathname, testFile)
  546. const importResponse =
  547. await basicAuthClient.apis.ProjectImport.importSnapshot1({
  548. project_id: testProjectId,
  549. snapshot: testSnapshot.toRaw(),
  550. })
  551. expect(importResponse.obj.projectId).to.equal(testProjectId)
  552. const historyResponse =
  553. await clientForProject.apis.Project.getLatestHistory({
  554. project_id: testProjectId,
  555. })
  556. const chunk = ChunkResponse.fromRaw(historyResponse.obj).getChunk()
  557. const file = chunk.getSnapshot().getFile(testFilePathname)
  558. expect(file.getRangesHash()).to.equal(testRangesHash)
  559. })
  560. it('preserves projectVersion, v2DocVersions and timestamp on snapshot import', async function () {
  561. const testProjectId = '1'
  562. const testFilePathname = 'empty.tex'
  563. const testDocId = '000000000000000000000001'
  564. const testProjectVersion = '12345.0'
  565. const testV2DocVersions = new V2DocVersions({
  566. [testDocId]: { pathname: testFilePathname, v: 123 },
  567. })
  568. const testTimestamp = new Date('2024-01-01T00:00:00.000Z')
  569. const response = await fetch(
  570. testServer.url(
  571. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  572. ),
  573. {
  574. method: 'PUT',
  575. body: fs.createReadStream(testFiles.path('empty.tex')),
  576. headers: { Authorization: testServer.basicAuthHeader },
  577. }
  578. )
  579. expect(response.ok).to.be.true
  580. const testSnapshot = new Snapshot()
  581. testSnapshot.addFile(testFilePathname, File.fromHash(File.EMPTY_FILE_HASH))
  582. testSnapshot.setProjectVersion(testProjectVersion)
  583. testSnapshot.setV2DocVersions(testV2DocVersions)
  584. testSnapshot.setTimestamp(testTimestamp)
  585. const importResponse =
  586. await basicAuthClient.apis.ProjectImport.importSnapshot1({
  587. project_id: testProjectId,
  588. snapshot: testSnapshot.toRaw(),
  589. })
  590. expect(importResponse.obj.projectId).to.equal(testProjectId)
  591. const historyResponse =
  592. await clientForProject.apis.Project.getLatestHistory({
  593. project_id: testProjectId,
  594. })
  595. const snapshot = ChunkResponse.fromRaw(historyResponse.obj)
  596. .getChunk()
  597. .getSnapshot()
  598. expect(snapshot.getProjectVersion()).to.equal(testProjectVersion)
  599. expect(snapshot.getV2DocVersions()).to.deep.equal(testV2DocVersions)
  600. expect(snapshot.getTimestamp()?.toISOString()).to.equal(
  601. testTimestamp.toISOString()
  602. )
  603. })
  604. it('rejects text operations on binary files', function () {
  605. const testProjectId = '1'
  606. const testFilePathname = 'main.tex'
  607. const testTextOperation = TextOperation.fromJSON({ textOperation: ['bb'] })
  608. let testSnapshot
  609. return fetch(
  610. testServer.url(
  611. `/api/projects/${testProjectId}/blobs/${testFiles.NON_BMP_TXT_HASH}`
  612. ),
  613. {
  614. method: 'PUT',
  615. body: fs.createReadStream(testFiles.path('non_bmp.txt')),
  616. headers: {
  617. Authorization: testServer.basicAuthHeader,
  618. },
  619. }
  620. )
  621. .then(response => {
  622. expect(response.ok).to.be.true
  623. })
  624. .then(() => {
  625. // Import a project
  626. testSnapshot = new Snapshot()
  627. testSnapshot.addFile(
  628. testFilePathname,
  629. File.fromHash(testFiles.NON_BMP_TXT_HASH)
  630. )
  631. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  632. project_id: testProjectId,
  633. snapshot: testSnapshot.toRaw(),
  634. })
  635. })
  636. .then(response => {
  637. // Check that the project is valid
  638. expect(response.obj.projectId).to.equal(testProjectId)
  639. })
  640. .then(() => {
  641. // Import invalid changes
  642. const changes = [
  643. makeChange(Operation.editFile(testFilePathname, testTextOperation)),
  644. ]
  645. return basicAuthClient.apis.ProjectImport.importChanges1({
  646. project_id: testProjectId,
  647. end_version: 0,
  648. changes: changes.map(changeToRaw),
  649. })
  650. })
  651. .then(() => {
  652. // Expect invalid changes to fail
  653. expect.fail()
  654. })
  655. .catch(expectResponse.unprocessableEntity)
  656. .then(() => {
  657. // Get latest content
  658. return clientForProject.apis.Project.getLatestContent({
  659. project_id: testProjectId,
  660. })
  661. })
  662. .then(response => {
  663. // Check that no changes were stored
  664. const snapshot = Snapshot.fromRaw(response.obj)
  665. expect(snapshot.countFiles()).to.equal(1)
  666. expect(snapshot.getFile(testFilePathname).getHash()).to.equal(
  667. testFiles.NON_BMP_TXT_HASH
  668. )
  669. })
  670. })
  671. it('accepts text operation on files with null characters if stringLength is present', function () {
  672. const testProjectId = '1'
  673. const mainFilePathname = 'main.tex'
  674. const testTextOperation = TextOperation.fromJSON({
  675. textOperation: [3, 'a'],
  676. })
  677. let testSnapshot
  678. function importChanges() {
  679. const changes = [
  680. makeChange(Operation.editFile(mainFilePathname, testTextOperation)),
  681. ]
  682. return basicAuthClient.apis.ProjectImport.importChanges1({
  683. project_id: testProjectId,
  684. end_version: 0,
  685. changes: changes.map(changeToRaw),
  686. })
  687. }
  688. function getLatestContent() {
  689. return clientForProject.apis.Project.getLatestContent({
  690. project_id: testProjectId,
  691. })
  692. }
  693. return fetch(
  694. testServer.url(
  695. `/api/projects/${testProjectId}/blobs/${testFiles.NULL_CHARACTERS_TXT_HASH}`
  696. ),
  697. {
  698. method: 'PUT',
  699. body: fs.createReadStream(testFiles.path('null_characters.txt')),
  700. headers: {
  701. Authorization: testServer.basicAuthHeader,
  702. },
  703. }
  704. )
  705. .then(response => {
  706. expect(response.ok).to.be.true
  707. })
  708. .then(() => {
  709. // Import project
  710. testSnapshot = new Snapshot()
  711. testSnapshot.addFile(
  712. mainFilePathname,
  713. File.fromHash(testFiles.NULL_CHARACTERS_TXT_HASH)
  714. )
  715. return basicAuthClient.apis.ProjectImport.importSnapshot1({
  716. project_id: testProjectId,
  717. snapshot: testSnapshot.toRaw(),
  718. })
  719. })
  720. .then(importChanges)
  721. .then(() => {
  722. // Expect invalid changes to fail
  723. expect.fail()
  724. })
  725. .catch(expectResponse.unprocessableEntity)
  726. .then(getLatestContent)
  727. .then(response => {
  728. // Check that no changes were made
  729. const snapshot = Snapshot.fromRaw(response.obj)
  730. expect(snapshot.countFiles()).to.equal(1)
  731. expect(snapshot.getFile(mainFilePathname).getHash()).to.equal(
  732. testFiles.NULL_CHARACTERS_TXT_HASH
  733. )
  734. })
  735. .then(() => {
  736. // Set string length
  737. return knex('project_blobs').update(
  738. 'string_length',
  739. testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH
  740. )
  741. })
  742. .then(importChanges)
  743. .then(getLatestContent)
  744. .then(response => {
  745. const snapshot = Snapshot.fromRaw(response.obj)
  746. expect(snapshot.countFiles()).to.equal(1)
  747. expect(snapshot.getFile(mainFilePathname).getContent()).to.equal(
  748. '\x00\x00\x00a'
  749. )
  750. })
  751. })
  752. it('returns 404 when chunk is not found in bucket', function () {
  753. const testProjectId = '1'
  754. const fooChange = makeChange(Operation.removeFile('foo.tex'))
  755. return knex('chunks')
  756. .insert({
  757. doc_id: testProjectId,
  758. start_version: 0,
  759. end_version: 100,
  760. end_timestamp: null,
  761. })
  762. .then(() => {
  763. // Import changes
  764. return basicAuthClient.apis.ProjectImport.importChanges1({
  765. project_id: testProjectId,
  766. end_version: 100,
  767. changes: [fooChange.toRaw()],
  768. })
  769. })
  770. .then(() => {
  771. // Expect invalid changes to fail
  772. expect.fail()
  773. })
  774. .catch(expectResponse.notFound)
  775. })
  776. it('creates and returns changes with v2 author ids', function () {
  777. const testFilePathname = 'test.tex'
  778. const testTextOperation = TextOperation.fromJSON({ textOperation: ['a'] })
  779. const v2Authors = ['5a296963ad5e82432674c839', null]
  780. let testProjectId
  781. return testProjects
  782. .createEmptyProject()
  783. .then(projectId => {
  784. testProjectId = projectId
  785. expect(testProjectId).to.be.a('string')
  786. })
  787. .then(() => {
  788. return fetch(
  789. testServer.url(
  790. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  791. ),
  792. {
  793. method: 'PUT',
  794. body: fs.createReadStream(testFiles.path('empty.tex')),
  795. headers: {
  796. Authorization: testServer.basicAuthHeader,
  797. },
  798. }
  799. )
  800. })
  801. .then(response => {
  802. expect(response.ok).to.be.true
  803. })
  804. .then(() => {
  805. // Import changes
  806. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  807. const changes = [
  808. makeChange(Operation.addFile(testFilePathname, testFile)),
  809. makeChange(Operation.editFile(testFilePathname, testTextOperation)),
  810. ]
  811. changes[1].setV2Authors(v2Authors)
  812. return basicAuthClient.apis.ProjectImport.importChanges1({
  813. project_id: testProjectId,
  814. end_version: 0,
  815. return_snapshot: 'hashed',
  816. changes: changes.map(changeToRaw),
  817. })
  818. })
  819. .then(response => {
  820. expect(response.status).to.equal(HTTPStatus.CREATED)
  821. const snapshot = Snapshot.fromRaw(response.obj)
  822. expect(snapshot.countFiles()).to.equal(1)
  823. expect(snapshot.getFile('test.tex').getHash()).to.equal(
  824. testFiles.STRING_A_HASH
  825. )
  826. })
  827. .then(() => {
  828. // Get project history
  829. return pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({
  830. project_id: testProjectId,
  831. })
  832. })
  833. .then(response => {
  834. // it should not fail when the some of the authors do not exist anymore
  835. const chunkResponse = ChunkResponse.fromRaw(response.obj)
  836. const changes = chunkResponse.getChunk().getChanges()
  837. expect(changes.length).to.equal(2)
  838. const changeWithAuthor = changes[1]
  839. expect(changeWithAuthor.getV2Authors()).to.deep.equal(v2Authors)
  840. })
  841. })
  842. it('should reject invalid v2 author ids', function () {
  843. const testFilePathname = 'test.tex'
  844. const v2Authors = ['not-a-v2-id']
  845. let testProjectId
  846. return testProjects
  847. .createEmptyProject()
  848. .then(projectId => {
  849. testProjectId = projectId
  850. expect(testProjectId).to.be.a('string')
  851. })
  852. .then(() => {
  853. return fetch(
  854. testServer.url(
  855. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  856. ),
  857. {
  858. method: 'PUT',
  859. body: fs.createReadStream(testFiles.path('empty.tex')),
  860. headers: {
  861. Authorization: testServer.basicAuthHeader,
  862. },
  863. }
  864. )
  865. })
  866. .then(response => {
  867. expect(response.ok).to.be.true
  868. })
  869. .then(() => {
  870. // Import changes
  871. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  872. const changes = [
  873. makeChange(Operation.addFile(testFilePathname, testFile)),
  874. ]
  875. changes[0].v2Authors = v2Authors
  876. return basicAuthClient.apis.ProjectImport.importChanges1({
  877. project_id: testProjectId,
  878. end_version: 0,
  879. changes: changes.map(changeToRaw),
  880. })
  881. })
  882. .then(() => {
  883. // Check that invalid changes fail
  884. expect.fail()
  885. })
  886. .catch(expectResponse.unprocessableEntity)
  887. })
  888. it('should reject changes with both v1 and v2 authors ids', function () {
  889. const testFilePathname = 'test.tex'
  890. const v1Authors = [456]
  891. const v2Authors = ['5a296963ad5e82432674c839', null]
  892. let testProjectId
  893. return testProjects
  894. .createEmptyProject()
  895. .then(projectId => {
  896. testProjectId = projectId
  897. expect(testProjectId).to.be.a('string')
  898. })
  899. .then(() => {
  900. return fetch(
  901. testServer.url(
  902. `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
  903. ),
  904. {
  905. method: 'PUT',
  906. body: fs.createReadStream(testFiles.path('empty.tex')),
  907. headers: {
  908. Authorization: testServer.basicAuthHeader,
  909. },
  910. }
  911. )
  912. })
  913. .then(response => {
  914. expect(response.ok).to.be.true
  915. })
  916. .then(() => {
  917. // Import changes
  918. const testFile = File.fromHash(File.EMPTY_FILE_HASH)
  919. const changes = [
  920. makeChange(Operation.addFile(testFilePathname, testFile)),
  921. ]
  922. changes[0].authors = v1Authors
  923. changes[0].v2Authors = v2Authors
  924. return basicAuthClient.apis.ProjectImport.importChanges1({
  925. project_id: testProjectId,
  926. end_version: 0,
  927. changes: changes.map(changeToRaw),
  928. })
  929. })
  930. .then(() => {
  931. // Check that invalid changes fail
  932. expect.fail()
  933. })
  934. .catch(expectResponse.unprocessableEntity)
  935. })
  936. it("returns unprocessable if end_version isn't provided", function () {
  937. return testProjects
  938. .createEmptyProject()
  939. .then(projectId => {
  940. expect(projectId).to.be.a('string')
  941. return projectId
  942. })
  943. .then(projectId => {
  944. // Import changes
  945. return basicAuthClient.apis.ProjectImport.importChanges1({
  946. project_id: projectId,
  947. changes: [],
  948. })
  949. })
  950. .then(() => {
  951. // Check that invalid changes fail
  952. expect.fail()
  953. })
  954. .catch(error => {
  955. expect(error.message).to.equal('request failed with status 422')
  956. })
  957. })
  958. it('returns unprocessable if return_snapshot is invalid', function () {
  959. return testProjects
  960. .createEmptyProject()
  961. .then(projectId => {
  962. // Import changes
  963. return basicAuthClient.apis.ProjectImport.importChanges1({
  964. project_id: projectId,
  965. changes: [],
  966. end_version: 0,
  967. return_snapshot: 'not_a_valid_value',
  968. })
  969. })
  970. .then(() => {
  971. // Check that invalid changes fail
  972. expect.fail()
  973. })
  974. .catch(error => {
  975. expect(error.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY)
  976. })
  977. })
  978. })