SyncTests.js 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320
  1. import async from 'async'
  2. import nock from 'nock'
  3. import { expect } from 'chai'
  4. import request from 'request'
  5. import assert from 'node:assert'
  6. import mongodb from 'mongodb-legacy'
  7. import logger from '@overleaf/logger'
  8. import Settings from '@overleaf/settings'
  9. import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
  10. import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
  11. import sinon from 'sinon'
  12. const { ObjectId } = mongodb
  13. const EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
  14. const MockHistoryStore = () => nock('http://127.0.0.1:3100')
  15. const MockFileStore = () => nock('http://127.0.0.1:3009')
  16. const MockWeb = () => nock('http://127.0.0.1:3000')
  17. describe('Syncing with web and doc-updater', function () {
  18. const historyId = new ObjectId().toString()
  19. let loggerWarn, loggerError
  20. beforeEach(function () {
  21. loggerWarn = sinon.spy(logger, 'warn')
  22. loggerError = sinon.spy(logger, 'error')
  23. })
  24. afterEach(function () {
  25. loggerWarn.restore()
  26. loggerError.restore()
  27. })
  28. beforeEach(function (done) {
  29. this.timestamp = new Date()
  30. ProjectHistoryApp.ensureRunning(error => {
  31. if (error) {
  32. throw error
  33. }
  34. this.project_id = new ObjectId().toString()
  35. this.doc_id = new ObjectId().toString()
  36. this.file_id = new ObjectId().toString()
  37. MockHistoryStore().post('/api/projects').reply(200, {
  38. projectId: historyId,
  39. })
  40. MockWeb()
  41. .get(`/project/${this.project_id}/details`)
  42. .reply(200, {
  43. name: 'Test Project',
  44. overleaf: {
  45. history: {
  46. id: historyId,
  47. },
  48. },
  49. })
  50. ProjectHistoryClient.initializeProject(historyId, done)
  51. })
  52. })
  53. afterEach(function () {
  54. nock.cleanAll()
  55. })
  56. describe('resyncing project history', function () {
  57. describe('without project-history enabled', function () {
  58. beforeEach(function () {
  59. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(404)
  60. })
  61. it('404s if project-history is not enabled', function (done) {
  62. request.post(
  63. {
  64. url: `http://127.0.0.1:3054/project/${this.project_id}/resync`,
  65. },
  66. (error, res, body) => {
  67. if (error) {
  68. return done(error)
  69. }
  70. expect(res.statusCode).to.equal(404)
  71. done()
  72. }
  73. )
  74. })
  75. })
  76. describe('with project-history enabled', function () {
  77. beforeEach(function () {
  78. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204)
  79. })
  80. describe('when a doc is missing', function () {
  81. it('should send add doc updates to the history store', function (done) {
  82. MockHistoryStore()
  83. .get(`/api/projects/${historyId}/latest/history`)
  84. .reply(200, {
  85. chunk: {
  86. history: {
  87. snapshot: {
  88. files: {
  89. persistedDoc: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  90. },
  91. },
  92. changes: [],
  93. },
  94. startVersion: 0,
  95. },
  96. })
  97. MockHistoryStore()
  98. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  99. .reply(200, '')
  100. const createBlob = MockHistoryStore()
  101. .put(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`, '')
  102. .reply(201)
  103. const addFile = MockHistoryStore()
  104. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  105. expect(body).to.deep.equal([
  106. {
  107. v2Authors: [],
  108. authors: [],
  109. timestamp: this.timestamp.toJSON(),
  110. operations: [
  111. {
  112. pathname: 'main.tex',
  113. file: {
  114. hash: EMPTY_FILE_HASH,
  115. },
  116. },
  117. ],
  118. origin: { kind: 'test-origin' },
  119. },
  120. ])
  121. return true
  122. })
  123. .query({ end_version: 0 })
  124. .reply(204)
  125. async.series(
  126. [
  127. cb => {
  128. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  129. },
  130. cb => {
  131. const update = {
  132. projectHistoryId: historyId,
  133. resyncProjectStructure: {
  134. docs: [
  135. { path: '/main.tex', doc: this.doc_id },
  136. { path: '/persistedDoc', doc: 'other-doc-id' },
  137. ],
  138. files: [],
  139. },
  140. meta: {
  141. ts: this.timestamp,
  142. },
  143. }
  144. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  145. },
  146. cb => {
  147. ProjectHistoryClient.flushProject(this.project_id, cb)
  148. },
  149. ],
  150. error => {
  151. if (error) {
  152. return done(error)
  153. }
  154. assert(
  155. createBlob.isDone(),
  156. '/api/projects/:historyId/blobs/:hash should have been called'
  157. )
  158. assert(
  159. addFile.isDone(),
  160. `/api/projects/${historyId}/changes should have been called`
  161. )
  162. done()
  163. }
  164. )
  165. })
  166. })
  167. describe('when a file is missing', function () {
  168. it('should send add file updates to the history store', function (done) {
  169. MockHistoryStore()
  170. .get(`/api/projects/${historyId}/latest/history`)
  171. .reply(200, {
  172. chunk: {
  173. history: {
  174. snapshot: {
  175. files: {
  176. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  177. },
  178. },
  179. changes: [],
  180. },
  181. startVersion: 0,
  182. },
  183. })
  184. const fileContents = Buffer.from([1, 2, 3])
  185. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  186. MockFileStore()
  187. .get(`/project/${this.project_id}/file/${this.file_id}`)
  188. .reply(200, fileContents)
  189. const headBlob = MockHistoryStore()
  190. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  191. .reply(404)
  192. const createBlob = MockHistoryStore()
  193. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  194. .reply(201)
  195. const addFile = MockHistoryStore()
  196. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  197. expect(body).to.deep.equal([
  198. {
  199. v2Authors: [],
  200. authors: [],
  201. timestamp: this.timestamp.toJSON(),
  202. operations: [
  203. {
  204. pathname: 'test.png',
  205. file: {
  206. hash: fileHash,
  207. },
  208. },
  209. ],
  210. origin: { kind: 'test-origin' },
  211. },
  212. ])
  213. return true
  214. })
  215. .query({ end_version: 0 })
  216. .reply(204)
  217. async.series(
  218. [
  219. cb => {
  220. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  221. },
  222. cb => {
  223. const update = {
  224. projectHistoryId: historyId,
  225. resyncProjectStructure: {
  226. docs: [],
  227. files: [
  228. {
  229. file: this.file_id,
  230. path: '/test.png',
  231. _hash: fileHash,
  232. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  233. },
  234. { path: '/persistedFile' },
  235. ],
  236. },
  237. meta: {
  238. ts: this.timestamp,
  239. },
  240. }
  241. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  242. },
  243. cb => {
  244. ProjectHistoryClient.flushProject(this.project_id, cb)
  245. },
  246. ],
  247. error => {
  248. if (error) {
  249. throw error
  250. }
  251. assert(!loggerWarn.called, 'no warning logged on 404')
  252. assert(
  253. headBlob.isDone(),
  254. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  255. )
  256. assert(
  257. createBlob.isDone(),
  258. '/api/projects/:historyId/blobs/:hash should have been called'
  259. )
  260. assert(
  261. addFile.isDone(),
  262. `/api/projects/${historyId}/changes should have been called`
  263. )
  264. done()
  265. }
  266. )
  267. })
  268. it('should skip HEAD on blob without hash', function (done) {
  269. MockHistoryStore()
  270. .get(`/api/projects/${historyId}/latest/history`)
  271. .reply(200, {
  272. chunk: {
  273. history: {
  274. snapshot: {
  275. files: {
  276. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  277. },
  278. },
  279. changes: [],
  280. },
  281. startVersion: 0,
  282. },
  283. })
  284. const fileContents = Buffer.from([1, 2, 3])
  285. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  286. MockFileStore()
  287. .get(`/project/${this.project_id}/file/${this.file_id}`)
  288. .reply(200, fileContents)
  289. const headBlob = MockHistoryStore()
  290. .head(`/api/projects/${historyId}/blobs/undefined`)
  291. .reply(500)
  292. const createBlob = MockHistoryStore()
  293. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  294. .reply(201)
  295. const addFile = MockHistoryStore()
  296. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  297. expect(body).to.deep.equal([
  298. {
  299. v2Authors: [],
  300. authors: [],
  301. timestamp: this.timestamp.toJSON(),
  302. operations: [
  303. {
  304. pathname: 'test.png',
  305. file: {
  306. hash: fileHash,
  307. },
  308. },
  309. ],
  310. origin: { kind: 'test-origin' },
  311. },
  312. ])
  313. return true
  314. })
  315. .query({ end_version: 0 })
  316. .reply(204)
  317. async.series(
  318. [
  319. cb => {
  320. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  321. },
  322. cb => {
  323. const update = {
  324. projectHistoryId: historyId,
  325. resyncProjectStructure: {
  326. docs: [],
  327. files: [
  328. {
  329. file: this.file_id,
  330. path: '/test.png',
  331. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  332. },
  333. { path: '/persistedFile' },
  334. ],
  335. },
  336. meta: {
  337. ts: this.timestamp,
  338. },
  339. }
  340. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  341. },
  342. cb => {
  343. ProjectHistoryClient.flushProject(this.project_id, cb)
  344. },
  345. ],
  346. error => {
  347. if (error) {
  348. throw error
  349. }
  350. assert(!loggerWarn.called, 'no warning logged on 404')
  351. assert(
  352. !headBlob.isDone(),
  353. 'HEAD /api/projects/:historyId/blobs/:hash should have been skipped'
  354. )
  355. assert(
  356. createBlob.isDone(),
  357. '/api/projects/:historyId/blobs/:hash should have been called'
  358. )
  359. assert(
  360. addFile.isDone(),
  361. `/api/projects/${historyId}/changes should have been called`
  362. )
  363. done()
  364. }
  365. )
  366. })
  367. it('should record error when checking blob fails with 500', function (done) {
  368. MockHistoryStore()
  369. .get(`/api/projects/${historyId}/latest/history`)
  370. .reply(200, {
  371. chunk: {
  372. history: {
  373. snapshot: {
  374. files: {
  375. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  376. },
  377. },
  378. changes: [],
  379. },
  380. startVersion: 0,
  381. },
  382. })
  383. const fileContents = Buffer.from([1, 2, 3])
  384. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  385. MockFileStore()
  386. .get(`/project/${this.project_id}/file/${this.file_id}`)
  387. .reply(200, fileContents)
  388. const headBlob = MockHistoryStore()
  389. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  390. .reply(500)
  391. const createBlob = MockHistoryStore()
  392. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  393. .reply(201)
  394. const addFile = MockHistoryStore()
  395. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  396. expect(body).to.deep.equal([
  397. {
  398. v2Authors: [],
  399. authors: [],
  400. timestamp: this.timestamp.toJSON(),
  401. operations: [
  402. {
  403. pathname: 'test.png',
  404. file: {
  405. hash: fileHash,
  406. },
  407. },
  408. ],
  409. origin: { kind: 'test-origin' },
  410. },
  411. ])
  412. return true
  413. })
  414. .query({ end_version: 0 })
  415. .reply(204)
  416. async.series(
  417. [
  418. cb => {
  419. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  420. },
  421. cb => {
  422. const update = {
  423. projectHistoryId: historyId,
  424. resyncProjectStructure: {
  425. docs: [],
  426. files: [
  427. {
  428. file: this.file_id,
  429. path: '/test.png',
  430. _hash: fileHash,
  431. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  432. },
  433. { path: '/persistedFile' },
  434. ],
  435. },
  436. meta: {
  437. ts: this.timestamp,
  438. },
  439. }
  440. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  441. },
  442. cb => {
  443. ProjectHistoryClient.flushProject(
  444. this.project_id,
  445. {
  446. allowErrors: true,
  447. },
  448. (err, res) => {
  449. if (err) return cb(err)
  450. assert(res.statusCode === 500, 'resync should have failed')
  451. cb()
  452. }
  453. )
  454. },
  455. ],
  456. error => {
  457. if (error) {
  458. throw error
  459. }
  460. assert(
  461. loggerError.calledWithMatch(
  462. sinon.match.any,
  463. 'error checking whether blob exists'
  464. ),
  465. 'error logged on 500'
  466. )
  467. assert(
  468. headBlob.isDone(),
  469. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  470. )
  471. assert(
  472. !createBlob.isDone(),
  473. '/api/projects/:historyId/blobs/:hash should have been skipped'
  474. )
  475. assert(
  476. !addFile.isDone(),
  477. `/api/projects/${historyId}/changes should have been skipped`
  478. )
  479. done()
  480. }
  481. )
  482. })
  483. it('should skip blob write when blob exists', function (done) {
  484. MockHistoryStore()
  485. .get(`/api/projects/${historyId}/latest/history`)
  486. .reply(200, {
  487. chunk: {
  488. history: {
  489. snapshot: {
  490. files: {
  491. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  492. },
  493. },
  494. changes: [],
  495. },
  496. startVersion: 0,
  497. },
  498. })
  499. const fileContents = Buffer.from([1, 2, 3])
  500. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  501. MockFileStore()
  502. .get(`/project/${this.project_id}/file/${this.file_id}`)
  503. .reply(200, fileContents)
  504. const headBlob = MockHistoryStore()
  505. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  506. .reply(200)
  507. const createBlob = MockHistoryStore()
  508. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  509. .reply(201)
  510. const addFile = MockHistoryStore()
  511. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  512. expect(body).to.deep.equal([
  513. {
  514. v2Authors: [],
  515. authors: [],
  516. timestamp: this.timestamp.toJSON(),
  517. operations: [
  518. {
  519. pathname: 'test.png',
  520. file: {
  521. hash: fileHash,
  522. },
  523. },
  524. ],
  525. origin: { kind: 'test-origin' },
  526. },
  527. ])
  528. return true
  529. })
  530. .query({ end_version: 0 })
  531. .reply(204)
  532. async.series(
  533. [
  534. cb => {
  535. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  536. },
  537. cb => {
  538. const update = {
  539. projectHistoryId: historyId,
  540. resyncProjectStructure: {
  541. docs: [],
  542. files: [
  543. {
  544. file: this.file_id,
  545. path: '/test.png',
  546. _hash: fileHash,
  547. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  548. },
  549. { path: '/persistedFile' },
  550. ],
  551. },
  552. meta: {
  553. ts: this.timestamp,
  554. },
  555. }
  556. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  557. },
  558. cb => {
  559. ProjectHistoryClient.flushProject(this.project_id, cb)
  560. },
  561. ],
  562. error => {
  563. if (error) {
  564. throw error
  565. }
  566. assert(!loggerWarn.called, 'no warning logged on 404')
  567. assert(
  568. headBlob.isDone(),
  569. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  570. )
  571. assert(
  572. !createBlob.isDone(),
  573. '/api/projects/:historyId/blobs/:hash should have been skipped'
  574. )
  575. assert(
  576. addFile.isDone(),
  577. `/api/projects/${historyId}/changes should have been called`
  578. )
  579. done()
  580. }
  581. )
  582. })
  583. it('should add file w/o url', function (done) {
  584. MockHistoryStore()
  585. .get(`/api/projects/${historyId}/latest/history`)
  586. .reply(200, {
  587. chunk: {
  588. history: {
  589. snapshot: {
  590. files: {
  591. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  592. },
  593. },
  594. changes: [],
  595. },
  596. startVersion: 0,
  597. },
  598. })
  599. const fileContents = Buffer.from([1, 2, 3])
  600. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  601. MockFileStore()
  602. .get(`/project/${this.project_id}/file/${this.file_id}`)
  603. .reply(200, fileContents)
  604. const headBlob = MockHistoryStore()
  605. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  606. .reply(200)
  607. const createBlob = MockHistoryStore()
  608. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  609. .reply(201)
  610. const addFile = MockHistoryStore()
  611. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  612. expect(body).to.deep.equal([
  613. {
  614. v2Authors: [],
  615. authors: [],
  616. timestamp: this.timestamp.toJSON(),
  617. operations: [
  618. {
  619. pathname: 'test.png',
  620. file: {
  621. hash: fileHash,
  622. },
  623. },
  624. ],
  625. origin: { kind: 'test-origin' },
  626. },
  627. ])
  628. return true
  629. })
  630. .query({ end_version: 0 })
  631. .reply(204)
  632. async.series(
  633. [
  634. cb => {
  635. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  636. },
  637. cb => {
  638. const update = {
  639. projectHistoryId: historyId,
  640. resyncProjectStructure: {
  641. docs: [],
  642. files: [
  643. {
  644. file: this.file_id,
  645. path: '/test.png',
  646. _hash: fileHash,
  647. createdBlob: true,
  648. },
  649. { path: '/persistedFile' },
  650. ],
  651. },
  652. meta: {
  653. ts: this.timestamp,
  654. },
  655. }
  656. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  657. },
  658. cb => {
  659. ProjectHistoryClient.flushProject(this.project_id, cb)
  660. },
  661. ],
  662. error => {
  663. if (error) {
  664. throw error
  665. }
  666. assert(!loggerWarn.called, 'no warning logged on 404')
  667. assert(
  668. headBlob.isDone(),
  669. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  670. )
  671. assert(
  672. !createBlob.isDone(),
  673. '/api/projects/:historyId/blobs/:hash should have been skipped'
  674. )
  675. assert(
  676. addFile.isDone(),
  677. `/api/projects/${historyId}/changes should have been called`
  678. )
  679. done()
  680. }
  681. )
  682. })
  683. describe('with filestore disabled', function () {
  684. before(function () {
  685. Settings.apis.filestore.enabled = false
  686. })
  687. after(function () {
  688. Settings.apis.filestore.enabled = true
  689. })
  690. it('should record error when blob is missing', function (done) {
  691. MockHistoryStore()
  692. .get(`/api/projects/${historyId}/latest/history`)
  693. .reply(200, {
  694. chunk: {
  695. history: {
  696. snapshot: {
  697. files: {
  698. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  699. },
  700. },
  701. changes: [],
  702. },
  703. startVersion: 0,
  704. },
  705. })
  706. const fileContents = Buffer.from([1, 2, 3])
  707. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  708. MockFileStore()
  709. .get(`/project/${this.project_id}/file/${this.file_id}`)
  710. .reply(200, fileContents)
  711. const headBlob = MockHistoryStore()
  712. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  713. .times(3) // three retries
  714. .reply(404)
  715. const createBlob = MockHistoryStore()
  716. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  717. .reply(201)
  718. const addFile = MockHistoryStore()
  719. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  720. expect(body).to.deep.equal([
  721. {
  722. v2Authors: [],
  723. authors: [],
  724. timestamp: this.timestamp.toJSON(),
  725. operations: [
  726. {
  727. pathname: 'test.png',
  728. file: {
  729. hash: fileHash,
  730. },
  731. },
  732. ],
  733. origin: { kind: 'test-origin' },
  734. },
  735. ])
  736. return true
  737. })
  738. .query({ end_version: 0 })
  739. .reply(204)
  740. async.series(
  741. [
  742. cb => {
  743. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  744. },
  745. cb => {
  746. const update = {
  747. projectHistoryId: historyId,
  748. resyncProjectStructure: {
  749. docs: [],
  750. files: [
  751. {
  752. file: this.file_id,
  753. path: '/test.png',
  754. _hash: fileHash,
  755. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  756. },
  757. { path: '/persistedFile' },
  758. ],
  759. },
  760. meta: {
  761. ts: this.timestamp,
  762. },
  763. }
  764. ProjectHistoryClient.pushRawUpdate(
  765. this.project_id,
  766. update,
  767. cb
  768. )
  769. },
  770. cb => {
  771. ProjectHistoryClient.flushProject(
  772. this.project_id,
  773. {
  774. allowErrors: true,
  775. },
  776. (err, res) => {
  777. if (err) return cb(err)
  778. assert(
  779. res.statusCode === 500,
  780. 'resync should have failed'
  781. )
  782. cb()
  783. }
  784. )
  785. },
  786. ],
  787. error => {
  788. if (error) {
  789. throw error
  790. }
  791. assert(
  792. loggerError.calledWithMatch(
  793. sinon.match.any,
  794. 'blocking filestore read'
  795. ),
  796. 'error logged on 500'
  797. )
  798. assert(
  799. headBlob.isDone(),
  800. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  801. )
  802. assert(
  803. !createBlob.isDone(),
  804. '/api/projects/:historyId/blobs/:hash should have been skipped'
  805. )
  806. assert(
  807. !addFile.isDone(),
  808. `/api/projects/${historyId}/changes should have been skipped`
  809. )
  810. done()
  811. }
  812. )
  813. })
  814. })
  815. })
  816. describe('when a file hash mismatches', function () {
  817. it('should remove and re-add file w/o url', function (done) {
  818. MockHistoryStore()
  819. .get(`/api/projects/${historyId}/latest/history`)
  820. .reply(200, {
  821. chunk: {
  822. history: {
  823. snapshot: {
  824. files: {
  825. 'test.png': { hash: EMPTY_FILE_HASH, byteLength: 0 },
  826. },
  827. },
  828. changes: [],
  829. },
  830. startVersion: 0,
  831. },
  832. })
  833. const fileContents = Buffer.from([1, 2, 3])
  834. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  835. MockFileStore()
  836. .get(`/project/${this.project_id}/file/${this.file_id}`)
  837. .reply(200, fileContents)
  838. const headBlob = MockHistoryStore()
  839. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  840. .reply(200)
  841. const createBlob = MockHistoryStore()
  842. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  843. .reply(201)
  844. const addFile = MockHistoryStore()
  845. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  846. expect(body).to.deep.equal([
  847. {
  848. v2Authors: [],
  849. authors: [],
  850. timestamp: this.timestamp.toJSON(),
  851. operations: [
  852. {
  853. pathname: 'test.png',
  854. newPathname: '',
  855. },
  856. ],
  857. origin: { kind: 'test-origin' },
  858. },
  859. {
  860. v2Authors: [],
  861. authors: [],
  862. timestamp: this.timestamp.toJSON(),
  863. operations: [
  864. {
  865. pathname: 'test.png',
  866. file: {
  867. hash: fileHash,
  868. },
  869. },
  870. ],
  871. origin: { kind: 'test-origin' },
  872. },
  873. ])
  874. return true
  875. })
  876. .query({ end_version: 0 })
  877. .reply(204)
  878. async.series(
  879. [
  880. cb => {
  881. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  882. },
  883. cb => {
  884. const update = {
  885. projectHistoryId: historyId,
  886. resyncProjectStructure: {
  887. docs: [],
  888. files: [
  889. {
  890. file: this.file_id,
  891. path: '/test.png',
  892. _hash: fileHash,
  893. createdBlob: true,
  894. },
  895. ],
  896. },
  897. meta: {
  898. ts: this.timestamp,
  899. },
  900. }
  901. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  902. },
  903. cb => {
  904. ProjectHistoryClient.flushProject(this.project_id, cb)
  905. },
  906. ],
  907. error => {
  908. if (error) {
  909. throw error
  910. }
  911. assert(!loggerWarn.called, 'no warning logged on 404')
  912. assert(
  913. headBlob.isDone(),
  914. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  915. )
  916. assert(
  917. !createBlob.isDone(),
  918. '/api/projects/:historyId/blobs/:hash should have been skipped'
  919. )
  920. assert(
  921. addFile.isDone(),
  922. `/api/projects/${historyId}/changes should have been called`
  923. )
  924. done()
  925. }
  926. )
  927. })
  928. })
  929. describe("when a file exists which shouldn't", function () {
  930. it('should send remove file updates to the history store', function (done) {
  931. MockHistoryStore()
  932. .get(`/api/projects/${historyId}/latest/history`)
  933. .reply(200, {
  934. chunk: {
  935. history: {
  936. snapshot: {
  937. files: {
  938. docToKeep: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  939. docToDelete: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  940. },
  941. },
  942. changes: [],
  943. },
  944. startVersion: 0,
  945. },
  946. })
  947. MockHistoryStore()
  948. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  949. .reply(200, '')
  950. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  951. .reply(200, '') // blob is requested once for each file
  952. const deleteFile = MockHistoryStore()
  953. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  954. expect(body).to.deep.equal([
  955. {
  956. v2Authors: [],
  957. authors: [],
  958. timestamp: this.timestamp.toJSON(),
  959. operations: [
  960. {
  961. pathname: 'docToDelete',
  962. newPathname: '',
  963. },
  964. ],
  965. origin: { kind: 'test-origin' },
  966. },
  967. ])
  968. return true
  969. })
  970. .query({ end_version: 0 })
  971. .reply(204)
  972. async.series(
  973. [
  974. cb => {
  975. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  976. },
  977. cb => {
  978. const update = {
  979. projectHistoryId: historyId,
  980. resyncProjectStructure: {
  981. docs: [{ path: 'docToKeep' }],
  982. files: [],
  983. },
  984. meta: {
  985. ts: this.timestamp,
  986. },
  987. }
  988. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  989. },
  990. cb => {
  991. ProjectHistoryClient.flushProject(this.project_id, cb)
  992. },
  993. ],
  994. error => {
  995. if (error) {
  996. throw error
  997. }
  998. assert(
  999. deleteFile.isDone(),
  1000. `/api/projects/${historyId}/changes should have been called`
  1001. )
  1002. done()
  1003. }
  1004. )
  1005. })
  1006. })
  1007. describe("when a doc's contents is not up to date", function () {
  1008. beforeEach(function () {
  1009. MockHistoryStore()
  1010. .get(`/api/projects/${historyId}/latest/history`)
  1011. .reply(200, {
  1012. chunk: {
  1013. history: {
  1014. snapshot: {
  1015. files: {
  1016. 'main.tex': {
  1017. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1018. stringLength: 3,
  1019. },
  1020. },
  1021. },
  1022. changes: [],
  1023. },
  1024. startVersion: 0,
  1025. },
  1026. })
  1027. MockHistoryStore()
  1028. .get(
  1029. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  1030. )
  1031. .reply(200, 'a\nb')
  1032. })
  1033. it('should send test updates to the history store', function (done) {
  1034. const addFile = MockHistoryStore()
  1035. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1036. expect(body).to.deep.equal([
  1037. {
  1038. v2Authors: [],
  1039. authors: [],
  1040. timestamp: this.timestamp.toJSON(),
  1041. operations: [
  1042. {
  1043. pathname: 'main.tex',
  1044. textOperation: [3, '\nc'],
  1045. },
  1046. ],
  1047. origin: { kind: 'test-origin' },
  1048. },
  1049. ])
  1050. return true
  1051. })
  1052. .query({ end_version: 0 })
  1053. .reply(204)
  1054. async.series(
  1055. [
  1056. cb => {
  1057. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  1058. },
  1059. cb => {
  1060. const update = {
  1061. projectHistoryId: historyId,
  1062. resyncProjectStructure: {
  1063. docs: [{ path: '/main.tex' }],
  1064. files: [],
  1065. },
  1066. meta: {
  1067. ts: this.timestamp,
  1068. },
  1069. }
  1070. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1071. },
  1072. cb => {
  1073. const update = {
  1074. path: '/main.tex',
  1075. projectHistoryId: historyId,
  1076. resyncDocContent: {
  1077. content: 'a\nb\nc',
  1078. },
  1079. doc: this.doc_id,
  1080. meta: {
  1081. ts: this.timestamp,
  1082. },
  1083. }
  1084. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1085. },
  1086. cb => {
  1087. ProjectHistoryClient.flushProject(this.project_id, cb)
  1088. },
  1089. ],
  1090. error => {
  1091. if (error) {
  1092. throw error
  1093. }
  1094. assert(
  1095. addFile.isDone(),
  1096. `/api/projects/${historyId}/changes should have been called`
  1097. )
  1098. done()
  1099. }
  1100. )
  1101. })
  1102. it('should strip non-BMP characters in updates before sending to the history store', function (done) {
  1103. const addFile = MockHistoryStore()
  1104. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1105. expect(body).to.deep.equal([
  1106. {
  1107. v2Authors: [],
  1108. authors: [],
  1109. timestamp: this.timestamp.toJSON(),
  1110. operations: [
  1111. {
  1112. pathname: 'main.tex',
  1113. textOperation: [3, '\n\uFFFD\uFFFDc'],
  1114. },
  1115. ],
  1116. origin: { kind: 'test-origin' },
  1117. },
  1118. ])
  1119. return true
  1120. })
  1121. .query({ end_version: 0 })
  1122. .reply(204)
  1123. async.series(
  1124. [
  1125. cb => {
  1126. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  1127. },
  1128. cb => {
  1129. const update = {
  1130. projectHistoryId: historyId,
  1131. resyncProjectStructure: {
  1132. docs: [{ path: '/main.tex' }],
  1133. files: [],
  1134. },
  1135. meta: {
  1136. ts: this.timestamp,
  1137. },
  1138. }
  1139. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1140. },
  1141. cb => {
  1142. const update = {
  1143. path: '/main.tex',
  1144. projectHistoryId: historyId,
  1145. resyncDocContent: {
  1146. content: 'a\nb\n\uD800\uDC00c',
  1147. },
  1148. doc: this.doc_id,
  1149. meta: {
  1150. ts: this.timestamp,
  1151. },
  1152. }
  1153. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1154. },
  1155. cb => {
  1156. ProjectHistoryClient.flushProject(this.project_id, cb)
  1157. },
  1158. ],
  1159. error => {
  1160. if (error) {
  1161. throw error
  1162. }
  1163. assert(
  1164. addFile.isDone(),
  1165. `/api/projects/${historyId}/changes should have been called`
  1166. )
  1167. done()
  1168. }
  1169. )
  1170. })
  1171. it('should fix comments in the history store', function (done) {
  1172. const commentId = 'comment-id'
  1173. const addComment = MockHistoryStore()
  1174. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1175. expect(body).to.deep.equal([
  1176. {
  1177. v2Authors: [],
  1178. authors: [],
  1179. timestamp: this.timestamp.toJSON(),
  1180. operations: [
  1181. {
  1182. pathname: 'main.tex',
  1183. commentId,
  1184. ranges: [{ pos: 1, length: 10 }],
  1185. },
  1186. ],
  1187. origin: { kind: 'test-origin' },
  1188. },
  1189. ])
  1190. return true
  1191. })
  1192. .query({ end_version: 0 })
  1193. .reply(204)
  1194. async.series(
  1195. [
  1196. cb => {
  1197. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  1198. },
  1199. cb => {
  1200. const update = {
  1201. projectHistoryId: historyId,
  1202. resyncProjectStructure: {
  1203. docs: [{ path: '/main.tex' }],
  1204. files: [],
  1205. },
  1206. meta: {
  1207. ts: this.timestamp,
  1208. },
  1209. }
  1210. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1211. },
  1212. cb => {
  1213. const update = {
  1214. path: '/main.tex',
  1215. projectHistoryId: historyId,
  1216. resyncDocContent: {
  1217. content: 'a\nb',
  1218. ranges: {
  1219. comments: [
  1220. {
  1221. id: commentId,
  1222. op: {
  1223. c: 'a',
  1224. p: 0,
  1225. hpos: 1,
  1226. hlen: 10,
  1227. t: commentId,
  1228. },
  1229. meta: {
  1230. user_id: 'user-id',
  1231. ts: this.timestamp,
  1232. },
  1233. },
  1234. ],
  1235. },
  1236. },
  1237. doc: this.doc_id,
  1238. meta: {
  1239. ts: this.timestamp,
  1240. },
  1241. }
  1242. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  1243. },
  1244. cb => {
  1245. ProjectHistoryClient.flushProject(this.project_id, cb)
  1246. },
  1247. ],
  1248. error => {
  1249. if (error) {
  1250. return done(error)
  1251. }
  1252. assert(
  1253. addComment.isDone(),
  1254. `/api/projects/${historyId}/changes should have been called`
  1255. )
  1256. done()
  1257. }
  1258. )
  1259. })
  1260. })
  1261. })
  1262. })
  1263. })