projects.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. 'use strict'
  2. const { expect } = require('chai')
  3. const fs = require('node:fs')
  4. const HTTPStatus = require('http-status')
  5. const fetch = require('node-fetch')
  6. const sinon = require('sinon')
  7. const cleanup = require('../storage/support/cleanup')
  8. const fixtures = require('../storage/support/fixtures')
  9. const testFiles = require('../storage/support/test_files')
  10. const {
  11. zipStore,
  12. BlobStore,
  13. persistChanges,
  14. redisBuffer,
  15. } = require('../../../../storage')
  16. const { expectHttpError } = require('./support/expect_response')
  17. const testServer = require('./support/test_server')
  18. const { createEmptyProject } = require('./support/test_projects')
  19. const {
  20. File,
  21. Snapshot,
  22. Change,
  23. AddFileOperation,
  24. EditFileOperation,
  25. TextOperation,
  26. } = require('overleaf-editor-core')
  27. const testProjects = require('./support/test_projects')
  28. describe('project controller', function () {
  29. beforeEach(cleanup.everything)
  30. beforeEach(fixtures.create)
  31. describe('initializeProject', function () {
  32. it('can initialize a new project', async function () {
  33. const projectId = await testProjects.createEmptyProject()
  34. expect(projectId).to.be.a('string')
  35. })
  36. })
  37. describe('createZip', function () {
  38. let importSnapshot
  39. let createZip
  40. before(function () {
  41. importSnapshot =
  42. testServer.basicAuthClient.apis.ProjectImport.importSnapshot1
  43. createZip = testServer.basicAuthClient.apis.Project.createZip
  44. })
  45. beforeEach(function () {
  46. // Don't start the work in the background in this test --- it is flaky.
  47. sinon.stub(zipStore, 'storeZip').resolves()
  48. })
  49. afterEach(function () {
  50. zipStore.storeZip.restore()
  51. })
  52. it('creates a URL to a zip file', async function () {
  53. // Create a test blob.
  54. const testProjectId = fixtures.docs.uninitializedProject.id
  55. const response = await fetch(
  56. testServer.url(
  57. `/api/projects/${testProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  58. ),
  59. {
  60. method: 'PUT',
  61. body: fs.createReadStream(testFiles.path('hello.txt')),
  62. headers: {
  63. Authorization: testServer.basicAuthHeader,
  64. },
  65. }
  66. )
  67. expect(response.ok).to.be.true
  68. // Import a project with the test blob.
  69. const testFilePathname = 'hello.txt'
  70. const testSnapshot = new Snapshot()
  71. testSnapshot.addFile(
  72. testFilePathname,
  73. File.fromHash(testFiles.HELLO_TXT_HASH)
  74. )
  75. const importResponse = await importSnapshot({
  76. project_id: testProjectId,
  77. snapshot: testSnapshot.toRaw(),
  78. })
  79. expect(importResponse.obj.projectId).to.equal(testProjectId)
  80. const createZipResponse = await createZip({
  81. project_id: testProjectId,
  82. version: 0,
  83. })
  84. expect(createZipResponse.status).to.equal(HTTPStatus.OK)
  85. const zipInfo = createZipResponse.obj
  86. expect(zipInfo.zipUrl).to.match(
  87. /^http:\/\/gcs:9090\/download\/storage\/v1\/b\/overleaf-test-zips/
  88. )
  89. expect(zipStore.storeZip.calledOnce).to.be.true
  90. })
  91. })
  92. // eslint-disable-next-line mocha/no-skipped-tests
  93. describe.skip('getLatestContent', function () {
  94. // TODO: remove this endpoint entirely, see
  95. // https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
  96. })
  97. describe('project with changes', function () {
  98. let projectId
  99. beforeEach(async function () {
  100. // used to provide a limit which forces us to persist all of the changes.
  101. const farFuture = new Date()
  102. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  103. const limits = {
  104. minChangeTimestamp: farFuture,
  105. maxChangeTimestamp: farFuture,
  106. }
  107. const changes = [
  108. new Change(
  109. [new AddFileOperation('test.tex', File.fromString('ab'))],
  110. new Date(),
  111. []
  112. ),
  113. new Change(
  114. [new AddFileOperation('other.tex', File.fromString('hello'))],
  115. new Date(),
  116. []
  117. ),
  118. ]
  119. projectId = await createEmptyProject()
  120. await persistChanges(projectId, changes, limits, 0)
  121. })
  122. describe('getLatestHashedContent', function () {
  123. it('returns a snapshot', async function () {
  124. const response =
  125. await testServer.basicAuthClient.apis.Project.getLatestHashedContent({
  126. project_id: projectId,
  127. })
  128. expect(response.status).to.equal(HTTPStatus.OK)
  129. const snapshot = Snapshot.fromRaw(response.obj)
  130. expect(snapshot.countFiles()).to.equal(2)
  131. expect(snapshot.getFile('test.tex').getHash()).to.equal(
  132. testFiles.STRING_AB_HASH
  133. )
  134. })
  135. })
  136. describe('getChanges', function () {
  137. it('returns all changes when not given a limit', async function () {
  138. const response =
  139. await testServer.basicAuthClient.apis.Project.getChanges({
  140. project_id: projectId,
  141. })
  142. expect(response.status).to.equal(HTTPStatus.OK)
  143. const { changes, hasMore } = response.obj
  144. expect(changes.length).to.equal(2)
  145. const filenames = changes
  146. .flatMap(change => change.operations)
  147. .map(operation => operation.pathname)
  148. expect(filenames).to.deep.equal(['test.tex', 'other.tex'])
  149. expect(hasMore).to.be.false
  150. })
  151. it('returns only requested changes', async function () {
  152. const response =
  153. await testServer.basicAuthClient.apis.Project.getChanges({
  154. project_id: projectId,
  155. since: 1,
  156. })
  157. expect(response.status).to.equal(HTTPStatus.OK)
  158. const { changes, hasMore } = response.obj
  159. expect(changes.length).to.equal(1)
  160. const filenames = changes
  161. .flatMap(change => change.operations)
  162. .map(operation => operation.pathname)
  163. expect(filenames).to.deep.equal(['other.tex'])
  164. expect(hasMore).to.be.false
  165. })
  166. it('rejects negative versions', async function () {
  167. await expect(
  168. testServer.basicAuthClient.apis.Project.getChanges({
  169. project_id: projectId,
  170. since: -1,
  171. })
  172. ).to.be.rejectedWith('Bad Request')
  173. })
  174. it('rejects out of bounds versions', async function () {
  175. await expect(
  176. testServer.basicAuthClient.apis.Project.getChanges({
  177. project_id: projectId,
  178. since: 20,
  179. })
  180. ).to.be.rejectedWith('Bad Request')
  181. })
  182. })
  183. describe('project with many chunks', function () {
  184. let projectId, changes
  185. beforeEach(async function () {
  186. // used to provide a limit which forces us to persist all of the changes.
  187. const farFuture = new Date()
  188. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  189. const limits = {
  190. minChangeTimestamp: farFuture,
  191. maxChangeTimestamp: farFuture,
  192. maxChunkChanges: 5,
  193. }
  194. projectId = await createEmptyProject()
  195. const blobStore = new BlobStore(projectId)
  196. const blob = await blobStore.putString('')
  197. changes = [
  198. new Change(
  199. [new AddFileOperation('test.tex', File.createLazyFromBlobs(blob))],
  200. new Date(),
  201. []
  202. ),
  203. ]
  204. for (let i = 0; i < 20; i++) {
  205. const textOperation = new TextOperation()
  206. textOperation.retain(i)
  207. textOperation.insert('x')
  208. changes.push(
  209. new Change(
  210. [new EditFileOperation('test.tex', textOperation)],
  211. new Date(),
  212. []
  213. )
  214. )
  215. }
  216. await persistChanges(projectId, changes, limits, 0)
  217. })
  218. it('returns the first chunk when not given a limit', async function () {
  219. const response =
  220. await testServer.basicAuthClient.apis.Project.getChanges({
  221. project_id: projectId,
  222. })
  223. expect(response.status).to.equal(HTTPStatus.OK)
  224. expect(response.obj).to.deep.equal({
  225. changes: changes.slice(0, 5).map(c => c.toRaw()),
  226. hasMore: true,
  227. })
  228. })
  229. it('returns only requested changes', async function () {
  230. const response =
  231. await testServer.basicAuthClient.apis.Project.getChanges({
  232. project_id: projectId,
  233. since: 12,
  234. })
  235. expect(response.status).to.equal(HTTPStatus.OK)
  236. expect(response.obj).to.deep.equal({
  237. changes: changes.slice(12, 15).map(c => c.toRaw()),
  238. hasMore: true,
  239. })
  240. })
  241. it('returns changes in the latest chunk', async function () {
  242. const response =
  243. await testServer.basicAuthClient.apis.Project.getChanges({
  244. project_id: projectId,
  245. since: 20,
  246. })
  247. expect(response.status).to.equal(HTTPStatus.OK)
  248. expect(response.obj).to.deep.equal({
  249. changes: changes.slice(20).map(c => c.toRaw()),
  250. hasMore: false,
  251. })
  252. })
  253. })
  254. })
  255. describe('getLatestHistoryRaw', function () {
  256. it('should handles read', async function () {
  257. const projectId = fixtures.docs.initializedProject.id
  258. const response =
  259. await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw(
  260. {
  261. project_id: projectId,
  262. readOnly: 'true',
  263. }
  264. )
  265. expect(response.body).to.deep.equal({
  266. startVersion: 0,
  267. endVersion: 1,
  268. endTimestamp: '2032-01-01T00:00:00.000Z',
  269. })
  270. })
  271. })
  272. describe('deleteProject', function () {
  273. it('deletes the project chunks', async function () {
  274. const projectId = fixtures.docs.initializedProject.id
  275. const historyResponse =
  276. await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory(
  277. {
  278. project_id: projectId,
  279. }
  280. )
  281. expect(historyResponse.status).to.equal(HTTPStatus.OK)
  282. expect(historyResponse.body).to.have.property('chunk')
  283. const deleteResponse =
  284. await testServer.basicAuthClient.apis.Project.deleteProject({
  285. project_id: projectId,
  286. })
  287. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  288. await expectHttpError(
  289. testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({
  290. project_id: projectId,
  291. }),
  292. HTTPStatus.NOT_FOUND
  293. )
  294. })
  295. it('deletes the project blobs', async function () {
  296. const projectId = fixtures.docs.initializedProject.id
  297. const token = testServer.createTokenForProject(projectId)
  298. const authHeaders = { Authorization: `Bearer ${token}` }
  299. const hash = testFiles.HELLO_TXT_HASH
  300. const fileContents = await fs.promises.readFile(
  301. testFiles.path('hello.txt')
  302. )
  303. const blobUrl = testServer.url(`/api/projects/${projectId}/blobs/${hash}`)
  304. const response1 = await fetch(blobUrl, {
  305. method: 'PUT',
  306. headers: authHeaders,
  307. body: fileContents,
  308. })
  309. expect(response1.ok).to.be.true
  310. const response2 = await fetch(blobUrl, { headers: authHeaders })
  311. const payload = await response2.text()
  312. expect(payload).to.equal(fileContents.toString())
  313. const deleteResponse =
  314. await testServer.basicAuthClient.apis.Project.deleteProject({
  315. project_id: projectId,
  316. })
  317. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  318. const response3 = await fetch(blobUrl, { headers: authHeaders })
  319. expect(response3.status).to.equal(HTTPStatus.NOT_FOUND)
  320. })
  321. it('deletes the project from the redis buffer', async function () {
  322. const projectId = await createEmptyProject()
  323. const blobStore = new BlobStore(projectId)
  324. const blob = await blobStore.putString('this is a test')
  325. const snapshot = new Snapshot()
  326. const change = new Change(
  327. [new AddFileOperation('test.tex', File.createLazyFromBlobs(blob))],
  328. new Date(),
  329. []
  330. )
  331. await redisBuffer.queueChanges(projectId, snapshot, 0, [change])
  332. const changesBefore = await redisBuffer.getNonPersistedChanges(
  333. projectId,
  334. 0
  335. )
  336. expect(changesBefore.length).to.equal(1)
  337. const deleteResponse =
  338. await testServer.basicAuthClient.apis.Project.deleteProject({
  339. project_id: projectId,
  340. })
  341. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  342. const changesAfter = await redisBuffer.getNonPersistedChanges(
  343. projectId,
  344. 0
  345. )
  346. expect(changesAfter.length).to.equal(0)
  347. const finalState = await redisBuffer.getState(projectId)
  348. expect(finalState).to.deep.equal({
  349. changes: [],
  350. expireTime: null,
  351. headSnapshot: null,
  352. headVersion: null,
  353. persistTime: null,
  354. persistedVersion: null,
  355. })
  356. })
  357. it('deletes an empty project from the redis buffer', async function () {
  358. const projectId = await createEmptyProject()
  359. const deleteResponse =
  360. await testServer.basicAuthClient.apis.Project.deleteProject({
  361. project_id: projectId,
  362. })
  363. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  364. const changesAfter = await redisBuffer.getNonPersistedChanges(
  365. projectId,
  366. 0
  367. )
  368. expect(changesAfter.length).to.equal(0)
  369. })
  370. })
  371. })