|
| 1 | +/* Copyright (C) 2024 NooBaa */ |
| 2 | +'use strict'; |
| 3 | + |
| 4 | +const _ = require('lodash'); |
| 5 | +const mocha = require('mocha'); |
| 6 | +const crypto = require('crypto'); |
| 7 | +const assert = require('assert'); |
| 8 | +const config = require('../../../../../config'); |
| 9 | +const http_utils = require('../../../../util/http_utils'); |
| 10 | +const buffer_utils = require('../../../../util/buffer_utils'); |
| 11 | +const dbg = require('../../../../util/debug_module')(__filename); |
| 12 | +const { NodeHttpHandler } = require('@smithy/node-http-handler'); |
| 13 | +const { S3Error } = require('../../../../endpoint/s3/s3_errors'); |
| 14 | +const { require_coretest, is_nc_coretest } = require('../../../system_tests/test_utils'); |
| 15 | +const { S3Client, PutObjectCommand, ChecksumAlgorithm, CreateMultipartUploadCommand, UploadPartCommand, |
| 16 | + CompleteMultipartUploadCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); |
| 17 | + |
| 18 | +const coretest = require_coretest(); |
| 19 | +const { rpc_client, EMAIL, POOL_LIST } = coretest; |
| 20 | +coretest.setup({ pools_to_create: is_nc_coretest ? undefined : [POOL_LIST[1]] }); |
| 21 | + |
| 22 | +const first_bucket = 'first.bucket'; |
| 23 | +const default_checksum_algorithm = ChecksumAlgorithm.SHA256; |
| 24 | +const non_chunked_upload_key = 'non_chunked_upload.txt'; |
| 25 | + |
| 26 | +mocha.describe('S3 basic chunked upload tests', async function() { |
| 27 | + let s3; |
| 28 | + |
| 29 | + mocha.before(async () => { |
| 30 | + const account_info = await rpc_client.account.read_account({ email: EMAIL, }); |
| 31 | + const s3_client_params = { |
| 32 | + endpoint: coretest.get_http_address(), |
| 33 | + credentials: { |
| 34 | + accessKeyId: account_info.access_keys[0].access_key.unwrap(), |
| 35 | + secretAccessKey: account_info.access_keys[0].secret_key.unwrap(), |
| 36 | + }, |
| 37 | + forcePathStyle: true, |
| 38 | + region: config.DEFAULT_REGION, |
| 39 | + requestHandler: new NodeHttpHandler({ |
| 40 | + httpAgent: http_utils.get_unsecured_agent(coretest.get_http_address()), |
| 41 | + }), |
| 42 | + }; |
| 43 | + s3 = new S3Client(s3_client_params); |
| 44 | + await validate_request_headers(s3); |
| 45 | + }); |
| 46 | + |
| 47 | + mocha.it('Put object - chunked upload - no additional content-encoding', async function() { |
| 48 | + const bucket = first_bucket; |
| 49 | + const key = 'chunked_upload.txt'; |
| 50 | + const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload |
| 51 | + await test_put_chunked_object({ s3, bucket, key, size }); |
| 52 | + }); |
| 53 | + |
| 54 | + mocha.it('Put object - chunked upload - multiple encodings - no spaces - aws-chunked will be added to content-encoding header without spaces by the sdk', async function() { |
| 55 | + const bucket = first_bucket; |
| 56 | + const key = 'chunked_upload_multiple_encodings_no_spaces.txt'; |
| 57 | + const content_encoding = 'gzip,zstd'; |
| 58 | + const size = 100; |
| 59 | + await test_put_chunked_object({ s3, bucket, key, size, content_encoding }); |
| 60 | + }); |
| 61 | + |
| 62 | + mocha.it('Put object - not a chunked upload but having aws-chunked with spaces in encoding header - should fail', async function() { |
| 63 | + const bucket = first_bucket; |
| 64 | + const key = non_chunked_upload_key; |
| 65 | + const content_encoding = 'zstd, gzip, aws-chunked'; |
| 66 | + const size = 100; |
| 67 | + try { |
| 68 | + await test_put_chunked_object({ s3, bucket, key, size, content_encoding, chunked_upload: false }); |
| 69 | + assert.fail('Put object - not a chunked upload but having aws-chunked in encoding header encoding - should fail'); |
| 70 | + } catch (err) { |
| 71 | + dbg.error('error', err); |
| 72 | + // the upload is recognized as a chunked upload because of the added aws-chunked encoding but the content is not chunked - NooBaa throws internal error |
| 73 | + assert.equal(err.Code, S3Error.InternalError.code); |
| 74 | + } |
| 75 | + }); |
| 76 | + |
| 77 | + mocha.it('MPU - chunked upload - no additional content encoding', async function() { |
| 78 | + const bucket = first_bucket; |
| 79 | + const key = 'chunked_upload_mpu.txt'; |
| 80 | + const parts_num = 3; |
| 81 | + const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload |
| 82 | + await test_chunked_mpu({ s3, bucket, key, size, parts_num }); |
| 83 | + }); |
| 84 | + |
| 85 | + mocha.it('MPU - chunked upload - multiple encodings - no spaces - aws-chunked will be added to content-encoding header without spaces by the sdk', async function() { |
| 86 | + const bucket = first_bucket; |
| 87 | + const key = 'chunked_upload_mpu_multiple_encodings_no_spaces.txt'; |
| 88 | + const content_encoding = 'gzip,zstd'; |
| 89 | + const parts_num = 3; |
| 90 | + const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload |
| 91 | + await test_chunked_mpu({ s3, bucket, key, size, parts_num, content_encoding }); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +/** |
| 96 | + * @param {{ |
| 97 | + * s3: S3Client, |
| 98 | + * bucket?: string, |
| 99 | + * key: string, |
| 100 | + * size?: number, |
| 101 | + * content_encoding?: string, |
| 102 | + * checksum_algorithm?: ChecksumAlgorithm |
| 103 | + * chunked_upload?: boolean |
| 104 | + * }} upload_config - Configuration object |
| 105 | + */ |
| 106 | +async function test_put_chunked_object(upload_config) { |
| 107 | + const { s3, bucket = first_bucket, key, size = 100, chunked_upload = true, |
| 108 | + content_encoding, checksum_algorithm = default_checksum_algorithm } = upload_config; |
| 109 | + const random_data_buffer = crypto.randomBytes(size); |
| 110 | + const random_data_stream = buffer_utils.buffer_to_read_stream(random_data_buffer); |
| 111 | + const body = chunked_upload ? random_data_stream : random_data_buffer; |
| 112 | + const input = { |
| 113 | + Bucket: bucket, |
| 114 | + Key: key, |
| 115 | + ContentLength: size, |
| 116 | + Body: body, |
| 117 | + ContentEncoding: content_encoding, |
| 118 | + ChecksumAlgorithm: chunked_upload ? checksum_algorithm : undefined, |
| 119 | + }; |
| 120 | + const put_object_command = new PutObjectCommand(input); |
| 121 | + const put_object_response = await s3.send(put_object_command); |
| 122 | + dbg.log0('PutObject response:', put_object_response); |
| 123 | + assert.ok(put_object_response.ETag); |
| 124 | + |
| 125 | + const get_object_res = await get_object(s3, bucket, key); |
| 126 | + assert.equal(get_object_res.body, random_data_buffer.toString()); |
| 127 | + assert.equal(get_object_res.ETag, put_object_response.ETag); |
| 128 | + |
| 129 | + await delete_object(s3, bucket, key); |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * @param {{ |
| 134 | + * s3: S3Client, |
| 135 | + * bucket?: string, |
| 136 | + * key: string, |
| 137 | + * size?: number, |
| 138 | + * parts_num?: number, |
| 139 | + * content_encoding?: string, |
| 140 | + * checksum_algorithm?: ChecksumAlgorithm |
| 141 | + * chunked_upload?: boolean |
| 142 | + * }} mpu_config - Configuration object |
| 143 | + */ |
| 144 | +async function test_chunked_mpu(mpu_config) { |
| 145 | + const { s3, bucket = first_bucket, key, size = 100, parts_num = 1, chunked_upload = true, |
| 146 | + content_encoding = undefined, checksum_algorithm = default_checksum_algorithm } = mpu_config; |
| 147 | + |
| 148 | + const create_mpu_input = { |
| 149 | + Bucket: bucket, Key: key, ContentEncoding: content_encoding, |
| 150 | + ChecksumAlgorithm: checksum_algorithm |
| 151 | + }; |
| 152 | + const create_mpu_command = new CreateMultipartUploadCommand(create_mpu_input); |
| 153 | + const create_mpu_response = await s3.send(create_mpu_command); |
| 154 | + dbg.log0('MPU create_mpu_response:', create_mpu_response); |
| 155 | + assert.ok(create_mpu_response.UploadId); |
| 156 | + |
| 157 | + const parts = []; |
| 158 | + let original_string = ''; |
| 159 | + for (let i = 1; i <= parts_num; i++) { |
| 160 | + const random_data_buffer = crypto.randomBytes(size); |
| 161 | + const random_data_stream = buffer_utils.buffer_to_read_stream(random_data_buffer); |
| 162 | + const body = chunked_upload ? random_data_stream : random_data_buffer; |
| 163 | + const upload_part_input = { |
| 164 | + Bucket: bucket, Key: key, UploadId: create_mpu_response.UploadId, |
| 165 | + PartNumber: i, Body: body, ContentLength: size, |
| 166 | + ContentEncoding: content_encoding, |
| 167 | + ChecksumAlgorithm: chunked_upload ? checksum_algorithm : undefined |
| 168 | + }; |
| 169 | + const upload_part_command = new UploadPartCommand(upload_part_input); |
| 170 | + const upload_part_response = await s3.send(upload_part_command); |
| 171 | + dbg.log0('MPU upload_part_response:', upload_part_response); |
| 172 | + assert.ok(upload_part_response.ETag); |
| 173 | + parts.push({ PartNumber: i, ETag: upload_part_response.ETag }); |
| 174 | + original_string += random_data_buffer.toString(); |
| 175 | + } |
| 176 | + |
| 177 | + const complete_mpu_input = { Bucket: bucket, Key: key, UploadId: create_mpu_response.UploadId, MultipartUpload: { Parts: parts } }; |
| 178 | + const complete_mpu_command = new CompleteMultipartUploadCommand(complete_mpu_input); |
| 179 | + const complete_mpu_response = await s3.send(complete_mpu_command); |
| 180 | + dbg.log0('MPU complete_mpu_response:', complete_mpu_response); |
| 181 | + assert.ok(complete_mpu_response.ETag); |
| 182 | + |
| 183 | + const get_object_res = await get_object(s3, bucket, key); |
| 184 | + assert.equal(get_object_res.body, original_string); |
| 185 | + |
| 186 | + await delete_object(s3, bucket, key); |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * validate_request_headers - Middleware to log and validate request headers |
| 191 | + * @param {S3Client} s3 |
| 192 | + * @returns {Promise<Object>} - Returns the request headers after the middleware is executed |
| 193 | + */ |
| 194 | +async function validate_request_headers(s3) { |
| 195 | + let request_headers; |
| 196 | + s3.middlewareStack.add( |
| 197 | + (next, context) => async args => { |
| 198 | + const command = context.commandName; |
| 199 | + const object_key = args.input.Key; |
| 200 | + const is_chunked_upload_by_key = object_key !== non_chunked_upload_key; |
| 201 | + const is_upload_command = command === 'PutObjectCommand' || command === 'UploadPartCommand'; |
| 202 | + if (is_chunked_upload_by_key && is_upload_command) { |
| 203 | + const upload_req_headers = args.request.headers; |
| 204 | + // NooBaa checks if the transfer is chunked by checking the content-encoding header |
| 205 | + // in this test file we artificially add aws-chunked to the content-encoding header |
| 206 | + // but we expect that the content is not chunked and the transfer-encoding header is not set |
| 207 | + // and the x-amz-decoded-content-length header is not set |
| 208 | + assert.ok(upload_req_headers['content-encoding'].includes('aws-chunked')); |
| 209 | + assert.ok(upload_req_headers['transfer-encoding'].includes('chunked')); |
| 210 | + assert.ok(upload_req_headers['x-amz-decoded-content-length'] !== undefined); |
| 211 | + } |
| 212 | + return next(args); |
| 213 | + }, |
| 214 | + { |
| 215 | + step: 'finalizeRequest', |
| 216 | + name: 'logRequestHeaders', |
| 217 | + priority: 'high', |
| 218 | + } |
| 219 | + ); |
| 220 | + return request_headers; |
| 221 | +} |
| 222 | + |
| 223 | +/** |
| 224 | + * get_object - Get an object from S3 bucket |
| 225 | + * @param {S3Client} s3 |
| 226 | + * @param {String} bucket |
| 227 | + * @param {String} key |
| 228 | + * @returns {Promise<{body: String, ETag: String}>} - The content of the object as a string |
| 229 | + */ |
| 230 | +async function get_object(s3, bucket, key) { |
| 231 | + const get_object_command = new GetObjectCommand({ Bucket: bucket, Key: key }); |
| 232 | + const get_object_response = await s3.send(get_object_command); |
| 233 | + dbg.log0('GetObject response:', _.omit(get_object_response, ['Body'])); |
| 234 | + const body = await get_object_response.Body.transformToString(); |
| 235 | + return { body, ETag: get_object_response.ETag }; |
| 236 | +} |
| 237 | + |
| 238 | +/** |
| 239 | + * delete_object - Delete an object from S3 bucket |
| 240 | + * @param {S3Client} s3 |
| 241 | + * @param {String} bucket |
| 242 | + * @param {String} key |
| 243 | + * @returns {Promise<Void>} - Deletes the object from the S3 bucket |
| 244 | + */ |
| 245 | +async function delete_object(s3, bucket, key) { |
| 246 | + const delete_object_command = new DeleteObjectCommand({ Bucket: bucket, Key: key }); |
| 247 | + const delete_object_response = await s3.send(delete_object_command); |
| 248 | + dbg.log0('Delete response:', delete_object_response); |
| 249 | +} |
0 commit comments