diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index c62ace060a..eece6848e9 100644 --- a/lib/api/apiUtils/authorization/prepareRequestContexts.js +++ b/lib/api/apiUtils/authorization/prepareRequestContexts.js @@ -261,6 +261,23 @@ function prepareRequestContexts(apiMethod, request, sourceBucket, if (requestedAttributes.filter(attr => attr != 'RestoreStatus').length > 0) { requestContexts.push(generateRequestContext('listObjectsV2OptionalAttributes')); } + } else if (apiMethodAfterVersionCheck === 'objectGetAttributes') { + if (request.headers['x-amz-version-id']) { + requestContexts.push( + generateRequestContext('objectGetVersion'), + generateRequestContext('objectGetVersionAttributes'), + ); + } else { + requestContexts.push( + generateRequestContext('objectGet'), + generateRequestContext('objectGetAttributes'), + ); + } + + const attributes = request.headers['x-amz-object-attributes']?.split(',') ?? []; + if (attributes.some(attr => attr.trim().toLowerCase().startsWith('x-amz-meta-'))) { + requestContexts.push(generateRequestContext('objectGetAttributesCustom')); + } } else { const requestContext = generateRequestContext(apiMethodAfterVersionCheck); diff --git a/lib/api/apiUtils/object/extractUserMetadata.js b/lib/api/apiUtils/object/extractUserMetadata.js new file mode 100644 index 0000000000..06aa1b9a79 --- /dev/null +++ b/lib/api/apiUtils/object/extractUserMetadata.js @@ -0,0 +1,23 @@ +/** + * extractUserMetadata - Extract requested user metadata from object metadata + * @param {object} metadata - source metadata object with x-amz-meta-* keys + * @param {Set} attributes - requested attributes (with x-amz-meta- prefix) + * @returns {object} - object containing requested user metadata key-value pairs + */ +function extractUserMetadata(metadata, attributes) { + const result = {}; + + const isWildcard = attributes.has('x-amz-meta-*'); + const sourceKeys = isWildcard ? Object.keys(metadata) : attributes; + + for (const key of sourceKeys) { + const isValidKey = isWildcard ? key.startsWith('x-amz-meta-') : true; + if (isValidKey && metadata[key] != null) { + result[key] = metadata[key]; + } + } + + return result; +} + +module.exports = extractUserMetadata; diff --git a/lib/api/apiUtils/object/parseAttributesHeader.js b/lib/api/apiUtils/object/parseAttributesHeader.js index 685ac93884..90627bf520 100644 --- a/lib/api/apiUtils/object/parseAttributesHeader.js +++ b/lib/api/apiUtils/object/parseAttributesHeader.js @@ -1,25 +1,43 @@ const { errorInstances } = require('arsenal'); -const { supportedGetObjectAttributes } = require('../../../../constants'); /** - * parseAttributesHeaders - Parse and validate the x-amz-object-attributes header - * @param {object} headers - request headers - * @returns {Set} - set of requested attribute names - * @throws {Error} - InvalidRequest if header is missing/empty, InvalidArgument if attribute is invalid + * Parse and validate attribute headers from a request. + * @param {object} headers - Request headers object + * @param {string} headerName - Name of the header to parse (e.g., 'x-amz-object-attributes') + * @param {Set} supportedAttributes - Set of valid attribute names + * @returns {string[]} Array of validated attribute names + * @throws {arsenal.errors.InvalidRequest} When header is required but missing/empty + * @throws {arsenal.errors.InvalidArgument} When an invalid attribute name is specified + * @example + * // Input headers: + * { 'headerName': 'ETag, ObjectSize, x-amz-meta-custom' } + * + * // Parsed result: + * ['ETag', 'ObjectSize', 'x-amz-meta-custom'] */ -function parseAttributesHeaders(headers) { - const attributes = headers['x-amz-object-attributes']?.split(',').map(attr => attr.trim()) ?? []; - if (attributes.length === 0) { - throw errorInstances.InvalidRequest.customizeDescription( - 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', - ); +function parseAttributesHeaders(headers, headerName, supportedAttributes) { + const rawValue = headers[headerName]; + if (rawValue === null || rawValue === undefined) { + return new Set(); } - if (attributes.some(attr => !supportedGetObjectAttributes.has(attr))) { - throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); + const result = new Set(); + + for (const rawAttr of rawValue.split(',')) { + let attr = rawAttr.trim(); + + if (!supportedAttributes.has(attr)) { + attr = attr.toLowerCase(); + } + + if (!attr.startsWith('x-amz-meta-') && !supportedAttributes.has(attr)) { + throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); + } + + result.add(attr); } - return new Set(attributes); + return result; } module.exports = parseAttributesHeaders; diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index c40029f017..35129f9141 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -10,6 +10,10 @@ const { pushMetric } = require('../utapi/utilities'); const versionIdUtils = versioning.VersionID; const monitoring = require('../utilities/monitoringHandler'); const { generateToken, decryptToken } = require('../api/apiUtils/object/continueToken'); +const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader'); +const extractUserMetadata = require('./apiUtils/object/extractUserMetadata'); + +const OPTIONAL_ATTRIBUTES = new Set(['RestoreStatus']); const xmlParamsToSkipUrlEncoding = new Set(['ContinuationToken', 'NextContinuationToken']); @@ -248,34 +252,21 @@ function processMasterVersions(bucketName, listParams, list) { function processOptionalAttributes(item, optionalAttributes) { const xml = []; - const userMetadata = new Set(); - - for (const attribute of optionalAttributes) { - switch (attribute) { - case 'RestoreStatus': - xml.push(''); - xml.push(`${!!item.restoreStatus?.inProgress}`); - - if (item.restoreStatus?.expiryDate) { - xml.push(`${item.restoreStatus?.expiryDate}`); - } - - xml.push(''); - break; - case 'x-amz-meta-*': - for (const key of Object.keys(item.userMetadata)) { - userMetadata.add(key); - } - break; - default: - if (item.userMetadata?.[attribute]) { - userMetadata.add(attribute); - } + + if (optionalAttributes.has('RestoreStatus')) { + xml.push(''); + xml.push(`${!!item.restoreStatus?.inProgress}`); + + if (item.restoreStatus?.expiryDate) { + xml.push(`${item.restoreStatus?.expiryDate}`); } + + xml.push(''); } - for (const key of userMetadata) { - xml.push(`<${key}>${item.userMetadata[key]}`); + const userMetadata = extractUserMetadata(item.userMetadata || {}, optionalAttributes); + for (const [key, value] of Object.entries(userMetadata)) { + xml.push(`<${key}>${value}`); } return xml; @@ -321,15 +312,11 @@ async function bucketGet(authInfo, request, log, callback) { const bucketName = request.bucketName; const v2 = params['list-type']; - const optionalAttributes = - request.headers['x-amz-optional-object-attributes'] - ?.split(',') - .map(attr => attr.trim()) - .map(attr => attr !== 'RestoreStatus' ? attr.toLowerCase() : attr) - ?? []; - if (optionalAttributes.some(attr => !attr.startsWith('x-amz-meta-') && attr != 'RestoreStatus')) { - throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified'); - } + const optionalAttributes = parseAttributesHeaders( + request.headers, + 'x-amz-optional-object-attributes', + OPTIONAL_ATTRIBUTES, + ); if (v2 !== undefined && Number.parseInt(v2, 10) !== 2) { throw errorInstances.InvalidArgument.customizeDescription('Invalid List Type specified in Request'); diff --git a/lib/api/objectGetAttributes.js b/lib/api/objectGetAttributes.js index 696f613baa..7af64ff05d 100644 --- a/lib/api/objectGetAttributes.js +++ b/lib/api/objectGetAttributes.js @@ -1,6 +1,6 @@ const { promisify } = require('util'); const xml2js = require('xml2js'); -const { errors } = require('arsenal'); +const { errors, errorInstances } = require('arsenal'); const { standardMetadataValidateBucketAndObj } = require('../metadata/metadataUtils'); const collectCorsHeaders = require('../utilities/collectCorsHeaders'); const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader'); @@ -8,6 +8,8 @@ const { decodeVersionId, getVersionIdResHeader } = require('./apiUtils/object/ve const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner'); const { pushMetric } = require('../utapi/utilities'); const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo'); +const { supportedGetObjectAttributes } = require('../../constants'); +const extractUserMetadata = require('./apiUtils/object/extractUserMetadata'); const checkExpectedBucketOwnerPromise = promisify(checkExpectedBucketOwner); const validateBucketAndObj = promisify(standardMetadataValidateBucketAndObj); @@ -41,6 +43,9 @@ function buildXmlResponse(objMD, requestedAttrs) { } } + const userMetadata = extractUserMetadata(objMD, requestedAttrs); + Object.assign(attrResp, userMetadata); + const builder = new xml2js.Builder(); return builder.buildObject({ GetObjectAttributesResponse: attrResp }); } @@ -131,7 +136,14 @@ async function objectGetAttributes(authInfo, request, log, callback) { throw err; } - const requestedAttrs = parseAttributesHeaders(headers); + const attrHeader = headers['x-amz-object-attributes']; + if (attrHeader === undefined) { + throw errorInstances.InvalidRequest.customizeDescription( + 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', + ); + } + + const requestedAttrs = parseAttributesHeaders(headers, 'x-amz-object-attributes', supportedGetObjectAttributes, true); if (requestedAttrs.has('Checksum')) { log.debug('Checksum attribute requested but not implemented', { diff --git a/lib/routes/veeam/list.js b/lib/routes/veeam/list.js index 5fd8585bfb..1c4c760259 100644 --- a/lib/routes/veeam/list.js +++ b/lib/routes/veeam/list.js @@ -25,7 +25,7 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) { prefix: validPath, maxKeys: parsedQs['max-keys'] || 1000, delimiter: '/', - optionalAttributes: [], + optionalAttributes: new Set(), }; const list = { IsTruncated: false, diff --git a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js index 8a665dc12e..ca19771efb 100644 --- a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js +++ b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js @@ -9,6 +9,8 @@ const { UploadPartCommand, CompleteMultipartUploadCommand, } = require('@aws-sdk/client-s3'); +const { streamCollector } = require('@smithy/node-http-handler'); +const { parseStringPromise } = require('xml2js'); const withV4 = require('../support/withV4'); const BucketUtility = require('../../lib/utility/bucket-util'); @@ -252,3 +254,286 @@ describe('Test get object attributes with multipart upload', () => { }); }); }); + +describe('objectGetAttributes with user metadata', () => { + withV4(sigCfg => { + let bucketUtil; + let s3; + + const getObjectAttributesWithUserMetadata = async (client, params, attributes) => { + let rawXml = ''; + + const addHeaderMiddleware = next => async args => { + // eslint-disable-next-line no-param-reassign + args.request.headers['x-amz-object-attributes'] = attributes; + return next(args); + }; + + const originalHandler = client.config.requestHandler; + const wrappedHandler = { + async handle(request, options) { + const { response } = await originalHandler.handle(request, options); + + if (response && response.body) { + const collected = await streamCollector(response.body); + const buffer = Buffer.from(collected); + rawXml = buffer.toString('utf-8'); + + const { Readable } = require('stream'); + response.body = Readable.from([buffer]); + } + + return { response }; + }, + }; + + // eslint-disable-next-line no-param-reassign + client.config.requestHandler = wrappedHandler; + client.middlewareStack.add(addHeaderMiddleware, { + step: 'build', + name: 'addObjectAttributesHeader', + }); + + try { + const result = await client.send(new GetObjectAttributesCommand({ + Bucket: params.Bucket, + Key: params.Key, + ObjectAttributes: ['ETag'], + })); + + if (!rawXml) { + return result; + } + + const parsedXml = await parseStringPromise(rawXml); + const parsedData = parsedXml?.GetObjectAttributesResponse; + + if (!parsedData) { + return result; + } + + Object.keys(parsedData).forEach(k => { + if (k.startsWith('x-amz-meta-')) { + result[k] = parsedData[k][0]; + } + }); + + return result; + } finally { + // eslint-disable-next-line no-param-reassign + client.config.requestHandler = originalHandler; + client.middlewareStack.remove('addObjectAttributesHeader'); + } + }; + + before(() => { + bucketUtil = new BucketUtility('default', sigCfg); + s3 = bucketUtil.s3; + }); + + beforeEach(async () => { + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + }); + + afterEach(async () => { + await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + await s3.send(new DeleteBucketCommand({ Bucket: bucket })); + }); + + it('should return specific user metadata when requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + 'custom-key': 'custom-value', + 'another-key': 'another-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-custom-key'); + + assert.strictEqual(response['x-amz-meta-custom-key'], 'custom-value'); + }); + + it('should return multiple user metadata when requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + foo: 'foo-value', + bar: 'bar-value', + baz: 'baz-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-foo,x-amz-meta-bar'); + + assert.strictEqual(response['x-amz-meta-foo'], 'foo-value'); + assert.strictEqual(response['x-amz-meta-bar'], 'bar-value'); + }); + + it('should return all user metadata when x-amz-meta-* is requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'], 'value3'); + }); + + it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,x-amz-meta-*'); + + const metadataKeys = Object.keys(response).filter(k => k.startsWith('x-amz-meta-')); + assert.strictEqual(metadataKeys.length, 0); + }); + + it('should return empty response when requested metadata key does not exist', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + existing: 'value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,x-amz-meta-nonexistent'); + + assert.strictEqual(response['x-amz-meta-nonexistent'], undefined); + }); + + it('should return user metadata along with standard attributes', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + custom: 'custom-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,x-amz-meta-custom,ObjectSize'); + + assert.strictEqual(response.ETag, expectedMD5); + assert.strictEqual(response.ObjectSize, body.length); + assert.strictEqual(response['x-amz-meta-custom'], 'custom-value'); + }); + + it('should not include x-amz-meta-* marker in response when wildcard is used', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + test: 'test-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-*'], undefined); + assert.strictEqual(response['x-amz-meta-test'], 'test-value'); + }); + it('should return all metadata when wildcard is combined with specific metadata key', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*,x-amz-meta-key1'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'], 'value3'); + }); + + it('should handle duplicate wildcard requests without duplicating results', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*,x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], 'value2'); + }); + + it('should handle duplicate specific metadata requests without duplicating results', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-key1,x-amz-meta-key1'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], undefined); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js index 0a08b104f3..865480d61e 100644 --- a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js +++ b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js @@ -396,4 +396,84 @@ describe('prepareRequestContexts', () => { }); }); }); + + describe('objectGetAttributes', () => { + describe('x-amz-object-attributes header', () => { + it('should include scality:GetObjectAttributes with x-amz-meta attribute', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'x-amz-meta-department', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributesCustom'); + }); + + it('should include scality:GetObjectAttributes with multiple attributes', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'x-amz-meta-department,ETag', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributesCustom'); + }); + + it('should not include scality:GetObjectAttributes with only RestoreStatus', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'RestoreStatus', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + }); + + it('should not include scality:GetObjectAttributes without header', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({}); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + }); + }); + + describe('x-amz-version-id header', () => { + it('should return version-specific actions with x-amz-version-id', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-version-id': '0987654323456789', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObjectVersion'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectVersionAttributes'); + }); + + it('should include scality:GetObjectAttributes with x-amz-version-id and x-amz-meta', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-version-id': '0987654323456789', + 'x-amz-object-attributes': 'x-amz-meta-department', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObjectVersion'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectVersionAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributesCustom'); + }); + }); + }); }); diff --git a/tests/unit/api/apiUtils/object/extractUserMetadata.js b/tests/unit/api/apiUtils/object/extractUserMetadata.js new file mode 100644 index 0000000000..67d4a88b23 --- /dev/null +++ b/tests/unit/api/apiUtils/object/extractUserMetadata.js @@ -0,0 +1,117 @@ +const assert = require('assert'); + +const extractUserMetadata = require('../../../../../lib/api/apiUtils/object/extractUserMetadata'); + +describe('extractUserMetadata', () => { + const metadata = { + 'x-amz-meta-color': 'red', + 'x-amz-meta-size': 'large', + 'x-amz-meta-count': '42', + 'content-length': 1024, + 'content-type': 'application/json', + }; + + describe('with wildcard x-amz-meta-*', () => { + it('should return all x-amz-meta-* keys', () => { + const attributes = new Set(['x-amz-meta-*']); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-color': 'red', + 'x-amz-meta-size': 'large', + 'x-amz-meta-count': '42', + }); + }); + + it('should not include non-metadata keys', () => { + const attributes = new Set(['x-amz-meta-*']); + const result = extractUserMetadata(metadata, attributes); + + assert.strictEqual(result['content-length'], undefined); + assert.strictEqual(result['content-type'], undefined); + }); + + it('should return empty object when no metadata keys exist', () => { + const noMetadata = { 'content-length': 1024 }; + const attributes = new Set(['x-amz-meta-*']); + const result = extractUserMetadata(noMetadata, attributes); + + assert.deepStrictEqual(result, {}); + }); + }); + + describe('with specific attribute names', () => { + it('should return only requested metadata keys', () => { + const attributes = new Set(['x-amz-meta-color']); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-color': 'red', + }); + }); + + it('should return multiple requested keys', () => { + const attributes = new Set(['x-amz-meta-color', 'x-amz-meta-count']); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-color': 'red', + 'x-amz-meta-count': '42', + }); + }); + + it('should ignore non-existent keys', () => { + const attributes = new Set(['x-amz-meta-color', 'x-amz-meta-nonexistent']); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-color': 'red', + }); + }); + + it('should ignore non-metadata attributes in the set', () => { + const attributes = new Set(['x-amz-meta-color', 'RestoreStatus', 'ETag']); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-color': 'red', + }); + }); + }); + + describe('edge cases', () => { + it('should return empty object for empty metadata', () => { + const attributes = new Set(['x-amz-meta-*']); + const result = extractUserMetadata({}, attributes); + + assert.deepStrictEqual(result, {}); + }); + + it('should return empty object for empty attributes', () => { + const attributes = new Set(); + const result = extractUserMetadata(metadata, attributes); + + assert.deepStrictEqual(result, {}); + }); + + it('should return keys with falsy values except null and undefined', () => { + const metadataWithFalsy = { + 'x-amz-meta-empty': '', + 'x-amz-meta-null': null, + 'x-amz-meta-undefined': undefined, + 'x-amz-meta-zero': 0, + 'x-amz-meta-false': false, + 'x-amz-meta-valid': 'value', + }; + const attributes = new Set(['x-amz-meta-*']); + const result = extractUserMetadata(metadataWithFalsy, attributes); + + assert.deepStrictEqual(result, { + 'x-amz-meta-empty': '', + 'x-amz-meta-zero': 0, + 'x-amz-meta-false': false, + 'x-amz-meta-valid': 'value', + }); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/object/parseAttributesHeader.js b/tests/unit/api/apiUtils/object/parseAttributesHeader.js index b722668880..76413f8d64 100644 --- a/tests/unit/api/apiUtils/object/parseAttributesHeader.js +++ b/tests/unit/api/apiUtils/object/parseAttributesHeader.js @@ -2,210 +2,46 @@ const assert = require('assert'); const parseAttributesHeaders = require('../../../../../lib/api/apiUtils/object/parseAttributesHeader'); -describe('parseAttributesHeaders', () => { - describe('missing or empty header', () => { - it('should throw InvalidRequest error when header is missing', () => { - const headers = {}; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidRequest, true); - assert.strictEqual( - err.description, - 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', - ); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header is empty string', () => { - const headers = { 'x-amz-object-attributes': '' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header contains only whitespace', () => { - const headers = { 'x-amz-object-attributes': ' ' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header contains only commas', () => { - const headers = { 'x-amz-object-attributes': ',,,' }; +const headerName = 'x-amz-object-attributes'; +const allowedAttributes = new Set(['ETag', 'StorageClass', 'ObjectSize']); - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); +describe('parseAttributesHeaders', () => { + it('should throw InvalidArgument when attribute is invalid', () => { + const headers = { [headerName]: 'InvalidAttribute' }; + + assert.throws( + () => parseAttributesHeaders(headers, headerName, allowedAttributes), + err => { + assert.strictEqual(err.is.InvalidArgument, true); + return true; + }, + ); }); - describe('invalid attribute names', () => { - it('should throw InvalidArgument error for single invalid attribute', () => { - const headers = { 'x-amz-object-attributes': 'InvalidAttribute' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when one attribute is invalid among valid ones', () => { - const headers = { 'x-amz-object-attributes': 'ETag,InvalidAttribute,ObjectSize' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + it('should return empty array when header is missing', () => { + const result = parseAttributesHeaders({}, headerName, allowedAttributes); - it('should throw InvalidArgument error for multiple invalid attributes', () => { - const headers = { 'x-amz-object-attributes': 'Invalid1,Invalid2' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.deepStrictEqual(result, new Set([])); }); - describe('valid attribute names', () => { - it('should return set with single valid attribute ETag', () => { - const headers = { 'x-amz-object-attributes': 'ETag' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag'])); - }); - - it('should return set with single valid attribute StorageClass', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass' }; - const result = parseAttributesHeaders(headers); + it('should parse valid attributes', () => { + const headers = { [headerName]: 'ETag,ObjectSize,x-amz-meta-custom,x-amz-meta-*' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['StorageClass'])); - }); - - it('should return set with single valid attribute ObjectSize', () => { - const headers = { 'x-amz-object-attributes': 'ObjectSize' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ObjectSize'])); - }); - - it('should return set with single valid attribute ObjectParts', () => { - const headers = { 'x-amz-object-attributes': 'ObjectParts' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ObjectParts'])); - }); - - it('should return set with single valid attribute Checksum', () => { - const headers = { 'x-amz-object-attributes': 'Checksum' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['Checksum'])); - }); - - it('should return set with multiple valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'ETag,ObjectSize,StorageClass' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize', 'StorageClass'])); - }); - - it('should return set with all valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass,ObjectSize,ObjectParts,Checksum,ETag' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.strictEqual(result.size, 5); - assert(result.has('StorageClass')); - assert(result.has('ObjectSize')); - assert(result.has('ObjectParts')); - assert(result.has('Checksum')); - assert(result.has('ETag')); - }); + assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize', 'x-amz-meta-custom', 'x-amz-meta-*'])); }); - describe('whitespace handling', () => { - it('should trim whitespace around attribute names', () => { - const headers = { 'x-amz-object-attributes': ' ETag , ObjectSize ' }; - const result = parseAttributesHeaders(headers); + it('should lowercase attributes not in allowedAttributes', () => { + const headers = { [headerName]: 'ETag,X-AMZ-META-CUSTOM' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize'])); - }); - - it('should throw InvalidArgument for extra commas between attributes', () => { - const headers = { 'x-amz-object-attributes': 'ETag,,ObjectSize' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.deepStrictEqual(result, new Set(['ETag', 'x-amz-meta-custom'])); + }); - it('should throw InvalidArgument for leading and trailing commas', () => { - const headers = { 'x-amz-object-attributes': ',ETag,ObjectSize,' }; + it('should trim whitespace around attribute names', () => { + const headers = { [headerName]: ' ETag , ObjectSize ' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize'])); }); }); diff --git a/tests/unit/api/objectGetAttributes.js b/tests/unit/api/objectGetAttributes.js index b9e756f991..04ab1f2129 100644 --- a/tests/unit/api/objectGetAttributes.js +++ b/tests/unit/api/objectGetAttributes.js @@ -348,6 +348,179 @@ describe('objectGetAttributes API with multipart upload', () => { }); }); +describe('objectGetAttributes API with user metadata', () => { + beforeEach(async () => { + cleanup(); + await bucketPutAsync(authInfo, testPutBucketRequest, log); + }); + + const createObjectWithMetadata = async (metadata = {}) => { + const testPutObjectRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + 'content-length': `${postBody.length}`, + ...metadata, + }, + parsedContentLength: postBody.length, + url: `/${bucketName}/${objectName}`, + }, + postBody, + ); + await objectPutAsync(authInfo, testPutObjectRequest, undefined, log); + }; + + it('should return specific user metadata when requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-custom-key': 'custom-value', + 'x-amz-meta-another-key': 'another-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-custom-key']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-custom-key'][0], 'custom-value'); + }); + + it('should return multiple user metadata when requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-foo': 'foo-value', + 'x-amz-meta-bar': 'bar-value', + 'x-amz-meta-baz': 'baz-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-foo', 'x-amz-meta-bar']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-foo'][0], 'foo-value'); + assert.strictEqual(response['x-amz-meta-bar'][0], 'bar-value'); + }); + + it('should return all user metadata when x-amz-meta-* is requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + 'x-amz-meta-key3': 'value3', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-key1'][0], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'][0], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'][0], 'value3'); + }); + + it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => { + await createObjectWithMetadata({}); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + const metadataKeys = Object.keys(response).filter(k => k.startsWith('x-amz-meta-')); + assert.strictEqual(metadataKeys.length, 0); + }); + + it('should return empty response when requested metadata key does not exist', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-existing': 'value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-nonexistent']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-nonexistent'], undefined); + }); + + it('should return user metadata along with standard attributes', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-custom': 'custom-value', + }); + + const testGetRequest = createGetAttributesRequest(['ETag', 'x-amz-meta-custom', 'ObjectSize']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response.ETag[0], expectedMD5); + assert.strictEqual(response.ObjectSize[0], String(body.length)); + assert.strictEqual(response['x-amz-meta-custom'][0], 'custom-value'); + }); + + it('should not include x-amz-meta-* marker in response when wildcard is used', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-test': 'test-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-*'], undefined); + assert.strictEqual(response['x-amz-meta-test'][0], 'test-value'); + }); + + it('should return all metadata when wildcard is combined with specific metadata key', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + 'x-amz-meta-key3': 'value3', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*', 'x-amz-meta-key1']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-key1'][0], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'][0], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'][0], 'value3'); + }); + + it('should handle duplicate wildcard requests without duplicating results', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*', 'x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-key1'][0], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'][0], 'value2'); + }); + + it('should handle duplicate specific metadata requests without duplicating results', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-key1', 'x-amz-meta-key1']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-key1'][0], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], undefined); + }); +}); + describe('objectGetAttributes API with versioning', () => { const enableVersioningRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled');