projects.test.js 11 KB

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