| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530 |
- const chai = require('chai')
- const { expect } = chai
- const fs = require('node:fs')
- const Stream = require('node:stream')
- const Settings = require('@overleaf/settings')
- const Path = require('node:path')
- const FilestoreApp = require('./FilestoreApp')
- const TestHelper = require('./TestHelper')
- const fetch = require('node-fetch')
- const { promisify } = require('node:util')
- const { Storage } = require('@google-cloud/storage')
- const streamifier = require('streamifier')
- chai.use(require('chai-as-promised'))
- const { ObjectId } = require('mongodb')
- const ChildProcess = require('node:child_process')
- const fsWriteFile = promisify(fs.writeFile)
- const fsStat = promisify(fs.stat)
- const exec = promisify(ChildProcess.exec)
- const msleep = promisify(setTimeout)
- if (!process.env.AWS_ACCESS_KEY_ID) {
- throw new Error('please provide credentials for the AWS S3 test server')
- }
- process.on('unhandledRejection', e => {
- // eslint-disable-next-line no-console
- console.log('** Unhandled Promise Rejection **\n', e)
- throw e
- })
- // store settings for multiple backends, so that we can test each one.
- // fs will always be available - add others if they are configured
- const {
- BackendSettings,
- s3Config,
- s3SSECConfig,
- AWS_S3_USER_FILES_STORAGE_CLASS,
- } = require('./TestConfig')
- const {
- AlreadyWrittenError,
- NotFoundError,
- NotImplementedError,
- NoKEKMatchedError,
- } = require('@overleaf/object-persistor/src/Errors')
- const {
- PerProjectEncryptedS3Persistor,
- RootKeyEncryptionKey,
- } = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
- const { S3Persistor } = require('@overleaf/object-persistor/src/S3Persistor')
- const crypto = require('node:crypto')
- const { WritableBuffer } = require('@overleaf/stream-utils')
- const { gzipSync } = require('node:zlib')
- describe('Filestore', function () {
- this.timeout(1000 * 10)
- const filestoreUrl = `http://127.0.0.1:${Settings.internal.filestore.port}`
- const seenSockets = []
- async function expectNoSockets() {
- try {
- await msleep(1000)
- const { stdout } = await exec('ss -tn')
- const lines = stdout.split('\n')
- const header = lines.shift()
- const badSockets = []
- for (const socket of lines) {
- const fields = socket.split(' ').filter(part => part !== '')
- if (
- fields.length > 2 &&
- parseInt(fields[1]) &&
- !seenSockets.includes(socket)
- ) {
- badSockets.push(socket)
- seenSockets.push(socket)
- }
- }
- if (badSockets.length) {
- // eslint-disable-next-line no-console
- console.error(
- 'ERR: Sockets still have receive buffer after connection closed'
- )
- console.error(header)
- for (const socket of badSockets) {
- // eslint-disable-next-line no-console
- console.error(socket)
- }
- throw new Error('Sockets still open after connection closed')
- }
- } catch (err) {
- expect(err).not.to.exist
- }
- }
- // redefine the test suite for every available backend
- for (const [backendVariantWithShardNumber, backendSettings] of Object.entries(
- BackendSettings
- )) {
- describe(backendVariantWithShardNumber, function () {
- let app,
- previousEgress,
- previousIngress,
- metricPrefix,
- templateId,
- otherProjectId,
- templateUrl,
- fileId,
- fileKey,
- fileUrl
- const dataEncryptionKeySize =
- backendSettings.backend === 's3SSEC' ? 32 : 0
- const BUCKET_NAMES = [
- process.env.GCS_TEMPLATE_FILES_BUCKET_NAME,
- `${process.env.GCS_TEMPLATE_FILES_BUCKET_NAME}-deleted`,
- ]
- before('start filestore with new settings', async function () {
- // create the app with the relevant filestore settings
- Settings.filestore = backendSettings
- app = new FilestoreApp()
- await app.runServer()
- })
- if (backendSettings.gcs) {
- before('create gcs buckets', async function () {
- // create test buckets for gcs
- const storage = new Storage(Settings.filestore.gcs.endpoint)
- for (const bucketName of BUCKET_NAMES) {
- await storage.createBucket(bucketName)
- }
- })
- after('delete gcs buckets', async function () {
- // tear down all the gcs buckets
- const storage = new Storage(Settings.filestore.gcs.endpoint)
- for (const bucketName of BUCKET_NAMES) {
- const bucket = storage.bucket(bucketName)
- await bucket.deleteFiles()
- await bucket.delete()
- }
- })
- }
- after('stop filestore app', async function () {
- await app.stop()
- })
- beforeEach('fetch previous egress metric', async function () {
- // retrieve previous metrics from the app
- if (['s3', 's3SSEC', 'gcs'].includes(Settings.filestore.backend)) {
- metricPrefix = Settings.filestore.backend.replace('SSEC', '')
- previousEgress = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_egress`
- )
- }
- templateId = new ObjectId().toString()
- otherProjectId = new ObjectId().toString()
- templateUrl = `${filestoreUrl}/template/${templateId}/v/0`
- fileId = new ObjectId().toString()
- fileUrl = `${templateUrl}/${fileId}`
- fileKey = `${templateId}/v/0/${fileId}`
- })
- it('should send a 200 for the status endpoint', async function () {
- const response = await fetch(`${filestoreUrl}/status`)
- expect(response.status).to.equal(200)
- const body = await response.text()
- expect(body).to.contain('filestore')
- expect(body).to.contain('up')
- })
- describe('with a file on the server', function () {
- let constantFileContent
- const localFileReadPath =
- '/tmp/filestore_acceptance_tests_file_read.txt'
- beforeEach('upload file', async function () {
- constantFileContent = [
- 'hello world',
- `line 2 goes here ${Math.random()}`,
- 'there are 3 lines in all',
- ].join('\n')
- await fsWriteFile(localFileReadPath, constantFileContent)
- const readStream = fs.createReadStream(localFileReadPath)
- const res = await fetch(fileUrl, { method: 'POST', body: readStream })
- if (!res.ok) throw new Error(res.statusText)
- })
- beforeEach('retrieve previous ingress metric', async function () {
- // The upload request can bump the ingress metric.
- // The content hash validation might require a full download
- // in case the ETag field of the upload response is not a md5 sum.
- if (['s3', 's3SSEC', 'gcs'].includes(Settings.filestore.backend)) {
- previousIngress = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_ingress`
- )
- }
- })
- it('should return 404 for a non-existant id', async function () {
- const url = fileUrl + '___this_is_clearly_wrong___'
- const response = await fetch(url)
- expect(response.status).to.equal(404)
- })
- it('should return the file size on a HEAD request', async function () {
- const expectedLength = Buffer.byteLength(constantFileContent)
- const res = await fetch(fileUrl, { method: 'HEAD' })
- expect(res.status).to.equal(200)
- expect(res.headers.get('Content-Length')).to.equal(
- expectedLength.toString()
- )
- })
- it('should be able get the file back', async function () {
- const res = await fetch(fileUrl)
- const body = await res.text()
- expect(body).to.equal(constantFileContent)
- })
- it('should send a 200 for the health-check endpoint using the file', async function () {
- const response = await fetch(`${filestoreUrl}/health_check`)
- expect(response.status).to.equal(200)
- const body = await response.text()
- expect(body).to.equal('OK')
- })
- it('should not leak a socket', async function () {
- const res = await fetch(fileUrl)
- if (!res.ok) throw new Error(res.statusText)
- await res.text()
- await expectNoSockets()
- })
- it('should be able to get back the first 9 bytes of the file', async function () {
- const res = await fetch(fileUrl, { headers: { Range: 'bytes=0-8' } })
- const body = await res.text()
- expect(body).to.equal('hello wor')
- })
- it('should be able to get back bytes 4 through 10 of the file', async function () {
- const res = await fetch(fileUrl, { headers: { Range: 'bytes=4-10' } })
- const body = await res.text()
- expect(body).to.equal('o world')
- })
- it('should be able to delete the file', async function () {
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- const response2 = await fetch(fileUrl)
- expect(response2.status).to.equal(404)
- })
- it('should be able to copy files', async function () {
- const newProjectID = new ObjectId().toString()
- const newFileId = new ObjectId().toString()
- const newFileUrl = `${filestoreUrl}/template/${newProjectID}/v/0/${newFileId}`
- const newFileKey = `${newProjectID}/v/0/${newFileId}`
- await app.persistor.copyObject(
- Settings.filestore.stores.template_files,
- fileKey,
- newFileKey
- )
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- const response = await fetch(newFileUrl)
- const body = await response.text()
- expect(body).to.equal(constantFileContent)
- })
- it('should be able to overwrite the file', async function () {
- const newContent = `here is some different content, ${Math.random()}`
- const readStream = streamifier.createReadStream(newContent)
- await fetch(fileUrl, { method: 'POST', body: readStream })
- const response = await fetch(fileUrl)
- const body = await response.text()
- expect(body).to.equal(newContent)
- })
- describe('IfNoneMatch', function () {
- if (backendSettings.backend === 'fs') {
- it('should refuse to handle IfNoneMatch', async function () {
- await expect(
- app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- fs.createReadStream(localFileReadPath),
- { ifNoneMatch: '*' }
- )
- ).to.be.rejectedWith(NotImplementedError)
- })
- } else {
- it('should reject sendStream on the same key with IfNoneMatch', async function () {
- await expect(
- app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- fs.createReadStream(localFileReadPath),
- { ifNoneMatch: '*' }
- )
- ).to.be.rejectedWith(AlreadyWrittenError)
- })
- it('should allow sendStream on a different key with IfNoneMatch', async function () {
- await app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- `${templateId}/v/0/${fileId}-other`,
- fs.createReadStream(localFileReadPath),
- { ifNoneMatch: '*' }
- )
- })
- }
- })
- if (backendSettings.backend !== 'fs') {
- it('should record an egress metric for the upload', async function () {
- const metric = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_egress`
- )
- expect(metric - previousEgress).to.equal(
- constantFileContent.length + dataEncryptionKeySize
- )
- })
- it('should record an ingress metric when downloading the file', async function () {
- const response = await fetch(fileUrl)
- expect(response.ok).to.be.true
- await response.text()
- const metric = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_ingress`
- )
- expect(metric - previousIngress).to.equal(
- constantFileContent.length + dataEncryptionKeySize
- )
- })
- it('should record an ingress metric for a partial download', async function () {
- const response = await fetch(fileUrl, {
- headers: { Range: 'bytes=0-8' },
- })
- expect(response.ok).to.be.true
- await response.text()
- const metric = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_ingress`
- )
- expect(metric - previousIngress).to.equal(9 + dataEncryptionKeySize)
- })
- }
- })
- describe('with multiple files', function () {
- let fileIds, fileUrls, otherFileUrls, otherProjectUrl
- const localFileReadPaths = [
- '/tmp/filestore_acceptance_tests_file_read_1.txt',
- '/tmp/filestore_acceptance_tests_file_read_2.txt',
- '/tmp/filestore_acceptance_tests_file_read_3.txt',
- ]
- const constantFileContents = [
- [
- 'hello world',
- `line 2 goes here ${Math.random()}`,
- 'there are 3 lines in all',
- ].join('\n'),
- [
- `for reference: ${Math.random()}`,
- 'cats are the best animals',
- 'wombats are a close second',
- ].join('\n'),
- [
- `another file: ${Math.random()}`,
- 'with multiple lines',
- 'the end',
- ].join('\n'),
- ]
- before('create local files', async function () {
- return await Promise.all([
- fsWriteFile(localFileReadPaths[0], constantFileContents[0]),
- fsWriteFile(localFileReadPaths[1], constantFileContents[1]),
- fsWriteFile(localFileReadPaths[2], constantFileContents[2]),
- ])
- })
- beforeEach('upload two files', async function () {
- otherProjectUrl = `${filestoreUrl}/template/${otherProjectId}/v/0`
- fileIds = [
- new ObjectId().toString(),
- new ObjectId().toString(),
- new ObjectId().toString(),
- ]
- fileUrls = [
- `${templateUrl}/${fileIds[0]}`,
- `${templateUrl}/${fileIds[1]}`,
- ]
- otherFileUrls = [`${otherProjectUrl}/${fileIds[2]}`]
- await Promise.all([
- fetch(fileUrls[0], {
- method: 'POST',
- body: fs.createReadStream(localFileReadPaths[0]),
- }),
- fetch(fileUrls[1], {
- method: 'POST',
- body: fs.createReadStream(localFileReadPaths[1]),
- }),
- fetch(otherFileUrls[0], {
- method: 'POST',
- body: fs.createReadStream(localFileReadPaths[2]),
- }),
- ])
- })
- it('should get the directory size', async function () {
- expect(
- await app.persistor.directorySize(
- Settings.filestore.stores.template_files,
- templateId
- )
- ).to.equal(
- constantFileContents[0].length + constantFileContents[1].length
- )
- })
- it('should store the files', async function () {
- for (const index in fileUrls) {
- const response = await fetch(fileUrls[index])
- const body = await response.text()
- expect(body).to.equal(constantFileContents[index])
- }
- })
- it('should be able to delete a folder', async function () {
- await app.persistor.deleteDirectory(
- Settings.filestore.stores.template_files,
- templateId + '/'
- )
- for (const index in fileUrls) {
- const response = await fetch(fileUrls[index])
- expect(response.status).to.equal(404)
- }
- })
- it('should not delete files in other projects', async function () {
- for (const index in otherFileUrls) {
- const response = await fetch(otherFileUrls[index])
- expect(response.status).to.equal(200)
- }
- })
- })
- describe('with a large file', function () {
- this.timeout(1000 * 20)
- let largeFileContent
- beforeEach('upload large file', async function () {
- largeFileContent = '_wombat_'.repeat(1024 * 1024) // 8 megabytes
- largeFileContent += Math.random()
- const readStream = streamifier.createReadStream(largeFileContent)
- const res = await fetch(fileUrl, { method: 'POST', body: readStream })
- if (!res.ok) throw new Error(res.statusText)
- })
- it('should be able to get the file back', async function () {
- const response = await fetch(fileUrl)
- const body = await response.text()
- expect(body).to.equal(largeFileContent)
- })
- it('should not leak a socket', async function () {
- const response = await fetch(fileUrl)
- await response.text()
- await expectNoSockets()
- })
- it('should not leak a socket if the connection is aborted', async function () {
- const controller = new AbortController()
- const response = await fetch(fileUrl, { signal: controller.signal })
- expect(response.ok).to.be.true
- controller.abort()
- await expectNoSockets()
- })
- })
- if (
- (backendSettings.backend === 's3' && !backendSettings.fallback) ||
- (backendSettings.backend === 'gcs' &&
- backendSettings.fallback?.backend === 's3')
- ) {
- describe('with a file in a specific bucket', function () {
- let constantFileContent, fileId, fileUrl, bucketName
- beforeEach('upload file into random bucket', async function () {
- constantFileContent = `This is a file in a different S3 bucket ${Math.random()}`
- fileId = new ObjectId().toString()
- bucketName = `random-bucket-${new ObjectId().toString()}`
- fileUrl = `${filestoreUrl}/bucket/${bucketName}/key/${fileId}`
- const s3 = new S3Persistor({
- ...s3Config(),
- key: process.env.MINIO_ROOT_USER,
- secret: process.env.MINIO_ROOT_PASSWORD,
- })._getClientForBucket(bucketName)
- await s3
- .createBucket({
- Bucket: bucketName,
- })
- .promise()
- await s3
- .upload({
- Bucket: bucketName,
- Key: fileId,
- Body: constantFileContent,
- })
- .promise()
- })
- it('should get the file from the specified bucket', async function () {
- const response = await fetch(fileUrl)
- const body = await response.text()
- expect(body).to.equal(constantFileContent)
- })
- })
- }
- if (backendSettings.backend === 'gcs') {
- describe('when deleting a file in GCS', function () {
- let content, error, dateBefore, dateAfter
- beforeEach('upload and delete file', async function () {
- content = '_wombat_' + Math.random()
- const readStream = streamifier.createReadStream(content)
- const res = await fetch(fileUrl, {
- method: 'POST',
- body: readStream,
- })
- if (!res.ok) throw new Error(res.statusText)
- dateBefore = new Date()
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- dateAfter = new Date()
- })
- it('should not throw an error', function () {
- expect(error).not.to.exist
- })
- it('should copy the file to the deleted-files bucket', async function () {
- let date = dateBefore
- const keys = []
- while (date <= dateAfter) {
- keys.push(`${templateId}/v/0/${fileId}-${date.toISOString()}`)
- date = new Date(date.getTime() + 1)
- }
- await TestHelper.expectPersistorToHaveSomeFile(
- app.persistor,
- `${Settings.filestore.stores.template_files}-deleted`,
- keys,
- content
- )
- })
- it('should remove the file from the original bucket', async function () {
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor,
- Settings.filestore.stores.template_files,
- fileKey
- )
- })
- })
- }
- if (backendSettings.fallback) {
- describe('with a fallback', function () {
- let constantFileContent, bucket, fallbackBucket
- beforeEach('prepare fallback', function () {
- constantFileContent = `This is yet more file content ${Math.random()}`
- bucket = Settings.filestore.stores.template_files
- fallbackBucket = Settings.filestore.fallback.buckets[bucket]
- })
- describe('with a file in the fallback bucket', function () {
- beforeEach('upload into fallback', async function () {
- await TestHelper.uploadStringToPersistor(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- })
- it('should not find file in the primary', async function () {
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey
- )
- })
- it('should find the file in the fallback', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- })
- describe('when copyOnMiss is disabled', function () {
- beforeEach('swap copyOnMiss=false', function () {
- app.persistor.settings.copyOnMiss = false
- })
- it('should fetch the file', async function () {
- const res = await fetch(fileUrl)
- const body = await res.text()
- expect(body).to.equal(constantFileContent)
- })
- it('should not copy the file to the primary', async function () {
- const response = await fetch(fileUrl)
- expect(response.ok).to.be.true
- await response.text()
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey
- )
- })
- })
- describe('when copyOnMiss is enabled', function () {
- beforeEach('swap copyOnMiss=true', function () {
- app.persistor.settings.copyOnMiss = true
- })
- it('should fetch the file', async function () {
- const res = await fetch(fileUrl)
- const body = await res.text()
- expect(body).to.equal(constantFileContent)
- })
- it('copies the file to the primary', async function () {
- const response = await fetch(fileUrl)
- expect(response.ok).to.be.true
- await response.text()
- // wait for the file to copy in the background
- await msleep(1000)
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey,
- constantFileContent
- )
- })
- })
- describe('when copying a file', function () {
- let newFileKey
- beforeEach('prepare to copy file', function () {
- const newProjectID = new ObjectId().toString()
- const newFileId = new ObjectId().toString()
- newFileKey = `${newProjectID}/v/0/${newFileId}`
- })
- describe('when copyOnMiss is false', function () {
- beforeEach('copy with copyOnMiss=false', async function () {
- app.persistor.settings.copyOnMiss = false
- await app.persistor.copyObject(
- Settings.filestore.stores.template_files,
- fileKey,
- newFileKey
- )
- })
- it('should leave the old file in the old bucket', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- })
- it('should not create a new file in the old bucket', async function () {
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- newFileKey
- )
- })
- it('should create a new file in the new bucket', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- newFileKey,
- constantFileContent
- )
- })
- it('should not copy the old file to the primary with the old key', async function () {
- // wait for the file to copy in the background
- await msleep(1000)
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey
- )
- })
- })
- describe('when copyOnMiss is true', function () {
- beforeEach('copy with copyOnMiss=false', async function () {
- app.persistor.settings.copyOnMiss = true
- await app.persistor.copyObject(
- Settings.filestore.stores.template_files,
- fileKey,
- newFileKey
- )
- })
- it('should leave the old file in the old bucket', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- })
- it('should not create a new file in the old bucket', async function () {
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- newFileKey
- )
- })
- it('should create a new file in the new bucket', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- newFileKey,
- constantFileContent
- )
- })
- it('should copy the old file to the primary with the old key', async function () {
- // wait for the file to copy in the background
- await msleep(1000)
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey,
- constantFileContent
- )
- })
- })
- })
- })
- describe('when sending a file', function () {
- beforeEach('upload file', async function () {
- const readStream =
- streamifier.createReadStream(constantFileContent)
- const res = await fetch(fileUrl, {
- method: 'POST',
- body: readStream,
- })
- if (!res.ok) throw new Error(res.statusText)
- })
- it('should store the file on the primary', async function () {
- await TestHelper.expectPersistorToHaveFile(
- app.persistor.primaryPersistor,
- bucket,
- fileKey,
- constantFileContent
- )
- })
- it('should not store the file on the fallback', async function () {
- await TestHelper.expectPersistorNotToHaveFile(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey
- )
- })
- })
- describe('when deleting a file', function () {
- describe('when the file exists on the primary', function () {
- beforeEach('upload into primary', async function () {
- await TestHelper.uploadStringToPersistor(
- app.persistor.primaryPersistor,
- bucket,
- fileKey,
- constantFileContent
- )
- })
- it('should delete the file', async function () {
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- const response2 = await fetch(fileUrl)
- expect(response2.status).to.equal(404)
- })
- })
- describe('when the file exists on the fallback', function () {
- beforeEach('upload into fallback', async function () {
- await TestHelper.uploadStringToPersistor(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- })
- it('should delete the file', async function () {
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- const response2 = await fetch(fileUrl)
- expect(response2.status).to.equal(404)
- })
- })
- describe('when the file exists on both the primary and the fallback', function () {
- beforeEach(
- 'upload into both primary and fallback',
- async function () {
- await TestHelper.uploadStringToPersistor(
- app.persistor.primaryPersistor,
- bucket,
- fileKey,
- constantFileContent
- )
- await TestHelper.uploadStringToPersistor(
- app.persistor.fallbackPersistor,
- fallbackBucket,
- fileKey,
- constantFileContent
- )
- }
- )
- it('should delete the files', async function () {
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- const response2 = await fetch(fileUrl)
- expect(response2.status).to.equal(404)
- })
- })
- describe('when the file does not exist', function () {
- it('should return success', async function () {
- // S3 doesn't give us a 404 when the object doesn't exist, so to stay
- // consistent we merrily return success ourselves here as well
- await app.persistor.deleteObject(
- Settings.filestore.stores.template_files,
- fileKey
- )
- })
- })
- })
- })
- }
- describe('with a pdf file', function () {
- let localFileSize
- const localFileReadPath = Path.resolve(
- __dirname,
- '../../fixtures/test.pdf'
- )
- beforeEach('upload test.pdf', async function () {
- const stat = await fsStat(localFileReadPath)
- localFileSize = stat.size
- const readStream = fs.createReadStream(localFileReadPath)
- const res = await fetch(fileUrl, { method: 'POST', body: readStream })
- if (!res.ok) throw new Error(res.statusText)
- })
- it('should be able get the file back', async function () {
- const response = await fetch(fileUrl)
- const body = await response.text()
- expect(body.substring(0, 8)).to.equal('%PDF-1.5')
- })
- if (backendSettings.backend !== 'fs') {
- it('should record an egress metric for the upload', async function () {
- const metric = await TestHelper.getMetric(
- filestoreUrl,
- `${metricPrefix}_egress`
- )
- expect(metric - previousEgress).to.equal(
- localFileSize + dataEncryptionKeySize
- )
- })
- }
- describe('getting the preview image', function () {
- this.timeout(1000 * 20)
- let previewFileUrl
- beforeEach('prepare previewFileUrl for preview', function () {
- previewFileUrl = `${fileUrl}?style=preview`
- })
- it('should not time out', async function () {
- const response = await fetch(previewFileUrl)
- expect(response.status).to.equal(200)
- await response.arrayBuffer()
- })
- it('should respond with image data', async function () {
- // note: this test relies of the imagemagick conversion working
- const response = await fetch(previewFileUrl)
- expect(response.status).to.equal(200)
- const body = await response.text()
- expect(body.length).to.be.greaterThan(400)
- expect(body.substr(1, 3)).to.equal('PNG')
- })
- })
- describe('warming the cache', function () {
- this.timeout(1000 * 20)
- let previewFileUrl
- beforeEach('prepare previewFileUrl for cacheWarn', function () {
- previewFileUrl = `${fileUrl}?style=preview&cacheWarm=true`
- })
- it('should not time out', async function () {
- const response = await fetch(previewFileUrl)
- expect(response.status).to.equal(200)
- await response.arrayBuffer()
- })
- it('should not leak sockets', async function () {
- const response1 = await fetch(previewFileUrl)
- expect(response1.status).to.equal(200)
- // do not read the response body, should be destroyed immediately
- const response2 = await fetch(previewFileUrl)
- expect(response2.status).to.equal(200)
- // do not read the response body, should be destroyed immediately
- await expectNoSockets()
- })
- it("should respond with only an 'OK'", async function () {
- // note: this test relies of the imagemagick conversion working
- const response = await fetch(previewFileUrl)
- const body = await response.text()
- expect(body).to.equal('OK')
- })
- })
- })
- describe('with server side encryption', function () {
- if (backendSettings.backend !== 's3SSEC') return
- before('sanity check top-level variable', function () {
- expect(dataEncryptionKeySize).to.equal(32)
- })
- let fileId1,
- fileId2,
- fileKey1,
- fileKey2,
- fileKeyOtherProject,
- fileUrl1,
- fileUrl2
- beforeEach('prepare ids', function () {
- fileId1 = new ObjectId().toString()
- fileId2 = new ObjectId().toString()
- fileKey1 = `${templateId}/v/0/${fileId1}`
- fileKey2 = `${templateId}/v/0/${fileId2}`
- fileKeyOtherProject = `${new ObjectId().toString()}/v/0/${new ObjectId().toString()}`
- fileUrl1 = `${templateUrl}/${fileId1}`
- fileUrl2 = `${templateUrl}/${fileId2}`
- })
- beforeEach('ensure DEK is missing', async function () {
- // Cannot use test helper expectPersistorNotToHaveFile here, we need to use the KEK.
- await expect(
- app.persistor.getDataEncryptionKeySize(
- backendSettings.stores.template_files,
- fileKey1
- )
- ).to.rejectedWith(NotFoundError)
- })
- async function createRandomContent(url, suffix = '') {
- const content = Math.random().toString() + suffix
- const res = await fetch(url, {
- method: 'POST',
- body: Stream.Readable.from([content]),
- })
- if (!res.ok) throw new Error(res.statusText)
- return async () => {
- const res = await fetch(url, { method: 'GET' })
- if (!res.ok) throw new Error(res.statusText)
- expect(await res.text()).to.equal(content)
- }
- }
- it('should create a DEK when asked explicitly', async function () {
- await app.persistor.generateDataEncryptionKey(
- backendSettings.stores.template_files,
- fileKey1
- )
- expect(
- await app.persistor.getDataEncryptionKeySize(
- backendSettings.stores.template_files,
- fileKey1
- )
- ).to.equal(32)
- })
- it('should create a DEK from writes', async function () {
- await createRandomContent(fileUrl1)
- expect(
- await app.persistor.getDataEncryptionKeySize(
- backendSettings.stores.template_files,
- fileKey1
- )
- ).to.equal(32)
- })
- it('should not create a DEK from reads', async function () {
- const res = await fetch(fileUrl1, {
- method: 'GET',
- })
- if (res.status !== 404) throw new Error(`${res.status} should be 404`)
- // Cannot use test helper expectPersistorNotToHaveFile here, we need to use the KEK.
- await expect(
- app.persistor.getDataEncryptionKeySize(
- backendSettings.stores.template_files,
- fileKey1
- )
- ).to.rejectedWith(NotFoundError)
- })
- it('should never overwrite a data encryption key', async function () {
- const checkGET = await createRandomContent(fileUrl1)
- await expect(
- app.persistor.generateDataEncryptionKey(
- backendSettings.stores.template_files,
- fileKey1
- )
- ).to.rejectedWith(AlreadyWrittenError)
- await checkGET()
- })
- it('should re-use the data encryption key after a write', async function () {
- const checkGET1 = await createRandomContent(fileUrl1, '1')
- const checkGET2 = await createRandomContent(fileUrl2, '2')
- await checkGET1()
- await checkGET2()
- })
- describe('kek rotation', function () {
- const newKEK = new RootKeyEncryptionKey(
- crypto.generateKeySync('aes', { length: 256 }).export(),
- Buffer.alloc(32)
- )
- const oldKEK = new RootKeyEncryptionKey(
- crypto.generateKeySync('aes', { length: 256 }).export(),
- Buffer.alloc(32)
- )
- const migrationStep0 = new PerProjectEncryptedS3Persistor({
- ...s3SSECConfig(),
- automaticallyRotateDEKEncryption: false,
- async getRootKeyEncryptionKeys() {
- return [oldKEK] // only old key
- },
- })
- const migrationStep1 = new PerProjectEncryptedS3Persistor({
- ...s3SSECConfig(),
- automaticallyRotateDEKEncryption: false,
- async getRootKeyEncryptionKeys() {
- return [oldKEK, newKEK] // new key as fallback
- },
- })
- const migrationStep2 = new PerProjectEncryptedS3Persistor({
- ...s3SSECConfig(),
- automaticallyRotateDEKEncryption: true, // <- different compared to partiallyRotated
- async getRootKeyEncryptionKeys() {
- return [newKEK, oldKEK] // old keys as fallback
- },
- })
- const migrationStep3 = new PerProjectEncryptedS3Persistor({
- ...s3SSECConfig(),
- automaticallyRotateDEKEncryption: true,
- async getRootKeyEncryptionKeys() {
- return [newKEK] // only new key
- },
- })
- async function checkWrites(
- fileKey,
- writer,
- readersSuccess,
- readersFailed
- ) {
- const content = Math.random().toString()
- await writer.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- Stream.Readable.from([content])
- )
- for (const persistor of readersSuccess) {
- await TestHelper.expectPersistorToHaveFile(
- persistor,
- backendSettings.stores.template_files,
- fileKey,
- content
- )
- }
- for (const persistor of readersFailed) {
- await expect(
- TestHelper.expectPersistorToHaveFile(
- persistor,
- backendSettings.stores.template_files,
- fileKey,
- content
- )
- ).to.be.rejectedWith(NoKEKMatchedError)
- }
- }
- const stages = [
- {
- name: 'stage 0 - [old]',
- prev: migrationStep0,
- cur: migrationStep0,
- fail: [migrationStep3],
- },
- {
- name: 'stage 1 - [old,new]',
- prev: migrationStep0,
- cur: migrationStep1,
- fail: [],
- },
- {
- name: 'stage 2 - [new,old]',
- prev: migrationStep1,
- cur: migrationStep2,
- fail: [],
- },
- {
- name: 'stage 3 - [new]',
- prev: migrationStep2,
- cur: migrationStep3,
- fail: [migrationStep0],
- },
- ]
- for (const { name, prev, cur, fail } of stages) {
- describe(name, function () {
- this.timeout(1000 * 30)
- it('can read old writes', async function () {
- await checkWrites(fileKey1, prev, [prev, cur], fail)
- await checkWrites(fileKey2, prev, [prev, cur], fail) // check again after access
- await checkWrites(fileKeyOtherProject, prev, [prev, cur], fail)
- })
- it('can read new writes', async function () {
- await checkWrites(fileKey1, prev, [prev, cur], fail)
- await checkWrites(fileKey2, cur, [prev, cur], fail) // check again after access
- await checkWrites(fileKeyOtherProject, cur, [prev, cur], fail)
- })
- })
- }
- describe('full migration', function () {
- it('can read old writes if rotated in sequence', async function () {
- await checkWrites(
- fileKey1,
- migrationStep0,
- [
- migrationStep0,
- migrationStep1,
- migrationStep2, // migrates
- migrationStep3,
- ],
- []
- )
- })
- it('cannot read/write if not rotated', async function () {
- await checkWrites(
- fileKey1,
- migrationStep0,
- [migrationStep0],
- [migrationStep3]
- )
- })
- })
- })
- /** @type {import('aws-sdk/clients/s3')} */
- let s3Client
- before('create s3 client', function () {
- s3Client = new S3Persistor(s3Config())._getClientForBucket('')
- })
- async function checkDEKStorage({
- dekBucketKeys = [],
- userFilesBucketKeys = [],
- }) {
- await createRandomContent(fileUrl1)
- const { Contents: dekEntries } = await s3Client
- .listObjectsV2({
- Bucket: process.env.AWS_S3_USER_FILES_DEK_BUCKET_NAME,
- Prefix: `${templateId}/`,
- })
- .promise()
- expect(dekEntries).to.have.length(dekBucketKeys.length)
- // Order is not predictable, use members
- expect(dekEntries.map(o => o.Key)).to.have.members(dekBucketKeys)
- const { Contents: userFilesEntries } = await s3Client
- .listObjectsV2({
- Bucket: backendSettings.stores.template_files,
- Prefix: `${templateId}/`,
- })
- .promise()
- expect(userFilesEntries).to.have.length(userFilesBucketKeys.length)
- // Order is not predictable, use members
- expect(userFilesEntries.map(o => o.Key)).to.have.members(
- userFilesBucketKeys
- )
- }
- it('should use a custom bucket for DEKs', async function () {
- await checkDEKStorage({
- dekBucketKeys: [`${templateId}/dek`],
- userFilesBucketKeys: [fileKey1],
- })
- })
- describe('deleteDirectory', function () {
- let checkGET1, checkGET2
- beforeEach('create files', async function () {
- checkGET1 = await createRandomContent(fileUrl1, '1')
- checkGET2 = await createRandomContent(fileUrl2, '2')
- })
- it('should refuse to delete top-level prefix', async function () {
- await expect(
- app.persistor.deleteDirectory(
- Settings.filestore.stores.template_files,
- templateId.slice(0, 3)
- )
- ).to.be.rejectedWith('not a project-folder')
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey1
- )
- ).to.equal(true)
- await checkGET1()
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.equal(true)
- expect(
- await app.persistor.getDataEncryptionKeySize(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.equal(32)
- await checkGET2()
- })
- it('should delete sub-folder and keep DEK', async function () {
- await app.persistor.deleteDirectory(
- Settings.filestore.stores.template_files,
- fileKey1 // not really a sub-folder, but it will do for this test.
- )
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey1
- )
- ).to.equal(false)
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.equal(true)
- expect(
- await app.persistor.getDataEncryptionKeySize(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.equal(32)
- await checkGET2()
- })
- it('should delete project folder and DEK', async function () {
- await app.persistor.deleteDirectory(
- Settings.filestore.stores.template_files,
- `${templateId}/`
- )
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey1
- )
- ).to.equal(false)
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.equal(false)
- await expect(
- app.persistor.getDataEncryptionKeySize(
- Settings.filestore.stores.template_files,
- fileKey2
- )
- ).to.rejectedWith(NotFoundError)
- })
- })
- })
- describe('getObjectSize', function () {
- it('should return a number', async function () {
- const buf = Buffer.from('hello')
- const res = await fetch(fileUrl, {
- method: 'POST',
- body: Stream.Readable.from([buf]),
- })
- if (!res.ok) throw new Error(res.statusText)
- expect(
- await app.persistor.getObjectSize(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(buf.byteLength)
- })
- })
- describe('checkIfObjectExists', function () {
- it('should return false when the object does not exist', async function () {
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(false)
- })
- it('should return true when the object exists', async function () {
- const res = await fetch(fileUrl, {
- method: 'POST',
- body: Stream.Readable.from(['hello']),
- })
- if (!res.ok) throw new Error(res.statusText)
- expect(
- await app.persistor.checkIfObjectExists(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(true)
- })
- })
- if (backendSettings.backend === 's3SSEC') {
- describe('storageClass', function () {
- it('should use the default storage class for dek', async function () {
- const dekBucket = process.env.AWS_S3_USER_FILES_DEK_BUCKET_NAME
- await app.persistor.sendStream(
- dekBucket,
- fileKey,
- Stream.Readable.from(['hello'])
- )
- expect(
- await app.persistor.getObjectStorageClass(dekBucket, fileKey)
- ).to.equal(undefined)
- })
- it('should use the custom storage class for user files', async function () {
- await app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- Stream.Readable.from(['hello'])
- )
- const sc = AWS_S3_USER_FILES_STORAGE_CLASS
- expect(sc).to.exist
- expect(
- await app.persistor.getObjectStorageClass(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(sc)
- })
- })
- }
- describe('autoGunzip', function () {
- this.timeout(60 * 1000)
- const body = Buffer.alloc(10 * 1024 * 1024, 'hello')
- const gzippedBody = gzipSync(body)
- /**
- * @param {string} key
- * @param {Buffer} wantBody
- * @param {boolean} autoGunzip
- * @return {Promise<void>}
- */
- async function checkBodyIsTheSame(key, wantBody, autoGunzip) {
- const s = await app.persistor.getObjectStream(
- Settings.filestore.stores.template_files,
- key,
- { autoGunzip }
- )
- const buf = new WritableBuffer()
- await Stream.promises.pipeline(s, buf)
- expect(buf.getContents()).to.deep.equal(wantBody)
- }
- if (backendSettings.backend === 'fs') {
- it('should refuse to handle autoGunzip', async function () {
- await expect(
- app.persistor.getObjectStream(
- Settings.filestore.stores.template_files,
- fileKey,
- { autoGunzip: true }
- )
- ).to.be.rejectedWith(NotImplementedError)
- })
- } else {
- it('should return the raw body with gzip', async function () {
- await app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- Stream.Readable.from([gzippedBody]),
- { contentEncoding: 'gzip' }
- )
- expect(
- await app.persistor.getObjectSize(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(gzippedBody.byteLength)
- // raw body with autoGunzip=true
- await checkBodyIsTheSame(fileKey, body, true)
- // gzip body without autoGunzip=false
- await checkBodyIsTheSame(fileKey, gzippedBody, false)
- })
- it('should return the raw body without gzip compression', async function () {
- await app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- Stream.Readable.from([body])
- )
- expect(
- await app.persistor.getObjectSize(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(body.byteLength)
- // raw body with both autoGunzip options
- await checkBodyIsTheSame(fileKey, body, true)
- await checkBodyIsTheSame(fileKey, body, false)
- })
- it('should return the gzip body without gzip header', async function () {
- await app.persistor.sendStream(
- Settings.filestore.stores.template_files,
- fileKey,
- Stream.Readable.from([gzippedBody])
- )
- expect(
- await app.persistor.getObjectSize(
- Settings.filestore.stores.template_files,
- fileKey
- )
- ).to.equal(gzippedBody.byteLength)
- // gzip body with both autoGunzip options
- await checkBodyIsTheSame(fileKey, gzippedBody, true)
- await checkBodyIsTheSame(fileKey, gzippedBody, false)
- })
- }
- })
- })
- }
- })
|