project_blobs.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. const { expect } = require('chai')
  2. const config = require('config')
  3. const fs = require('node:fs')
  4. const fetch = require('node-fetch')
  5. const HTTPStatus = require('http-status')
  6. const cleanup = require('../storage/support/cleanup')
  7. const fixtures = require('../storage/support/fixtures')
  8. const testFiles = require('../storage/support/test_files')
  9. const testServer = require('./support/test_server')
  10. const { expectHttpError } = require('./support/expect_response')
  11. const { globalBlobs } = require('../../../../storage/lib/mongodb.js')
  12. const {
  13. loadGlobalBlobs,
  14. } = require('../../../../storage/lib/blob_store/index.js')
  15. describe('Project blobs API', function () {
  16. const projectId = '123'
  17. beforeEach(cleanup.everything)
  18. beforeEach(fixtures.create)
  19. let client
  20. let token
  21. before(async function () {
  22. client = await testServer.createClientForProject(projectId)
  23. token = testServer.createTokenForProject(projectId)
  24. })
  25. it('returns 404 if the blob is not found', async function () {
  26. const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  27. await expectHttpError(
  28. client.apis.Project.getProjectBlob({
  29. project_id: projectId,
  30. hash: testHash,
  31. }),
  32. HTTPStatus.NOT_FOUND
  33. )
  34. })
  35. it('checks if file hash matches the hash parameter', async function () {
  36. const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  37. const response = await fetch(
  38. testServer.url(`/api/projects/${projectId}/blobs/${testHash}`),
  39. {
  40. method: 'PUT',
  41. headers: { Authorization: `Bearer ${token}` },
  42. body: fs.createReadStream(testFiles.path('hello.txt')),
  43. }
  44. )
  45. expect(response.status).to.equal(HTTPStatus.CONFLICT)
  46. // check that it did not store the file
  47. await expectHttpError(
  48. client.apis.Project.getProjectBlob({
  49. project_id: projectId,
  50. hash: testFiles.HELLO_TXT_HASH,
  51. }),
  52. HTTPStatus.NOT_FOUND
  53. )
  54. })
  55. it('rejects oversized files', async function () {
  56. const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  57. const buffer = Buffer.alloc(
  58. parseInt(config.get('maxFileUploadSize'), 10) + 1
  59. )
  60. const response = await fetch(
  61. testServer.url(`/api/projects/${projectId}/blobs/${testHash}`),
  62. {
  63. method: 'PUT',
  64. headers: { Authorization: `Bearer ${token}` },
  65. body: buffer,
  66. }
  67. )
  68. expect(response.status).to.equal(HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
  69. })
  70. describe('with an existing blob', async function () {
  71. let fileContents
  72. beforeEach(async function () {
  73. fileContents = await fs.promises.readFile(testFiles.path('hello.txt'))
  74. const response = await fetch(
  75. testServer.url(
  76. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  77. ),
  78. {
  79. method: 'PUT',
  80. headers: { Authorization: `Bearer ${token}` },
  81. body: fileContents,
  82. }
  83. )
  84. expect(response.ok).to.be.true
  85. })
  86. it('fulfills a request with a JWT header', async function () {
  87. const response = await client.apis.Project.getProjectBlob({
  88. project_id: projectId,
  89. hash: testFiles.HELLO_TXT_HASH,
  90. })
  91. const responseText = await response.data.text()
  92. expect(responseText).to.equal(fileContents.toString())
  93. })
  94. it('fulfills a request with a token parameter', async function () {
  95. const url = new URL(
  96. testServer.url(
  97. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  98. )
  99. )
  100. url.searchParams.append('token', token)
  101. const response = await fetch(url)
  102. const payload = await response.text()
  103. expect(payload).to.equal(fileContents.toString())
  104. })
  105. it('supports range request from beginning', async function () {
  106. const url = new URL(
  107. testServer.url(
  108. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  109. )
  110. )
  111. url.searchParams.append('token', token)
  112. const response = await fetch(url, { headers: { Range: 'bytes=0-4' } })
  113. const payload = await response.text()
  114. // 0-4 is inclusive, so 5 bytes
  115. expect(payload).to.equal(fileContents.slice(0, 5).toString())
  116. expect(response.headers.get('Content-Range')).to.equal(
  117. `bytes 0-4/${testFiles.HELLO_TXT_BYTE_LENGTH}`
  118. )
  119. expect(response.headers.get('Content-Length')).to.equal('5')
  120. expect(response.status).to.equal(HTTPStatus.PARTIAL_CONTENT)
  121. })
  122. it('supports range request in middle', async function () {
  123. const url = new URL(
  124. testServer.url(
  125. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  126. )
  127. )
  128. url.searchParams.append('token', token)
  129. const response = await fetch(url, { headers: { Range: 'bytes=5-9' } })
  130. const payload = await response.text()
  131. // 5-9 is inclusive, so 5 bytes
  132. expect(payload).to.equal(fileContents.slice(5, 10).toString())
  133. expect(response.headers.get('Content-Range')).to.equal(
  134. `bytes 5-9/${testFiles.HELLO_TXT_BYTE_LENGTH}`
  135. )
  136. expect(response.headers.get('Content-Length')).to.equal('5')
  137. expect(response.status).to.equal(HTTPStatus.PARTIAL_CONTENT)
  138. })
  139. it('supports range request past end', async function () {
  140. const url = new URL(
  141. testServer.url(
  142. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  143. )
  144. )
  145. url.searchParams.append('token', token)
  146. const response = await fetch(url, { headers: { Range: 'bytes=5-100' } })
  147. const payload = await response.text()
  148. expect(payload).to.equal(fileContents.slice(5).toString())
  149. // Response range should be capped to the actual file size
  150. expect(response.headers.get('Content-Range')).to.equal(
  151. `bytes 5-10/${testFiles.HELLO_TXT_BYTE_LENGTH}`
  152. )
  153. expect(response.headers.get('Content-Length')).to.equal('6')
  154. expect(response.status).to.equal(HTTPStatus.PARTIAL_CONTENT)
  155. })
  156. describe('with invalid request ranges', async function () {
  157. async function testInvalidRange(range) {
  158. const url = new URL(
  159. testServer.url(
  160. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  161. )
  162. )
  163. url.searchParams.append('token', token)
  164. const response = await fetch(url, { headers: { Range: range } })
  165. expect(response.headers.get('Content-Range')).to.equal(
  166. `bytes */${testFiles.HELLO_TXT_BYTE_LENGTH}`
  167. )
  168. expect(response.headers.get('Content-Length')).to.equal('0')
  169. expect(response.status).to.equal(
  170. HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
  171. )
  172. }
  173. it('should fail on invalid range where start > end', async function () {
  174. testInvalidRange('bytes=10-5')
  175. })
  176. it('should fail on invalid range where start = length', async function () {
  177. testInvalidRange(`bytes=${testFiles.HELLO_TXT_BYTE_LENGTH + 1}-105`)
  178. })
  179. it('should fail on invalid range where start > length', async function () {
  180. testInvalidRange(`bytes=${testFiles.HELLO_TXT_BYTE_LENGTH}-105`)
  181. })
  182. })
  183. describe('with an empty blob', async function () {
  184. beforeEach(async function () {
  185. const response = await fetch(
  186. testServer.url(
  187. `/api/projects/${projectId}/blobs/${testFiles.EMPTY_FILE_HASH}`
  188. ),
  189. {
  190. method: 'PUT',
  191. headers: { Authorization: `Bearer ${token}` },
  192. body: '',
  193. }
  194. )
  195. expect(response.ok).to.be.true
  196. })
  197. it('should not handle range requests', async function () {
  198. const url = new URL(
  199. testServer.url(
  200. `/api/projects/${projectId}/blobs/${testFiles.EMPTY_FILE_HASH}`
  201. )
  202. )
  203. url.searchParams.append('token', token)
  204. const response = await fetch(url, { headers: { Range: 'bytes=0-0' } })
  205. expect(response.headers.get('Content-Range')).to.equal(
  206. `bytes */${testFiles.EMPTY_FILE_BYTE_LENGTH}`
  207. )
  208. expect(response.headers.get('Content-Length')).to.equal('0')
  209. expect(response.status).to.equal(
  210. HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
  211. )
  212. })
  213. it('should handle full requests', async function () {
  214. const url = new URL(
  215. testServer.url(
  216. `/api/projects/${projectId}/blobs/${testFiles.EMPTY_FILE_HASH}`
  217. )
  218. )
  219. url.searchParams.append('token', token)
  220. const response = await fetch(url)
  221. const payload = await response.text()
  222. expect(payload).to.equal('')
  223. expect(response.status).to.equal(HTTPStatus.OK)
  224. })
  225. })
  226. it('supports HEAD request', async function () {
  227. const url = new URL(
  228. testServer.url(
  229. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  230. )
  231. )
  232. url.searchParams.append('token', token)
  233. const response = await fetch(url, { method: 'HEAD' })
  234. expect(response.headers.get('Content-Length')).to.equal(
  235. testFiles.HELLO_TXT_BYTE_LENGTH.toString()
  236. )
  237. const payload = await response.text()
  238. expect(payload).to.have.length(0)
  239. })
  240. it('rejects an unautorized request', async function () {
  241. const response = await fetch(
  242. testServer.url(
  243. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  244. )
  245. )
  246. expect(response.status).to.equal(HTTPStatus.UNAUTHORIZED)
  247. })
  248. it('copies the blob to another project', async function () {
  249. const targetProjectId = '456'
  250. const targetClient =
  251. await testServer.createClientForProject(targetProjectId)
  252. const targetToken = testServer.createTokenForProject(targetProjectId)
  253. const url = new URL(
  254. testServer.url(
  255. `/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  256. )
  257. )
  258. url.searchParams.append('copyFrom', projectId)
  259. const response = await fetch(url, {
  260. method: 'POST',
  261. headers: { Authorization: `Bearer ${targetToken}` },
  262. })
  263. expect(response.status).to.equal(HTTPStatus.CREATED)
  264. const newBlobResponse = await targetClient.apis.Project.getProjectBlob({
  265. project_id: targetProjectId,
  266. hash: testFiles.HELLO_TXT_HASH,
  267. })
  268. const newBlobResponseText = await newBlobResponse.data.text()
  269. expect(newBlobResponseText).to.equal(fileContents.toString())
  270. })
  271. it('skips copying a blob to another project if it already exists', async function () {
  272. const targetProjectId = '456'
  273. const targetClient =
  274. await testServer.createClientForProject(targetProjectId)
  275. const targetToken = testServer.createTokenForProject(targetProjectId)
  276. const fileContents = await fs.promises.readFile(
  277. testFiles.path('hello.txt')
  278. )
  279. const uploadResponse = await fetch(
  280. testServer.url(
  281. `/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  282. ),
  283. {
  284. method: 'PUT',
  285. headers: { Authorization: `Bearer ${targetToken}` },
  286. body: fileContents,
  287. }
  288. )
  289. expect(uploadResponse.ok).to.be.true
  290. const url = new URL(
  291. testServer.url(
  292. `/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  293. )
  294. )
  295. url.searchParams.append('copyFrom', projectId)
  296. const response = await fetch(url, {
  297. method: 'POST',
  298. headers: { Authorization: `Bearer ${targetToken}` },
  299. })
  300. expect(response.status).to.equal(HTTPStatus.NO_CONTENT)
  301. const newBlobResponse = await targetClient.apis.Project.getProjectBlob({
  302. project_id: targetProjectId,
  303. hash: testFiles.HELLO_TXT_HASH,
  304. })
  305. const newBlobResponseText = await newBlobResponse.data.text()
  306. expect(newBlobResponseText).to.equal(fileContents.toString())
  307. })
  308. })
  309. describe('with a global blob', async function () {
  310. before(async function () {
  311. await globalBlobs.insertOne({
  312. _id: testFiles.STRING_A_HASH,
  313. byteLength: 1,
  314. stringLength: 1,
  315. })
  316. await loadGlobalBlobs()
  317. })
  318. it('does not copy global blobs', async function () {
  319. const targetProjectId = '456'
  320. const targetToken = testServer.createTokenForProject(targetProjectId)
  321. const url = new URL(
  322. testServer.url(
  323. `/api/projects/${targetProjectId}/blobs/${testFiles.STRING_A_HASH}`
  324. )
  325. )
  326. url.searchParams.append('copyFrom', projectId)
  327. const response = await fetch(url, {
  328. method: 'POST',
  329. headers: { Authorization: `Bearer ${targetToken}` },
  330. })
  331. expect(response.status).to.equal(HTTPStatus.NO_CONTENT)
  332. })
  333. })
  334. })